Skip to content

3-4. PID Namespaceとfork

さあ、いよいよ3章の大詰め、PID Namespaceです。
(UTC、Mount、PID以外のNamespaceは、効果を確かめにくいため今回は扱いません)

PID Namespaceを分けてみる

PID Namespaceは非常に強力なNamespaceで、プロセスIDの採番をやり直すと共に、Namespace外のプロセスを見えない状態にします。
PID Namespaceを分けるとNamespace内のinitプロセス (PID: 1) が作られ、そこをルートとした新しいプロセスIDの採番がなされます。

ひとまず先程までと同じ要領でNamespaceを分けてみましょう!

想定解答

想定解答
go
// runサブコマンド
func runCommand(c Config) error {
  // cgroupの設定
  if err := SetupCgroup(c.Name, os.Getpid(), c.Cgroup); err != nil {
    return errors.WithStack(err)
  }

  // Namespaceを分離
  if err := unix.Unshare(unix.CLONE_NEWUTS | unix.CLONE_NEWNS); err != nil { 
  if err := unix.Unshare(unix.CLONE_NEWUTS | unix.CLONE_NEWNS | unix.CLONE_NEWPID); err != nil { 
    return errors.WithStack(err)
  }

  // rootfsの設定
  if err := SetupRootfs(c.Rootfs); err != nil {
    return errors.WithStack(err)
  }

  // 作成した簡易コンテナ内でエントリーポイントを実行
  path, err := exec.LookPath(c.EntryPoint[0])
  if err != nil {
    return errors.WithStack(err)
  }
  if err := unix.Exec(path, c.EntryPoint, os.Environ()); err != nil {
    return errors.WithStack(err)
  }

  return nil
}

一筋縄ではいかないPID Namespace

それではコンテナを起動してみましょう。
Namespaceの分離にはroot権限が必要なので、sudo suを実行してrootになってからプログラムを実行して下さい。

console
$ sudo su
# make run
go build -o main *.go
./main run bash

...おそらくどの環境でも何かしらのエラーが出たかと思います。
特にDev Containerで取り組んでいる皆さんは、以下のようなエラーが高速で繰り返し表示されたはずです (ctrl(cmd) + cを長押しで止まる時がありますが、止まらなければPIDを調べてkillするかDev Containerを再起動して下さい)。

plaintext
bash: fork: Cannot allocate memory
bash: cannot make pipe for command substitution: Too many open files

運良く起動できた方は、PIDの採番がやり直されているか確かめてみましょう。

console
$ sudo su
# make run
go build -o main *.go
./main run bash
bash: fork: Cannot allocate memory
# echo $$
184720
#

どうやらPIDも振り直されてなさそうです。
3-1と同様にプロセスツリーを表示すると、echo $$で表示されたPIDはホスト側のPIDで、PIDが振り直されていないことがわかります (プロセスツリーの確認時は、sudo suした後のシェルpstreeに入れるPIDを取得するのがおススメです)。

挙動確認用シェル

plaintext
Every 1.0s: pstree -p 184552

bash(184552)---make(184719)---bash(184720)

これは想定内の挙動であり、PID Namespaceの特殊な仕様によるものです。

PID Namespaceの特殊仕様

PID Namespaceは、分離してから子プロセスを生成しないと正しく動かない仕様になっています。
Namespaceを分離した後、生成された子プロセスがNamespace内のinitプロセス (PID: 1) になります。

PID Namespaceの仕様

Linuxの都合上、生成されたプロセスの自認しているPIDを後から変更するのが不可能なため、このような仕様になっているそうです。

PID Namespaceを正しく実装する

では、PID Namespaceを正しく動かしてみましょう!
以下フローチャートのような処理の流れを実装して下さい。

実装するロジック

ヒント1

子プロセスを生やしたいときはos/exec.Cmdが使えましたね!
今回は新しいサブコマンドを作った上で、os/exec.Cmdを用いて自分自身のサブコマンドを呼び出すという方法が最適解のはずです。

ヒント2

/proc/self/exeは、常に自分自身のバイナリを指します。

ヒント3

Go側の都合で、PID Namespaceを分離した後にexec.Cmdの実行はできません

exec.CmdにはSysProcAttrという*unix.SysProcAttr型のフィールドがあります。
この中のCloneflagsというフィールドにunix.Unshare()に渡したのと同じflagsを渡すと、子プロセスを生成する際flagsで指定したNamespaceを分けながら生成してくれます。

想定解答

想定解答
go
func main() {
  // ~~~~~~~省略~~~~~~~

  // 指定されたサブコマンドの実行
  switch os.Args[1] {
  case "run":
    if err := runCommand(c); err != nil {
      log.Fatalln(errors.StackTraces(err))
    }

  case "init": 
    if err := initCommand(c); err != nil { 
      log.Fatalln(errors.StackTraces(err)) 
    } 

  default:
    log.Fatalf("unknown command: %s", os.Args[1])
  }
}

// runサブコマンド
func runCommand(c Config) error {
  // cgroupの設定
  if err := SetupCgroup(c.Name, os.Getpid(), c.Cgroup); err != nil {
    return errors.WithStack(err)
  }

  // exec.Cmdを使って自分自身を呼び出す
  cmd := exec.Command("/proc/self/exe", "init") 
  cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr 
  // Go側の都合で、PID Namespaceを分離した後にexec.Cmdの実行はできないので
  // PID Namespaceを分離しながら呼びだすようSysProcAttrを設定する
  cmd.SysProcAttr = &unix.SysProcAttr{ 
    Cloneflags: unix.CLONE_NEWPID, 
  } 
  if err := cmd.Run(); err != nil { 
    return errors.WithStack(err) 
  } 

  return nil
} 

// initサブコマンド
func initCommand(c Config) error { 
  // Namespaceを分離
  if err := unix.Unshare(unix.CLONE_NEWUTS | unix.CLONE_NEWNS | unix.CLONE_NEWPID); err != nil { 
  if err := unix.Unshare(unix.CLONE_NEWUTS | unix.CLONE_NEWNS); err != nil { 
    return errors.WithStack(err)
  }

  // rootfsの設定
  if err := SetupRootfs(c.Rootfs); err != nil {
    return errors.WithStack(err)
  }

  // 作成した簡易コンテナ内でエントリーポイントを実行
  path, err := exec.LookPath(c.EntryPoint[0])
  if err != nil {
    return errors.WithStack(err)
  }
  if err := unix.Exec(path, c.EntryPoint, os.Environ()); err != nil {
    return errors.WithStack(err)
  }

  return nil
}

Namespaceが分かれたことを確かめる

実行したシェルのPIDが1になっていることを確かめましょう。
Namespaceの分離にはroot権限が必要なので、sudo suを実行してrootになってからプログラムを実行して下さい。

console
$ sudo su
# make run
go build -o main *.go
./main run bash
# echo $$
1
#

実行したエントリーポイントがPID 1のinitプロセスになっていることが確認できましたね!

なお、psコマンドを実行するとまだホスト側の全プロセスが見えてしまいますが、これの修正には/procディレクトリの再マウントが必要です。
/procディレクトリ周りについては次の章で詳しく解説します。