ひらめの日常

日常のメモをつらつらと

コンテナを支える技術 ー namespace

コンテナを支える技術(を自分が勉強した内容)についてまとめていきます。主に次の二つの書籍を参考にし、それにプラスして自分で調べたことをまとめた内容となります。

前回は cgroup についてまとめました。

hiramekun.hatenablog.com

今回は、前回に引き続き Linux のカーネル機能の一つである namespace について扱います。 各種コマンドは Apple M2 Pro, macOS Sonoma 14.5 のホストマシン上に、lima を用いて Ubuntu 26.04 LTS を動かして確認しています。

$ brew install lima
$ limactl start --name=linux template:ubuntu-lts
$ limactl shell linux

https://github.com/lima-vm/lima

namespace

Linux namespace は、1 台のホスト上で動く複数のプロセスに対して、それぞれ別々の見える世界を与えるカーネル機能です。PID(プロセス ID)・ネットワーク・マウントポイント・ホスト名などのシステムリソースを namespace ごとに分離することで、あるプロセスからは自分専用の OS が動いているかのように見せます。Docker や Kubernetes のコンテナ分離は、この namespace の仕組みを土台にしています。

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. -- namespaces(7) - Linux manual page

namespace には以下の種類があります。

  • PID namespace: プロセス群の隔離
  • Mount namespace: マウントポイントリストの隔離
  • Network namespace: ネットワーク関連のリソース隔離
  • UTS namespace: ホスト名等の隔離
  • IPC namespace: プロセス間通信に関するリソースの隔離
  • User namespace: ユーザー/グループや権限等の隔離
  • Cgroup namespace: プロセスから見える cgroup のルートディレクトリの隔離
  • Time namespace: システム起動時刻等のオフセットの隔離

いずれも unshare コマンドを利用することで手軽に試すことができます。unshare(1) - Linux manual page

その一部を見ていきましょう。

UTS namespace

unshare --uts コマンドを使うことで、ホスト名とドメイン名を隔離して設定することができます。試しに hostname を設定してみると、新しい namespace の中では hostname が変わっていますが、抜けると元に戻っていることが分かります。

lima@lima-linux:~$ sudo unshare --uts sh
# hostname
lima-linux
# hostname experiment
# hostname
experiment
# exit
lima@lima-linux:~$ hostname
lima-linux

UTS namespace では、unshare を呼び出したプロセス自身が新しい namespace に移動します。次に見る PID namespace は、この点が異なります。

PID namespace

unshare --pid コマンドを使うことで、プロセスを隔離することができます。実際にやってみましょう。

lima@lima-linux:~$ sudo unshare --pid sh
# whoami
root
# whoami
sh: 2: Cannot fork
# whoami
sh: 3: Cannot fork
# exit

すると、1回目の whoami は成功しますが、2回目以降は Cannot fork で失敗してしまいます。なぜなのでしょうか。unshare(2) の公式ドキュメントを確認してみます。

The calling process is not moved into the new namespace. The first child created by the calling process will have the process ID 1 -- unshare(2) - Linux manual page

unshare(1) は実際に CLI から打つコマンドのことで、unshare(2) は Linux のシステムコールです。unshare(1)unshare(2) の薄いラッパーとして提供されています。

呼び出したプロセスは新しい namespace に移らず、そこから作られた子プロセスが PID 1を持つようになります。先ほどの例では、sudo unshare --pid sh を打った直後はまだ新しい namespace ではなく、次に打った whoami が新しい namespace で PID 1として実行されました。試しに PID を出力してみると、確かにホスト上の PID が出力されていることが分かります。

lima@lima-linux:~$ sudo unshare --pid sh
# echo $$
526679

別ターミナルからプロセス階層を表示すると、unshare が存在しておらず、sh が sudo の子プロセスになっていることが分かります。unshareunshare(2) を呼んだ後、execve(2) で自分自身を sh に置き換えます。プロセスは新しく作られず、中身だけが入れ替わるため、unshare はプロセス階層に残りません。

lima@lima-linux:~$ ps fa
...
 526838 pts/2    S+     0:00  \_ sudo unshare --pid sh
 526840 pts/3    Ss     0:00      \_ sudo unshare --pid sh
 526841 pts/3    S+     0:00          \_ sh
...

その後の whoami コマンドはすぐに終了するため、新しい namespace 内では PID=1 のプロセスがすぐに終了してしまったことになります。この場合、後続のプロセスが全て失敗します。その挙動は pid_namespaces(7) に記載があります。

The first process created in a new namespace (i.e., the process created using clone(2) with the CLONE_NEWPID flag, or the first child created by a process after a call to unshare(2) using the CLONE_NEWPID flag) has the PID 1, and is the "init" process for the namespace (see init(1)). This process becomes the parent of any child processes that are orphaned because a process that resides in this PID namespace terminated (see below for further details). If the "init" process of a PID namespace terminates, the kernel terminates all of the processes in the namespace via a SIGKILL signal. This behavior reflects the fact that the "init" process is essential for the correct operation of a PID namespace. In this case, a subsequent fork(2) into this PID namespace fail with the error ENOMEM; it is not possible to create a new process in a PID namespace whose "init" process has terminated. -- pid_namespaces(7) - Linux manual page

PID=1 のプロセス以降が全て失敗してしまっては実用性がありません。そこで unshare(1) には --fork オプションが用意されています。指定したプログラムを直接実行するのではなく、unshare(2) を呼んだ後に fork し、その子プロセスとして実行します。これにより sh 自身が新しい namespace の PID 1 となり、以降のコマンドを正常に実行できます。

lima@lima-linux:~$ sudo unshare --pid --fork sh
# echo $$
1

また、--fork オプションなしで実行した時と異なり、whoami を何回呼び出しても成功するようになっています。

# whoami
root
# whoami
root

別ターミナルからプロセス階層を表示すると、--fork なしの時とは異なり、unshare が存在しています。--fork を付けると unshare は自分自身を置き換えず、fork して生まれた子プロセスで sh を実行します。そのため unshare が親として階層に残ります。

lima@lima-linux:~$ ps fa
...
 526806 pts/2    S+     0:00  \_ sudo unshare --pid --fork sh
 526808 pts/3    Ss     0:00      \_ sudo unshare --pid --fork sh
 526809 pts/3    S      0:00          \_ unshare --pid --fork sh
 526810 pts/3    S+     0:00              \_ sh
...

さて、docker などでコンテナを立ち上げたときには、ps を実行するとコンテナの PID namespace 内で実行されたプロセスだけが出力されます。それと同じ挙動を期待して unshare で新しい PID namespace を作ってその中で ps を実行してみましょう。

lima@lima-linux:~$ sudo unshare --pid --fork sh
# ps
    PID TTY          TIME CMD
      1 ?        00:07:15 systemd
      2 ?        00:00:05 kthreadd
      3 ?        00:00:00 pool_workqueue_release
      4 ?        00:00:00 kworker/R-rcu_gp
      5 ?        00:00:00 kworker/R-sync_wq
      6 ?        00:00:00 kworker/R-kvfree_rcu_reclaim
...

すると、期待した挙動とは異なり、ホスト全体のプロセスが表示されてしまっていることが分かります。

ps/proc 以下のディレクトリを読んでプロセス一覧を組み立てるプログラムで、自分がどの PID namespace にいるかは見ていません。そして unshare --pid は PID namespace を新しくするだけで、/proc のマウントには手を付けません。そのため ps はホストの /proc を読み、ホスト全体のプロセスを表示します。この /proc を新しい namespace 用にマウントし直す必要があり、そのためにはマウント操作をホストから分離する仕組みが要ります。それが次に扱う Mount namespace です。

