🐳

コンテナに使用されているLinuxカーネル技術 & 簡易コンテナ実装

2023/05/20に公開

コンテナとは

コンテナとは、仮想化の一種で、複数のアプリケーションを単一のホストOS上で互いに干渉することなく実行できるようにするものです。コンテナは、アプリケーションとその依存関係を1つのイメージにパッケージ化し、異なる環境でも一貫してアプリケーションを展開・実行することを容易にします。コンテナはVMとよく比較されますが、どちらも1つのホスト上で複数の分離された環境を実行できる技術だからです。しかし、コンテナは、ホストのOSを共有するため、より軽量化されます。その結果、起動時間が短縮され、リソースの使用量が減り、パフォーマンスが向上が見込めます。

Linuxカーネル技術

コンテナは、Linuxカーネルが提供するいくつかの機能とサブシステムを基盤としています。これらの機能により、コンテナ技術に不可欠なプロセス分離、リソース管理、およびセキュリティが可能になります。Linuxでコンテナを実装するために使用される主要な機能について調査していきます。

namespace

Linuxにおけるnamespaceはプロセスの見ることができるリソースに制限をかけることができます。例えばプロセスID、ネットワークインターフェイス、マウントポイント、ユーザーIDなど、さまざまなシステムリソースの個別のインスタンスを作成することにより、プロセスの分離を実現することができます。各コンテナは独自のネームスペース内で実行され、コンテナ内のプロセスはそのネームスペース内のリソースのみを参照および操作できるようになります。この分離により、コンテナ間の干渉を防ぎ、セキュリティを向上させることができます。

名前空間 定数 概要
UTS(Unix Time Sharing) CLONE_NEWUTS ホスト名とドメイン名を分離
PID(Process ID) CLONE_NEWPID プロセスIDの番号空間を分離
Network CLONE_NEWNET ネットワークインターフェイス、ルーティングテーブルを分離
Mount CLONE_NEWNS ファイルシステムのマウントポイントを分離
IPC(Interprocess Communication) CLONE_NEWIPC メッセージキュー、共有メモリー、セマフォなどのIPCリソースを分離
User CLONE_NEWUSER ユーザーID、グループIDを分離

namespace API

namespaceのAPIとして以下のシステムコールがあります。

syscall 説明
clone(2) clone(2) システムコールは新しいプロセスを作成する
setns(2) setns(2) システムコールを使うと、呼び出したプロセスを既存の名前空間に参加させることができる
unshare(2) unshare(2) システムコールは、 呼び出したプロセスを新しい名前空間に移動する

この説明がイラスト付きで理解しやすいです。

Linuxコマンドで確認してみる

ここでは例としてPID namespaceを使用して、PIDが分離されているかを確認したいと思います。unshareというコマンドを使うことで新規のnamespaceを作成することが可能です。ここでは--pidオプションを使用し、新規PID namespaceを作成します。まず$ps axでroot pid namespaceを確認します(一部省略)。

$ ps ax
     PID TTY      STAT   TIME COMMAND
      1 ?        Ss     0:16 /sbin/init
      2 ?        S      0:00 [kthreadd]
      3 ?        I<     0:00 [rcu_gp]
      ~~~
  74062 pts/0    R+     0:00 ps -ax

このようにたくさんのプロセスを確認できました。次にunshareコマンドを使用し、
PID namespaceを分離します。

$ sudo unshare --pid --mount-proc --fork /bin/bash
# ps ax
    PID TTY      STAT   TIME COMMAND
      1 pts/1    S      0:00 /bin/bash
      8 pts/1    R+     0:00 ps -ax
  • --pid pid namespaceを分離する
  • --mount-procプログラムを実行する直前に、procファイルシステムをマウントポイントにマウントする(デフォルトは/proc)
  • --forkunshareプロセスの子プロセスとして実行する

ご覧の通りPIDを分離することができました。最後に分離したPID namespaceがroot pid namespaceからどのように見えているか確認してみましょう。