Mount namespace

mount namespace は、プロセスから見えるマウントポイントの一覧を分離する仕組みです。この中で行ったマウント・アンマウントは、ホスト側には影響しません。

実際に確認してみましょう。--mount だけを指定して、tmpfs をマウントしてみます。

lima@lima-linux:~$ sudo unshare --mount sh
# mkdir -p /tmp/test
# mount -t tmpfs none /tmp/test
# findmnt /tmp/test
TARGET    SOURCE FSTYPE OPTIONS
/tmp/test none   tmpfs  rw,relatime,inode64

別のターミナルからホスト側を確認すると、このマウントは存在しません。

lima@lima-linux:~$ findmnt /tmp/test
lima@lima-linux:~$

さて、前節で ps がホスト全体のプロセスを表示してしまう問題に行き当たりました。これを解決するのが --mount-proc オプションです。

lima@lima-linux:~$ sudo unshare --pid --fork --mount-proc sh
# ps
    PID TTY          TIME CMD
      1 pts/3    00:00:00 sh
      2 pts/3    00:00:00 ps

新しい PID namespace の中のプロセスだけが表示されるようになりました。unshare(1) のドキュメントによると、このオプションはプログラムを実行する直前に procfs を(既定では /proc に)マウントするもので、新しい mount namespace の作成も同時に行います。

Just before running the program, mount the proc filesystem at mountpoint (default is /proc). This is useful when creating a new PID namespace. It also implies creating a new mount namespace since the /proc mount would otherwise mess up existing programs on the system. -- unshare(1) - Linux manual page

なぜ mount namespace まで必要になるのでしょうか。ここには2つの異なる役割があります。

まず一つ目に、procfs の再マウントが、表示を変えています。 procfs はディスク上に実体を持たず、読まれるたびにカーネルが内容を組み立てる疑似ファイルシステムです。このときどの PID namespace の情報を返すかは、マウントした時点で決まります。pid_namespaces(7) に記載があります。

A /proc filesystem shows (in the /proc/[pid] directories) only processes visible in the PID namespace of the process that performed the mount -- pid_namespaces(7) - Linux manual page

unshare --pid だけでは /proc はホストでマウントされたものを引き継ぐため、ps はホストの情報を読んでいました。新しい PID namespace の中でマウントし直すことで、初めて表示が変わります。

二つ目に、mount namespace が、その副作用を封じ込めています。procfs をマウントし直すだけで良いかというと、そうはいきません。マウントポイントの一覧はシステム全体で共有されているため、/proc を差し替えるとホスト側の ps や systemd まで巻き込んでしまいます。unshare(1) のドキュメントも、/proc のマウントがシステム上の既存のプログラムを壊してしまうため mount namespace の作成を伴う、と説明しています。

Cgroup namespace

cgroup namespace はプロセスが自分の cgroup よりも上位の cgroup ディレクトリの設定を閲覧できないようにするものです。自分の cgroup がルート cgroup として扱われるようになります。

The process will have a virtualized view of /proc/self/cgroup, and new cgroup mounts will be rooted at the namespace cgroup root. -- unshare(1) - Linux manual page

cgroup namespace の外側と内側で /proc/self/cgroup の内容を比較すればその動作を確認できます。このファイルは procfs が提供する疑似ファイルの一つで、「このプロセスは cgroup ツリーのどこにいるか」を返します。

lima@lima-linux:~$ cat /proc/self/cgroup
0::/user.slice/user-501.slice/session-6.scope
lima@lima-linux:~$ sudo unshare --cgroup sh
# cat /proc/self/cgroup
0::/
#

unshare --cgroup を実行した時点で所属していた cgroup が、新しい namespace ではルートとして見えるようになりました。実際にはホストの階層のどこかにいるのに、それを知ることができません。ホスト側から新しい namespace を実行しているプロセスの cgroup を見に行くと実際の階層を見ることができます。

# echo $$
527316

別のターミナルに移り、ホストから確認してみます。

lima@lima-linux:~$ cat /proc/527316/cgroup
0::/user.slice/user-501.slice/session-6.scope

コンテナを支える技術 ー cgroup

コンテナを支える技術(を自分が勉強した内容)についてまとめていきます。主に次の二つの書籍を参考にし、それにプラスして自分で調べたことをまとめた内容となります。

前回は低レベルランタイムについてまとめました。

hiramekun.hatenablog.com

今回は Linux のカーネル機能の一つである cgroup について扱います。 各種コマンドは Apple M2 Pro, macOS Sonoma 14.5 のホストマシン上に、lima を用いて Ubuntu 26.04 LTS を動かして確認しています。

$ brew install lima
$ limactl start --name=linux template:ubuntu-lts
$ limactl shell linux

https://github.com/lima-vm/lima

cgroup

cgroup とは

ここでは Ubuntu 22.04+ 等でデフォルトになっている v2 について扱います。

lima@lima-linux:/sys/fs/cgroup$ stat -fc %T /sys/fs/cgroup
cgroup2fs

cgroup (control groups) は、Linux カーネルが提供する、プロセスグループ単位で CPU・メモリ・I/O などのリソース使用量を制限・計測・分配する機能です。また、そうした管理を階層構造的に扱うことができます。

cgroup is a mechanism to organize processes hierarchically and distribute system resources along the hierarchy in a controlled and configurable manner. -- Control Group v2 — The Linux Kernel documentation

cgroup には大きく分けて cgroup core と cgroup controller という二種類の概念が存在します。cgroup core は主に階層構造的にプロセスを管理する役割を持ち、cgroup controller はその階層構造に従って、特定のシステムリソースを分配する役割を持ちます。

cgroup is largely composed of two parts - the core and controllers. cgroup core is primarily responsible for hierarchically organizing processes. A cgroup controller is usually responsible for distributing a specific type of system resource along the hierarchy although there are utility controllers which serve purposes other than resource distribution.-- Control Group v2 — The Linux Kernel documentation

cgroup の階層構造を理解する

cgroup フォルダの中を見ると、いくつかのファイル・フォルダが存在していることに気づくと思います。それぞれの役割を見ていきながら、「階層構造的に管理する(hierarchically organizing processes)」意味を理解しましょう。

lima@lima-linux:/sys/fs/cgroup$ ls
cgroup.controllers      cpu.pressure           dmem.capacity  memory.numa_stat        proc-sys-fs-binfmt_misc.mount
cgroup.max.depth        cpu.stat               dmem.current   memory.pressure         sys-fs-fuse-connections.mount
cgroup.max.descendants  cpu.stat.local         init.scope     memory.reclaim          sys-kernel-config.mount
cgroup.pressure         cpuset.cpus.effective  io.cost.model  memory.stat             sys-kernel-debug.mount
cgroup.procs            cpuset.cpus.isolated   io.cost.qos    memory.zswap.writeback  sys-kernel-tracing.mount
cgroup.stat             cpuset.mems.effective  io.pressure    misc.capacity           system.slice
cgroup.subtree_control  dev-hugepages.mount    io.prio.class  misc.current            user.slice
cgroup.threads          dev-mqueue.mount       io.stat        misc.peak

cgroup.* は cgroup core のファイル群で、memory.*, cpu.*, pids.* 等は cgroup controller のファイル群です。 cgroup.controllers にはこの cgroup で利用可能なコントローラの一覧が表示されています。 cgroup.subtree_control はどのコントローラを子 cgroup に委譲するかを書き込む設定ファイルです。ここに記載のあるコントローラーがサブディレクトリで制御ファイルとして現れます。

この図を例にして考えます。

cgroup では各ディレクトリが一つのグループとして扱われ、それが木構造になって管理されています。/sys/fs/cgroup がルートグループです。

まずはルートグループの設定を見てみましょう。ここから先は /sys/fs/cgroup 配下への書き込みに root 権限が必要なので sudo -i を打ってから作業します。

lima@lima-linux:/sys/fs/cgroup$ sudo -i
root@lima-linux:~# cd /sys/fs/cgroup
root@lima-linux:/sys/fs/cgroup# cat cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma misc dmem

コントローラは親から子へ委譲されます。ルートで cgroup.subtree_control: cpuset cpu io memory... を指定しているため、子グループにもそれらの制御ファイルが現れます(親グループの cgroup.subtree_control が、子グループの cgroup.controllers になる)。実際にグループを作成してみます。

root@lima-linux:/sys/fs/cgroup# mkdir parent
root@lima-linux:/sys/fs/cgroup# cd parent
root@lima-linux:/sys/fs/cgroup/parent# cat cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc dmem
root@lima-linux:/sys/fs/cgroup/parent# ls
cgroup.controllers      cpuset.cpus.exclusive            hugetlb.32MB.events.local  memory.oom.group
cgroup.events           cpuset.cpus.exclusive.effective  hugetlb.32MB.max           memory.peak
cgroup.freeze           cpuset.cpus.partition            hugetlb.32MB.numa_stat     memory.pressure
cgroup.kill             cpuset.mems                      hugetlb.32MB.rsvd.current  memory.reclaim
...省略

次に、この parentcgroup.subtree_control を制限して、parent 配下に子グループの child を作成してみます。

root@lima-linux:/sys/fs/cgroup/parent# echo "+memory" > /sys/fs/cgroup/parent/cgroup.subtree_control
root@lima-linux:/sys/fs/cgroup/parent# cat cgroup.subtree_control
memory
root@lima-linux:/sys/fs/cgroup/parent# mkdir child
root@lima-linux:/sys/fs/cgroup/parent# ls child
cgroup.controllers      cgroup.stat             io.pressure          memory.numa_stat     memory.swap.high
cgroup.events           cgroup.stat.local       memory.current       memory.oom.group     memory.swap.max
cgroup.freeze           cgroup.subtree_control  memory.events        memory.peak          memory.swap.peak
cgroup.kill             cgroup.threads          memory.events.local  memory.pressure      memory.zswap.current
cgroup.max.depth        cgroup.type             memory.high          memory.reclaim       memory.zswap.max
cgroup.max.descendants  cpu.pressure            memory.low           memory.stat          memory.zswap.writeback
cgroup.pressure         cpu.stat                memory.max           memory.swap.current
cgroup.procs            cpu.stat.local          memory.min           memory.swap.events

parent では cgroup.subtree_control: memory としているため、その子である child には memory.* が並び、制御用のファイルである cpu.max は存在しません。ただし cpu.statcpu.pressureio.pressure 等は残っています。これらは制御用ではなく統計・計測用のファイルで、コントローラの委譲とは無関係にすべての cgroup に存在します。

実際にどのコントローラーが有効になっているかは cgroup.controllers を見れば分かります。parentcgroup.subtree_control に指定した memory だけが有効化されています。

root@lima-linux:/sys/fs/cgroup/parent# cat child/cgroup.controllers
memory

なお、制限値そのものが子にコピーされるわけではありません。上から下へとリソースが配分されていくため、子は自分の memory.max を持ちますが、親より緩い値を書いても実際には親の制限が優先されます。(図にあるように parentmemory.max=100M の時、子に memory.max=500M を設定すること自体はできるが、実際に配分されるのは 100M。)

実際にコンテナプロセスを立ち上げてみる

ここまでは実際に自分で cgroup を mkdir で作成してその挙動を見てきました。しかし、普段コンテナを立ち上げるときに cgroup で mkdir をしている人はいないはずです。新しくコンテナプロセスが立ち上がったときの挙動を確認しましょう。

まず、lscgroup を使って事前の cgroup の状態を保存しておきます。

lima@lima-linux:/sys/fs/cgroup$ find /sys/fs/cgroup -type d | sort > before.txt

次に、別ターミナルで mycontainer という名前でコンテナを立ち上げます。bundle ディレクトリは コンテナを支える技術 ー 低レベルランタイム - ひらめの日常 で用意したものを使います。

lima@lima-linux:~/bundle$ sudo runc run mycontainer
sh-4.4#

再び cgroup を出力し、before と diff をとって比較します。

lima@lima-linux:~$ find /sys/fs/cgroup -type d | sort > after.txt
lima@lima-linux:~$ diff before.txt after.txt
54a55
> /sys/fs/cgroup/user.slice/user-501.slice/mycontainer

すると、user.slice/user-501.slice/mycontainer に新しく cgroup が作成されています。runc コマンドが裏側で cgroup の作成とプロセスのアタッチをしてくれていたのですね。

コンテナプロセスにメモリ制限を適用してみる

次に、このコンテナプロセスに実際にメモリの制限をかけてみましょう。

lima@lima-linux:~$ sudo -i
root@lima-linux:~# cd /sys/fs/cgroup/user.slice/user-501.slice/
root@lima-linux:/sys/fs/cgroup/user.slice/user-501.slice# ls | grep mycontainer
mycontainer

mycontainer ディレクトリ (=cgroup) が作成されていることがわかります。 その中の、memory.max を設定してみます。

root@lima-linux:/sys/fs/cgroup/user.slice/user-501.slice# echo 1M > mycontainer/memory.max
root@lima-linux:/sys/fs/cgroup/user.slice/user-501.slice# cat mycontainer/memory.max
1048576

コンテナ側で見えているルートの memory.max を見てみましょう。コンテナ内から見ると、自分の cgroup がルートとして見えています。これは cgroup namespace の働きによるもので、コンテナは自分がホストの階層のどこに位置しているかを知ることができません。次節で扱う namespace の一種です。

sh-4.4# cat /sys/fs/cgroup/memory.max
1048576

ホスト側で設定した値がルートに設定されていることがわかりますね。さらに小さい値をホストからコンテナプロセスに設定してみましょう。

root@lima-linux:/sys/fs/cgroup/user.slice/user-501.slice# echo 100K > mycontainer/memory.max

すると、非常に少ないメモリ量のため、コンテナプロセスが OOM Killer によって終了してしまうことがわかります。

このようにしてホスト側で設定した cgroup がコンテナプロセスに割り当てられていて、リソースの使使用制限がかけられていることを確認できました。

具体的な使用例として、Kubernetes で Pod に resources.limits.memory: 512Mi を設定すると、kubelet がその Pod のコンテナに対応する cgroup の memory.max を 512Mi に設定し、それを超えて確保しようとしたプロセスは OOM Killer に kill されます。

ここまで cgroup の挙動についてみてきました。次回は namespace についてです!

hiramekun.hatenablog.com

コンテナを支える技術 ー 全体像・高レベルランタイム

コンテナを支える技術(を自分が勉強した内容)についてまとめいきます。主に次の二つの書籍を参考にし、それにプラスして自分で調べたことをまとめた内容となります。今回は全体像の説明とその中の高レベルランタイムについて扱います。

全体像

単純化のために、Kubernetes 上で動くコンテナを対象に考えます。