$ pstree -p | grep unshare
            |           `-sshd(90416)-(省略)-sudo(94574)---unshare(94575)---bash(94576)

root pid namespaceではbashはpid=94576で動いていることが確認できました。

chroot

前節ではunshareを用いてPID namespaceの分離を行ってみました。しかしコンテナ内からホストのファイルシステムが見えてしまっています。

$ sudo unshare --pid --mount-proc --fork /bin/bash
# ls /
  bin   dev  home  lib32  libx32      media  opt   root  sbin  srv  tmp  var
boot  etc  lib   lib64  lost+found  mnt    proc  run   snap  sys  usr

linuxにはrootディレクトリを変更するためのコマンド$chrootが存在しています。rootディレクトリを変更することで、そのディレクトリより上の階層にはアクセス出来ない状態となります。では早速chrootを使用しrootディレクトリを変更してみましょう。

$ mkdir new_root_dir
$ sudo chroot new_root_dir
  chroot: failed to run command ‘/bin/bash’: No such file or directory

エラーが発生してしましました。これは新しいrootディレクトリに/bin/bashファイルが存在していないからです。それどころかchroot実行後のrootディレクトリは空となっています。そのため自分の実行したいコマンドが使えるように実行ファイルを配置する必要があります。ホストのbashシェルとその依存関係をコピーするなどの方法があると思いますが、ここでは$docker exportを使用してalpine linuxを入れます。$docker exportはコンテナのファイルシステムを tarアーカイブとして出力(export)します。

$ docker pull alpine
$ docker create [イメージのID]
$ docker export [コンテナのID] > alpine.tar
$ mv alpine.tar new_root_dir/alpine.tar
$ tar xvf new_root_dir/alpine.tar -C new_root_dir
$ rm new_root_dir/alpine.tar
$ sudo chroot new_root_dir sh
# ls
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

Cgroup(コントロールグループ)

コントロールグループ(cgroup)は、Linuxカーネルの機能で、CPU時間、システムメモリ、ネットワーク帯域幅、またはこれらのリソースの組み合わせなどを、ユーザー定義のプロセスグループに割り当てることを可能とします。また、プロセスグループを監視し、特定のリソースへのアクセスを拒否したり、グループの凍結や再起動も可能です。これは、リソースの分離と制限を可能にするため、Linuxコンテナの背後にある重要な技術です。Linuxカーネルは/sys/fs/cgroupディレクトリ以下にcgroupファイルシステムをマウントしています。ここでは例としてcpuの使用量に上限を設けてみましょう。

Linuxコマンドで確認してみる

まず、cgroup-toolsパッケージをインストールする必要があります。まだインストールしていない場合は、以下のコマンドでインストールすることができます(Ubuntu、Debianの場合)

$ sudo apt-get install cgroup-tools

パッケージがインストールされたら、新たにcgropuを作成することが可能です。ここでは例としてcpuの使用量に上限を設けてみましょう。cpuサブシステムにmy_cgroupという名前のcgroupを作成します。

$ sudo cgcreate -g cpu:/my_cgroup

cgroupを作成したので、パラメータを変更することができます。cpu.cfs_quota_usパラメータにマイクロ秒単位の値を設定することでcpuの使用率に制限をかけることが可能です。デフォルトでは、cpu.cfs_period_usパラメータは1000000(1秒)に設定されているので、cpu.cfs_quota_usを200000に設定すると、cgroupのプロセスがCPU時間の20%に制限されます。

$ echo 200000 | sudo tee /sys/fs/cgroup/cpu/my_cgroup/cpu.cfs_quota_us

作成したcgroupに制限したいプロセスを追加するには、追加したいプロセスのPIDを書き込む(ここではcgroup.procsファイル)だけです。

$ echo [プロセスのPID] | sudo tee /sys/fs/cgroup/cpu/my_cgroup/cgroup.procs

無限ループのプログラムなどを動かすことで、実際にCPU使用率が制限されている事を確認することができます。

Go言語を用いて簡易コンテナをつくってみる

ここまでLinuxカーネルを用いてコンテナがどのように実現されているか確認してきました。
ここではGo言語のsyscallを用いて簡易コンテナを実装してみます。

package main

import (
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"syscall"
)

func main() {
	switch os.Args[1] {
	case "run":
		run()
	case "child":
		child()
	default:
		panic("help")
	}
}

func run() {
	if len(os.Args) < 4 {
		fmt.Printf("Usage: %s <rootfs path> <cgroup name> <command>\n", os.Args[0])
		os.Exit(1)
	}
	fmt.Printf("Running %v \n", os.Args[4])
	//go run main.go child [command...]
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	//新たなnamespaceを作成
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}

	if err := cmd.Run(); err != nil {
		fmt.Printf("Error starting the command - %s\n", err)
		os.Exit(1)
	}
}

func child() {
	fmt.Printf("Child Running %v \n", os.Args[4])
	rootfs := os.Args[2]
	cgroupName := os.Args[3]
	cg(cgroupName)
	//rootディレクトリとカレントディレクトリをrootfsに設定
	syscall.Chroot(rootfs)
	syscall.Chdir("/")
	//新たなホストネームを設定
	syscall.Sethostname([]byte("container"))
	//procをマウント
	syscall.Mount("proc", "proc", "proc", 0, "")
	cmd := exec.Command(os.Args[4], os.Args[5:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	cmd.Run()
}

func cg(cgroupName string) {
	pid := os.Getpid()
	cgroupPath := filepath.Join("/sys/fs/cgroup/cpu", cgroupName)
	if err := os.Mkdir(cgroupPath, 0755); err != nil && !os.IsExist(err) {
		fmt.Printf("Error creating cgroup - %s\n", err)
		os.Exit(1)
	}
	if err := ioutil.WriteFile(filepath.Join(cgroupPath, "tasks"), []byte(strconv.Itoa(pid)), 0644); err != nil {
		fmt.Printf("Error adding the process to the cgroup - %s\n", err)
		os.Exit(1)
	}
	if err := ioutil.WriteFile(filepath.Join(cgroupPath, "cpu.cfs_quota_us"), []byte("200000"), 0644); err != nil {
		fmt.Printf("Error setting the CPU quota - %s\n", err)
		os.Exit(1)
	}
}

実装には1, 2を参考にさせてもらいました。

Discussion