登場人物を紹介します。

  • kubelet: 各ノードに常駐しており、CRI を通して Pod を実際に動かします。
  • CRI (Container Runtime Interface): kubelet が操作する gRPC API とその仕様のことを指します。kubelet はこの API を介して高レベルランタイムを呼び出しますが、その実装詳細が何であるかは知る必要がありません。
  • 高レベルランタイム: kubelet から Pod 操作に関する指示を受け、それに従ってイメージをレジストリから取得したり、コンテナ群を Pod として作成したりするソフトウェアです。containerd や CRI-O 等があります。最終的に OCI Runtime Spec で必要とされている bundle を組み立てるところまでが仕事です。CRI を介して呼ばれるため CRI ランタイムとも呼ばれています。
  • OCI: コンテナのイメージ形式(OCI Image Spec)と Runtime の振る舞い(OCI Runtime Spec)を定めた業界標準仕様のことです。
  • 低レベルランタイム: コンテナプロセスを実際に起動する役割を担います。runc は Linux のカーネル機能(namespace、cgroup 等)を直接使いますが、runsc はユーザー空間のカーネル実装を、kata-runtime は軽量 VM を用いるなど、実現手段は実装によって異なります。その振る舞いは OCI Runtime Spec に則るため、OCI ランタイムとも呼ばれています。

これ以降では、CRI等によって呼び出される高レベルランタイム・OCIを通して実行される低レベルランタイム・低レベルランタイムを通して呼び出される Linux のカーネル機能についていくつか実装を紹介していきます。

高レベルランタイム

containerd

元々は Docker の一部でしたが、そこから独立して開発が進んでいます。containerd は CRI を実装しているため、Kubernetes 上で containerd を高レベルランタイムとして利用することができます。

現在も Docker はコンテナ実行のために内部で containerd を利用しています。また、GKE・EKS・AKS などマネージド Kubernetes のデフォルトランタイムでもあります。例:containerd ノードのイメージ  |  Google Kubernetes Engine (GKE)  |  Google Cloud Documentation

Docker

Docker は高レベルランタイムに相当します。全体像に示したように、高レベルランタイムを呼び出すために CRI を満たす必要がありますが、実は Docker は CRI を実装していません。その代わりに CRI を実装している Docker の薄いラッパーが提供されており、これを用いることで Kubernetes 上で Docker を高レベルランタイムとして利用することができます。

Kubernetes v1.23 までは kubelet に dockershim が組み込まれていましたが、v1.20 で非推奨が宣言され、v1.24 で削除されました。現在は Docker を使い続けたい場合、別途 cri-dockerd を導入する形になっています。

GitHub - Mirantis/cri-dockerd: dockerd as a compliant Container Runtime Interface for Kubernetes · GitHub

containerd の項目で説明したように、Docker 内部では containerd が動いています。では、Docker をわざわざ高レベルランタイムとして利用する理由はあるのでしょうか?containerd を利用する方が無駄が少なくて良いような気がしますよね。

実は、Kubernetes から Pod を動かす上では containerd で何の問題もありません。困るのは、これまで Docker コマンドを利用して debug をしていた人間やツールです。例えば docker psdocker exec を用いて障害調査をしていた場合、Docker がなくなってしまうと運用手順に支障が出ます。なので、これまで Docker を資産として使ってきており、取り除くのに大きなコストがかかるならば Docker を高レベルランタイムとして利用するのが良いということになります。それ以外は基本的に containerd を使うのが良さそうです。

CRI-O

Kubernetes だけに特化して不要な機能を持たない高レベルランタイムです。CRI 仕様を満たす最小限の機能だけを持ちます。そのため攻撃対象領域が小さく、セキュリティ面を重視する際の選択肢となります。

Red Hat 系(OpenShift)で標準採用されているランタイムです。 第1章 CRI-O コンテナーエンジンの使用 | CRI-O ランタイム | OpenShift Container Platform | 3.11 | Red Hat Documentation

終わりに

高レベルランタイムとして containerd, Docker, CRI-O が使えることを図でまとめると次のようになります。

この記事ではコンテナを支える技術の全体像と、その中の高レベルランタイムとその使い分けを中心に見てきました。次回は低レベルランタイムについてです。気長に待っていただけると嬉しいです!→ 書きました

hiramekun.hatenablog.com

コンテナを支える技術 ー 低レベルランタイム

コンテナを支える技術(を自分が勉強した内容)についてまとめていきます。主に次の二つの書籍を参考にし、それにプラスして自分で調べたことをまとめた内容となります。

前回は全体像と高レベルランタイムについてまとめました。

hiramekun.hatenablog.com

今回は低レベルランタイムについて扱います。 各種コマンドは Apple M2 Pro, macOS Sonoma 14.5 のホストマシン上に、lima を用いて Ubuntu 26.04 LTS を動かして確認しています。

$ brew install lima
$ limactl start --name=linux template:ubuntu-lts
$ limactl shell linux

https://github.com/lima-vm/lima

低レベルランタイム

runc

runc は、OCI を満たす軽量なコンテナランタイムです。namespace, cgroup 等の Linux の機能を用いてプロセスから見えるリソースを分離・制限します(このLinuxの機能については次のブログで詳細を解説します)。Dockerなどの上位レイヤーがseccompフィルターを設定することで利用可能なシステムコールを制限できますが、コンテナ内のプロセスがホストのカーネルに直接システムコールを発行できる構造になっており、セキュリティ上の懸念が残ります。こうした懸念に対処する選択肢として、後述する gVisor や Kata Containers といった別の低レベルランタイムの利用が検討されます。

多くの Linux 環境では、Docker から利用される OCI ランタイムとして runc が用いられています。

試しに runc を用いてコンテナ環境を立ち上げてみましょう。runc はコンテナのルートファイルシステムと、コンテナ実行環境の設定ファイルを必要とします。今回は『イラストでわかるDockerとKubernetes Software Design plus』の例に従って、docker コマンドで作成したコンテナから docker export を用いてルートファイルシステムを取得して実験します。

lima@lima-linux:~$ mkdir -p bundle/rootfs
lima@lima-linux:~$ docker pull centos:8
lima@lima-linux:~$ docker run --rm --name tmp -d centos:8 sleep infinity
lima@lima-linux:~$ docker export tmp | tar -xC bundle/rootfs

ここまでで、コンテナに必要なルートファイルシステムが bundle/rootfs 配下に配置されました。

lima@lima-linux:~$ ls bundle/rootfs
bin  dev  etc  home  lib  lib64  lost+found  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

次に runc spec を用いて、基本的なデフォルト設定が記載された設定ファイル (config.json) を出力します。

lima@lima-linux:~$ runc spec -b bundle
lima@lima-linux:~$ tree -L 1 bundle
bundle
├── config.json
└── rootfs

ここまでで、bundle ディレクトリ配下にルートファイルシステム rootfs と設定ファイル config.json が揃いました。 以上が揃えば、runc run コマンドでコンテナを立ち上げることができます。 -b で bundle を指定して立ち上げます。立ち上がったコンテナの中では新しくルートファイルシステムが認識されており、外のプロセスも見えていないことがわかります。

lima@lima-linux:~$ sudo runc run -b bundle mycentos
sh-4.4# ls
bin  dev  etc  home  lib  lib64  lost+found  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
sh-4.4# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.5  0.0   3800  2864 pts/0    Ss   15:44   0:00 sh
root           8  0.0  0.0   8620  3044 pts/0    R+   15:44   0:00 ps aux

コンテナ内で sleep を実行します。

sh-4.4# sleep 100

そして、別のターミナルからプロセス一覧を見てみましょう。

lima@lima-linux:~$ ps fax
...
 321098 pts/2    S+     0:00      |   \_ sudo runc run -b bundle mycentos
 321100 pts/3    Ss     0:00      |       \_ sudo runc run -b bundle mycentos
 321101 pts/3    Sl+    0:00      |           \_ runc run -b bundle mycentos
 321114 pts/0    Ss     0:00      |               \_ sh
 321122 pts/0    S+     0:00      |                   \_ /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100

runc では、コンテナ内の sh や sleep はホストカーネル上の通常のプロセスとして実行されるため、ホスト側の ps にも現れます。ただし、実行ファイルはホスト環境のルートファイルシステムではなく、コンテナのルートファイルシステムとして使われている bundle/rootfs から読み込まれます。

別のターミナルから次のコマンドを打ってコンテナを停止します。

lima@lima-linux:~$ sudo runc kill mycentos KILL

ちなみに、馴染みある docker コマンドは sudo を必要としないのに、runc は sudo で実行する必要があるのはなぜでしょうか。ここをもう少し詳しく見ていきます。

まず、docker コマンドもデフォルトでは sudo が必要です。何もしていない状態で実行すると、次のように permission denied になります。

$ docker run -it alpine sh
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

多くの環境で不要に見えるのは、公式のインストール手順に含まれる sudo usermod -aG docker $USER が実施済みだからです。これは実行者を docker グループに含めるためのコマンドです。

docs.docker.com

なぜこのコマンドによって sudo なしでコンテナを作成できるようになるのでしょうか。

docker がコンテナを作成するとき、次の仕組みが裏側で動いていることの理解が必要です。

  1. docker コマンドは薄いクライアントにすぎず、Unix ドメインソケット /var/run/docker.sock を経由して、root 権限で常駐している dockerd にリクエストを送る
  2. dockerd が containerd に依頼し、最終的に runc がコンテナを作成する

Docker uses a client-server architecture. The Docker client talks to the Docker daemon, which does the heavy lifting of building, running, and distributing your Docker containers. The Docker client and daemon can run on the same system, or you can connect a Docker client to a remote Docker daemon. The Docker client and daemon communicate using a REST API, over UNIX sockets or a network interface -- What is Docker? | Docker Docs

The Docker daemon can listen for Docker Engine API requests via three different types of Socket: unix, tcp, and fd. By default, a unix domain socket (or IPC socket) is created at /var/run/docker.sock, requiring either root permission, or docker group membership. -- dockerd | Docker Docs

つまりコンテナを作っているのは、実行者のプロセスではなく root で動く dockerd です。ユーザーが必要なのは、そのソケットに接続する権限だけということになります。そのソケットに接続する権限を得るために、docker グループのメンバーであることが必要なのです。実際に socket の権限を覗いてみます。

lima@lima-linux:~$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Aug 17 00:31 /var/run/docker.sock

所有者は root、所有グループは docker、その他には権限がありません。ソケットへの接続は書き込み扱いなので、root か docker グループのメンバーでなければ拒否されます。usermod -aG docker が必要だったのはこのためです。

一方 runc には、特権作業を代行してくれるデーモンがいません。次回のブログで紹介する namespace や cgroup の作成が必要で、これらには root 権限が必要です。CLI が直接カーネルを叩くため、実行者自身が特権を持つ必要がある。だから runc では sudo が必要だったのでした。

gVisor

gVisor は Google 発のコンテナランタイムで、GKE Sandbox で利用でき、Cloud Run の第1世代実行環境にも採用されています。(なお、 Cloud Run 第2世代では使われていません。参考:あらゆるニーズに応えるサーバーレス: Cloud Run ジョブと第 2 世代の実行環境が一般提供に | Google Cloud 公式ブログ

gVisor はユーザー空間カーネルと呼ばれる技術を使っており、Go言語で Linux のシステムコールの多くがユーザー空間で再実装されています

gVisor acts as an application kernel, but runs in userspace. This means it takes the role that a kernel would from the perspective of a sandboxed workload, while gVisor itself otherwise acts as a regular user application from the host kernel’s perspective. -- Introduction to gVisor security - gVisor

このユーザー空間カーネルの実装は Sentry と呼ばれており、アプリケーションが発行したシステムコールがこの Sentry 上で実行されます。また、Sentry からホストOSへのシステムコール発行は Linux の機能を用いて制限されており、コンテナからホストOSに到達するシステムコールが制限されています。ファイルアクセスに関連するシステムコールについては Sentry から直接触れるようにはなっておらず、Gofer と呼ばれる別のプロセスに処理を委譲します。

gVisor を Docker/Kubernetes 等から使うための OCI ランタイムとして、runsc があります。runc と同じ OCI ランタイムインターフェースを実装しているため、runc の代わりに使うことができます。実際に使ってみましょう。

公式に従って runsc をインストールします。Installation - gVisor

$ sudo apt-get update && \
sudo apt-get install -y \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg
$ curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list > /dev/null
$ sudo apt-get update && sudo apt-get install -y runsc

先ほど runc で使っていた bundle フォルダを再利用します。runsc spec コマンドが config.json を作成するので、runc 用に作成した config.json は名前を変えておきます。また、runc とは異なり -bundle で bundle フォルダを指定します。

lima@lima-linux:~$ mv bundle/config.json bundle/config.json.runc
lima@lima-linux:~$ runsc spec -bundle bundle # これで config.json が作成される
lima@lima-linux:~$ sudo runsc run -bundle bundle mycentos
sh: cannot set terminal process group (-1): Inappropriate ioctl for device
sh: no job control in this shell
sh-4.4#

このコンテナ内で sleep コマンドを実行します。

sh-4.4# sleep 100

そして、別ターミナルでホスト側から見たプロセスを表示します。

lima@lima-linux:~$ ps fax
...
 320508 pts/2    S+     0:00      |       \_ sudo runsc run -bundle bundle mycentos
 320510 pts/3    Ss     0:00      |           \_ sudo runsc run -bundle bundle mycentos
 320511 pts/3    Sl+    0:00      |               \_ runsc run -bundle bundle mycentos
 320519 ?        Ssl    0:00      |                   \_ runsc-gofer --root=/var/run/runsc gofer --bundle bundle --gofer-mount-confs=...
 320524 ?        Ssl    0:00      |                   \_ runsc-sandbox --root=/var/run/runsc boot --bundle=bundle --gofer-mount-confs=...
 320546 ?        Ss     0:00      |                       \_ [gvisor_sentry]
 320550 ?        S      0:00      |                       \_ [gvisor_sentry]
 320551 ?        SN     0:00      |                       |   \_ [gvisor_sentry]
 320558 ?        S      0:00      |                       \_ [gvisor_sentry]
 320559 ?        SN     0:00      |                       |   \_ [gvisor_sentry]
 320561 ?        S      0:00      |                       \_ [gvisor_sentry]
 320562 ?        SN     0:00      |                           \_ [gvisor_sentry]

runsc run プロセスは、runsc-gofer (Gofer用) と runsc-sandbox (Sentry用) の二つの子プロセスを作成しています。ここで、実際に Sentry での実行ファイルを確認すると、いずれも gvisor_sentry を参照しており、sleep 実行ファイルは参照されていないことが分かります。

lima@lima-linux:~$ sudo ls -l /proc/320546/exe
lrwxrwxrwx 1 root root 0 Aug 29 03:37 /proc/320546/exe -> /usr/bin/gvisor-bin/gvisor_sentry
lima@lima-linux:~$ sudo ls -l /proc/320550/exe
lrwxrwxrwx 1 root root 0 Aug 29 03:37 /proc/320550/exe -> /usr/bin/gvisor-bin/gvisor_sentry

ここに gVisor の特徴が表れています。gVisor はコンテナ内のプロセスをホストのプロセスとして作成せず、Sentry の内部で管理されるタスクとして実行します。そのためホストのカーネルはコンテナ内のプロセスを認識しておらず、ps にも現れません。ここに gVisor の防御の方向性が表れています。gVisor が守っているのは「コンテナ内の脆弱性や悪意あるコードが、ホストカーネルへ到達すること」です。ホストカーネルへ届くシステムコールを絞ることで、カーネルの脆弱性を突かれるリスクを下げています。

ただし、Linux のシステムコールが必ずしも全て gVisor で実装されているわけではないことには注意が必要です。

Kata Containers

Kata Containers は、コンテナ実行のためにチューニングされた、軽量な仮想マシンを Pod 単位で作成し、その中でコンテナを実行する OCI 互換のランタイムです。

Kata Containers is an open source community working to build a secure container runtime with lightweight virtual machines that feel and perform like containers, but provide stronger workload isolation using hardware virtualization technology as a second layer of defense.-- Kata Containers - Open Source Container Runtime Software | Kata Containers

ハイパーバイザーには QEMU・Cloud Hypervisor・Firecracker などを選択できます。gVisor が「ユーザー空間の擬似カーネルでシステムコールをエミュレートし、ホストカーネルに触れる範囲を減らす」アプローチなのに対し、Kata Containers は「Pod ごとに本物の Linux カーネルを積んだ VM を丸ごと用意し、ハードウェア仮想化の境界で隔離する」という伝統的な VM に近いアプローチです。

(Kata Containers は Apple M2 Pro では動かせないようなので実行例は割愛...)

終わりに

今回は低レベルランタイムについて見てきました。それぞれを一枚絵に表すとこのようになるかと思います。

左に行くほど軽量で、右に行くほど隔離が厳密になっているが、その分オーバーヘッドが大きくなるという感覚です。使い分けですが、「信頼できないコードを、他人のワークロードと同じホストで動かすか」が最初の分岐点になります。一般的に、信頼できる自社のコードを動かすだけであれば runc で十分です。信頼できないコードを、短時間で大量に起動・終了させたい場合は gVisor が適しています。コンプライアンスにより強い隔離が必要、または gVisor で非対応のシステムコールに依存している場合は、ハードウェア仮想化による隔離を行い、Linux 互換性の高い Kata Containers を使う場面になりそうです。

次回は低レベルランタイム(主に runc)が呼び出している Linux の各種機能についてです。お楽しみに〜 → まずは cgroup について書きました

hiramekun.hatenablog.com

mise で dotfiles を宣言的に管理する

はじめに

Ansible と asdf で組んでいた dotfilesmise ベースに移行し、さらに自前のシェルスクリプトも mise bootstrap に置き換えたので、現在の構成をまとめとして残しておきます。

github.com

mise bootstrap に置き換える部分ではこちらの記事を参考にしました。

dev.classmethod.jp

対象は macOS の Apple Silicon のみです。以下の 1 コマンドで環境が一通り揃います。

curl -fsSL https://raw.githubusercontent.com/hiramekun/dotfiles/main/install.sh | bash

すでにクローン済みなら、リポジトリ内で ./up を実行します。

cd ~/dotfiles
./up

全体の構成

ファイルの役割は次のとおりです。

  • install.sh: 何も入っていないマシン向けの curl ワンライナー。Homebrew を入れてリポジトリをクローンし、up に引き継ぐ。
  • up: Homebrew と mise を用意して mise タスクに引き継ぐブートストラップスクリプト。
  • mise.toml: プロビジョニングの入口。ランタイム、開発ツール、dotfile のリンク、macOS 設定、タスクをすべて宣言する。
  • Brewfile: GUI アプリとシステムレベルのパッケージを宣言する。
  • scripts/setup-vim: dein.vim と Neovim プラグインを導入する。
  • shell/env.sh: シェル起動時に mise と direnv を有効化する。

./upmise run setup を呼び、setup は 5 つのタスクを順に実行します。

brew     → Brewfile のアプリ・パッケージ導入
tools    → ランタイムと開発ツールの導入 (mise install)
link     → dotfile のシンボリックリンク作成
defaults → ログインシェルと macOS 環境設定の適用
vim      → dein.vim と Neovim プラグインの導入

シェルスクリプトは install.shupsetup-vim の 3 本だけです。それ以外は mise.toml の宣言に寄せています。

install.sh

新しいマシンで最初に困るのが、git が無いのでリポジトリをクローンできないという点です。以前は README で xcode-select --install を先に実行してもらっていましたが、GUI のダイアログが出て完了を待つ必要があり、手順としても分断されていました。

そこで、Homebrew を先に入れるようにしました。Homebrew のインストーラは、Command Line Tools が無ければ softwareupdate 経由で導入する処理を持っています(install.sh)

main() {
  require_apple_silicon_macos
  install_homebrew
  clone_or_update_repo
  exec "$DOTFILES_PATH/up"
}

curl | bash で実行されるため、標準入力は curl のパイプに使われています。Homebrew のインストーラは確認プロンプトで停止してしまうので、NONINTERACTIVE=1 を渡して抑止しています。

NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

最後は execup に処理を渡します。

up

up がやることは、mise が動く状態を作るところまでです。

  • macOS と Apple Silicon の確認。
  • mise を brew install(導入済みなら brew upgrade)する。
  • mise trust してから mise run setup を実行する。

引数を渡すとそのタスクだけを実行するので、./up link のような使い方もできます。

冒頭で環境変数を 1 つ設定しています。

export MISE_CEILING_PATHS="${MISE_CEILING_PATHS:-$HOME}"

Settings | mise-en-place

mise は上位ディレクトリの設定ファイルを遡って探索しマージするので、$HOME を天井にしておかないと ~/.tool-versions~/mise.toml のようなホーム直下の設定がリポジトリの設定に混ざります。

mise.toml

ランタイムと開発ツール

[tools]
node = "lts"
python = "latest"
ruby = "latest"
java = "latest"
go = "latest"
terraform = "latest"
uv = "latest"

"npm:pyright" = "latest"
"npm:prettier" = "latest"
"pipx:black" = { version = "latest", depends = ["uv"] }
"go:golang.org/x/tools/gopls" = "latest"
"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "latest"
...

言語ランタイムに加えて、LSP・フォーマッタ・リンタも mise の npm / pipx / go バックエンドで管理しています。

dotfiles テーブル

シンボリックリンクの定義です。以前は scripts/link-dotfiles という bash スクリプトでバックアップや冪等性の面倒を見ていましたが、テーブルに書くだけになりました。

[dotfiles]
"~/.zshrc" = { source = "zsh/.zshrc", mode = "symlink" }
"~/.gitconfig" = { source = "git/.gitconfig", mode = "symlink" }
"~/.tmux.conf" = { source = "tmux/.tmux.conf", mode = "symlink" }
"~/.config/ghostty" = { source = "ghostty", mode = "symlink" }
"~/.config/nvim" = { source = "nvim", mode = "symlink" }
...

Dotfiles | mise-en-place

bootstrap テーブル

ログインシェルと macOS の環境設定です。今まで新しいマシンで手作業していた部分をここに書いています。

[bootstrap.user]
login_shell = "/bin/zsh"

[bootstrap.macos.keyboard]
key_repeat = 2
initial_key_repeat = 15
fn_state = true

[bootstrap.macos.dock]
autohide = true
tilesize = 49

[bootstrap.macos.finder]
show_all_files = true

[bootstrap.macos.defaults]
"NSGlobalDomain" = { AppleInterfaceStyle = "Dark" }

macOS Defaults | mise-en-place

適用は mise run defaults です。キーリピート速度やダークモードのような細かい好みが Git で管理できるようになりました。

タスク

[tasks.setup]
run = [
  { task = "brew" },
  { task = "tools" },
  { task = "link" },
  { task = "defaults" },
  { task = "vim" },
]

[tasks.link]
run = "mise bootstrap dotfiles apply --yes"

[tasks.defaults]
run = [
  "mise bootstrap user apply --yes",
  "mise bootstrap macos defaults apply --yes",
]
...

setup はサブタスクの合成です。それぞれのサブタスクを上から順に実行することで環境構築が完了するようになっています。以前はこれを Ansible で管理していましたが、mise で管理することでシンプルな作りになりました。

Brewfile

言語ランタイムは mise、GUI アプリと Homebrew でしか入らないものは Brewfile、という住み分けです。iTerm2、Karabiner-Elements、Docker Desktop などの cask と、tmux、ripgrep、kubectl、lua-language-server などの formula を宣言しています。

タスクは brew bundle --no-upgrade で実行しています。足りないものだけ入り、既存パッケージは更新されません。全部上げたいときは明示的に実行します。

brew bundle upgrade --file Brewfile

リポジトリの mise.toml をグローバル設定にする

shell/env.sh で、このリポジトリの mise.toml をグローバルの mise 設定として指定しています。

export MISE_GLOBAL_CONFIG_FILE="${DOTFILES_PATH:-$HOME/dotfiles}/mise.toml"
export MISE_GLOBAL_CONFIG_ROOT="${DOTFILES_PATH:-$HOME/dotfiles}"
export MISE_CEILING_PATHS="$HOME"

eval "$(mise activate zsh)"

Configuration | mise-en-place

これで dotfiles ディレクトリの外でも同じランタイムと LSP が使えます。

MISE_CEILING_PATHS でホーム直下の設定は無視しつつ、プロジェクト配下の mise.toml は通常どおり優先されます。グローバルは dotfiles、プロジェクトはプロジェクトの設定が適用されます。

Neovim

scripts/setup-vim が dein.vim を shallow clone し、ヘッドレスで起動してプラグインを入れます。

nvim --headless -i NONE \
  -c 'if dein#check_install() | call dein#install() | endif' \
  -c 'qall'

Neovim 側は nvim-lspconfig、nvim-cmp、Telescope を中心に構成しています。pyright や gopls、black、prettierd といったエディタが依存する CLI は mise.toml に宣言してあります。

終わりに

シンボリックリンク→Ansible と移行してきて、mise へと行きつきました。以前よりもシンプルに書けるので個人的には満足しています。もっと良い管理方法などありましたらコメントいただけると嬉しいです!

過去のdotfilesに関する記事はこちら hiramekun.hatenablog.com hiramekun.hatenablog.com

Istioを使ったサービスメッシュの実装メモ

『クラウドネイティブで実現する マイクロサービス開発・運用 実践ガイド』を読んだので、その中でも印象に残ったサービスメッシュと Istio について、自分で調べた内容を付け加えてまとめとして残しておきます。

サービスメッシュとは

サービスメッシュを語る際に重要となる概念が「横断的関心事」というもの。横断的関心事とは、複数のマイクロサービスで共通的に利用される機能のことを指す。ルーティング、サービスディスカバリ、セキュリティ、ロギングなどが横断的関心事の一例として挙げられる。これらの機能はマイクロサービス全体で必要とされるが、個別のマイクロサービスのビジネスロジックに直接関連しているわけではない。

各サービスで横断的関心事をそれぞれの言語やフレームワークで実装すると、ビジネスロジックに関係ないコードが多く書かれてしまうことになる。マイクロサービスではこの横断的関心事をどのように扱うかが課題となるが、それを解決するためにサービスメッシュが活用される。

サービスメッシュとはサービス間通信を横断的に制御するインフラ層で、マイクロサービス間の通信を管理し、通信制御のための処理を挿入するための仕組み。サービスメッシュを活用することによって、横断的関心事を個々のマイクロサービスのビジネスロジックから分離することができる。

Istioとは

Istio とは Kubernetes などのクラスタ上で動くマイクロサービス群に対してサービスメッシュを提供するオープンソースのプロダクト。

istio.io

各 Pod に対して Envoy Sidecar Proxy を注入し、そのプロキシ群を istiod という control plane が一括で管理する。アプリケーションコードを変更することなく、横断的関心事を担当する処理を適用できるのが特徴。

  • Envoy: Envoy とはマイクロサービスに必要な機能を集めた sidecar
  • Sidecar Proxy: Sidecar Proxy とは各サービスのインスタンスに並走するような形で配置される補助的なプロキシプロセス。各サービス間の通信は、必ずこのプロキシを経由して行われる。アプリケーションからはプロキシの存在を意識しなくて良い

Istioでできること

Istio を導入すると、以下の機能をアプリケーションコードの外側であるインフラ層の sidecar に切り出すことができる。

  • トラフィック管理: 特定バージョンへのトラフィックの重み付けルーティング(カナリアリリース、A/B テスト)、自動リトライ・タイムアウト・サーキットブレーカー、障害注入によるカオスエンジニアリング的なテストなど、アプリ側のコード変更なしに実現できる
  • セキュリティ: サービス間通信を自動的に mTLS 化し、証明書の発行・ローテーションも istiod が肩代わりする。ゼロトラストなネットワークを、各サービスに TLS 実装を組み込まずに実現できる
  • Observability: すべてのサービス間通信がサイドカーを経由するため、レイテンシ・トラフィック量・エラー率などのメトリクスや分散トレーシングの情報をアプリの計装なしに自動収集できる

istio.io

トラフィック制御

トラフィック制御では主に DestinationRuleVertualService という Istio リソースを使用する。

  • DestinationRule: サービスへのトラフィックに対するポリシーを定義する。サブセット(Pod の Label を用いたまとまり)の定義、負荷分散ルールの設定、サーキットブレーカー設定等ができる
  • VirtualService: サービスへのルーティングルールを定義する。HTTPヘッダやURLパスに基づいたルーティング、DestinationRule と合わせたサービスのサブセット単位でのルーティングルール設定等ができる

ルーティング制御

まずは DestinationRule を設定する。ここでは frontend サービスのサブセットを定義している。

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: frontend
spec:
  host: frontend
  trafficPolicy:
    loadBalancer:
      simple: ROUND_ROBIN # ラウンドロビンで負荷分散
  subsets:
  - name: v1      # サブセットv1
    labels:       # version=v1というラベルを持つPodにルーティング
      version: v1
  - name: v2      # サブセットv2
    labels:       # version=v2というラベルを持つPodにルーティング
      version: v2

これを適用すると、負荷分散アルゴリズムがラウンドロビンに設定されているので、v1 と v2 に交互にリクエストがルーティングされるようになる。 次に、この DestinationRule を利用して VirtualService を設定し、トラフィックを v1 に 75%、v2 に 25% 流すように設定する。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: frontend
spec:
  hosts:
  - "*"
  gateways:
  - bookshop-gateway
  http:
  - match:
    - uri:
        prefix: /
    route:
    - destination:
        host: frontend
        subset: v1
      weight: 75  # サブセットv1にルーティングする比率
    - destination:
        host: frontend
        subset: v2
      weight: 25  # サブセットv2にルーティングする比率

フォールトインジェクション

意図的にサービスに遅延を発生させることで、システムが障害や遅延にどのように対応するかをテストすることができる。

その一例として、VirtualService を用いてアプリケーション側の処理を実装することなく、強制的にサービスに通信の遅延を発生させることができる。次の例は catalogue サービスに2秒の動作遅延を発生させる設定で、Istio のフォールとインジェクションという機能を利用している。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: catalogue
spec:
  hosts:
  - catalogue
  http:
  - route:
    - destination:
        host: catalogue
    fault:
      delay:  # 100%の確率で2秒間の遅延
        percent: 100
        fixedDelay: 2s

これにより、 catalogue という名前のサービスに対する全ての HTTP リクエストが2秒間遅延することになる。

サーキットブレーカー

サーキットブレーカーとは、障害が発生したサービスへのリクエストを制御するための仕組み。障害が発生しているサービスへのリクエストを一時的に遮断し、システム全体への障害を防ぐ。

サーキットブレーカーの設定は DestinationRule で行う。outlierDetection を設定することで、エラーが発生したサービスへのアクセスに対して Istio が代理で返答してくれるようになる。

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: catalogue
spec:
  host: catalogue
  trafficPolicy:
    outlierDetection:
      consecutive5xxErrors: 3
      interval: 10s
      baseEjectionTime: 60s
  subsets:
  - name: v1
    labels:
      version: v1

これにより、10秒以内に5xxエラーが3回以上発生すると60秒間サーキットブレーカーが発動する。

セキュリティ

mTLS

相互TLS(mTLS)とは、クライアントとサーバーの双方が互いに証明書を提示し合い、双方向で相手の身元を検証する方式のこと。Istio では Envoy sidecar が証明書の発行・配布・ローテーションを行ってくれる。これにより、アプリケーションコードは TLS を一切意識せず、サイドカー同士が裏側で mTLS 通信を行う。

通信の流れとしては次のようになる。

  1. アプリケーションは自分の Pod 内の Envoy sidecar に対して平文でリクエストを送る
  2. 送信側 Envoy が宛先の Envoy との間で TLS ハンドシェイクを行い、双方が証明書を提示し合い相互に検証する
  3. 検証が成功すると暗号化コネクションが確立し、Pod 間の通信は暗号化される
  4. 受信側 Envoy が復号し、平文で自分の Pod 内アプリケーションに渡す

mTLS を矯正するかどうかは PeerAuthentication リソースで設定する。

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: mtls
spec:
  mtls:
    mode: STRICT

mtls.mode を STRICT にすることで mTLS 通信のみ受け付けるように設定ができる(デフォルトは PERMISSIVE で、mTLS・平文の両方のリクエストを受け付ける)。

JWT検証

Istio のサービスメッシュでは、JWT の検証を RequestAuthentication リソースで設定し、実際の検証処理は Envoy sidecar に組み込まれた jwt_authn フィルタが行う。リクエストの Authorization: Bearer <token> ヘッダなどから JWT を取り出し、issuer の JWKS を使って JWT を検証する。

下記は Keycloak から発行された JWT を利用してリクエスト認証を行うための設定。fromHeaders セクションで Authorization ヘッダから JWT を抽出する方法を指定している。

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: keycloak
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  jwtRules:
  - issuer: "http://127.0.0.1:8080/realms/gihyo-ms" # Keycloak のサーバURL
    jwksUri: http://keycloak.keycloak.svc.cluster.local:8080/realms/gihyo-ms/protocol/openid-connect/certs # JWT の公開鍵を取得するためのエンドポイント
    fromHeaders:
    - name: Authorization
      prefix: "Bearer "
    forwardOriginalToken: true

RequestAuthentication は JWT がついていれば検証する設定であり、JWT がついていないリクエストや検証に失敗したリクエストを拒否するのは後述する AuthorizationPolicy の役割。

認可制御

認可の設定には AuthorizationPolicy を使用する。例えば、次の設定では bff に対するアクセスでは JWT トークンが付与されている場合のみアクセスを許可し、bff 以外へのアクセスでは無条件にアクセスを許可する。

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: keycloak
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  action: ALLOW
  rules:
  - to:
    - operation:
        notPaths: ["/bff/*"]
  - to:
    - operation:
        paths: ["/bff/*"]
    when:
    - key: request.auth.principal
      values: ["*"]

Observability

Istio を使わない場合、各サービスがそれぞれの言語・フレームワークで計測用のライブラリを組み込む必要がある。Istio は全サービス間通信を Envoy sidecar 経由で流すため、アプリケーションコードを実装しなくても通信に関するメトリクス・アクセスログ・分散トレースの土台を自動的に収集できる。

メトリクス

各 Envoy sidecar が、リクエスト数・レイテンシ・レスポンスコード・バイト数などの標準メトリクスを Prometheus フォーマットで自動的に公開する。代表的なものは以下のようなもの。

  • istio_requests_total: リクエスト数
  • istio_request_duration_milliseconds: レイテンシ
  • istio_tcp_connections_opened_total: TCP コネクション数

これらを Prometheus で収集し、Grafana を使って可視化するのが典型的な構成となる。

アクセスログ

Envoy はリクエスト単位でのアクセスログを出力でき、どのサービスからどのサービスへ、どんなステータスコード・レイテンシで飛んだのかを追跡することができる。

分散トレーシング

Envoy は各リクエストに対して、Zipkin/Jaeger 形式のトレーシングヘッダー(x-request-id, x-b3-traceid など、Envoy が生成する span)を付与・伝播し、Jaeger や Zipkin、OpenTelemetry Collector にスパン情報を送信できる。

Istio がサイドカーを自動で注入する仕組み

namespace にラベルを付けることで、そこに作成される Pod に対して Mutating Admission Webhook が自動でサイドカーを注入するようになる

kubectl label namespace default istio-injection=enabled

すでに動いている Pod にはこのラベルは遡って適用されないため、Deployment を再起動してサイドカー入りの Pod を再作成する必要がある

kubectl rollout restart deployment -n default

Kubernetes の API サーバは、リソース(Pod など)が etcd に保存される前に「admission control」という検証・変更フェーズを通す。Mutating Admission Webhook とはそのフェーズで動く拡張機構の一つで、リクエストされたオブジェクト(例: Pod の定義)を外部の Webhook サーバに問い合わせて書き換える(mutate する)ための仕組み。Istio のサイドカー自動注入はこの仕組みで実現されている。

『つくって、壊して、直して学ぶ Kubernetes入門』を読んだ

『つくって、壊して、直して学ぶ Kubernetes入門』を読んだので、その中に出てきた便利な kubectl コマンドをメモとして残しておきます

便利な kubectl コマンド

コンテナにログインする

kubectl exec -it <対象Pod名> -- /bin/sh

deploymentのimageを取得する

kubectl get deployment <対象deployment名> -o=jsonpath='{.spec.template.spec.containers[0].image}'

同じPod内からアプリケーションの接続確認を行う

kubectl debug -it <デバッグ対象Pod名> --image=<デバッグ用コンテナのimage> --target=<デバッグ対象のコンテナ名>

クラスタ内かつ別Podから接続確認を行う

kubectl run curl --image curlimages/curl --rm -it --restart=Never --command -- curl <アクセスしたいIP, Port>

NodeのIPを取得する

kubectl get nodes -o jsonpath='{.items[*].status.addresses[?(@.type=="InternalIP")].address}'

PodのIPを取得する

kubectl get pods -o custom-columns=NAME:.metadata.name,IP:.status.podIP

サービスのIPを取得する

kubectl get svc -o custom-columns=NAME:.metadata.name,IP:.spec.clusterIP

PodのQoSクラスを取得する

kubectl get pod <Pod名> -o jsonpath='{.status.qosClass}'

Podのresources.requestを取得する

kubectl get deployment <対象deployment名> -o=jsonpath='{.spec.template.spec.containers[0].resources.requests}'

PodのlastStateを取得する

kubectl get pod <OOMKilledが発生したPod名> -o=jsonpath='{.status.containerStatuses[0].lastState}'

Podのtolerationを確認する

kubectl get deployment <対象deployment名> -o=jsonpath='{.spec.template.spec.tolerations}'

Podのaffinityを確認する

kubectl get deployment <対象deployment名> -o=jsonpath='{.spec.template.spec.affinity}'

Nodeのtolerationを確認する

kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS-KEY:.spec.taints[*].key'