📦

chroot と unshare で簡易「コンテナ」を実装してコンテナと仲良くなる

2024/11/24に公開

はじめに

コンテナの利用が広く浸透し、日常的にコンテナを使うことが多くなってきました。
一方で、docker の CLI の操作はなんとなくわかるようになってきたが、いまいち裏で何が起きているのかはよく理解しておらずなんとなくもやもやを抱えながらしばらく使っていたように感じます。

最近、コンテナの比較的低レベルな部分に関わる技術に触れる機会があり、コンテナがどのように実現されているかという点について少し調べる機会がありました。

コンテナは普段使っているものでありながら、これまであまり実行の仕組みに触れる機会は多くなかったように感じており、今回自分の理解のためを兼ねて記事にしてみたいと考えました。

概要

この記事ではまず前半でコンテナという用語について見直し、コンテナ環境が実現していることについて少し整理してみます。その後、後半部分ではいくつかのシステムコールを利用して非常に簡易的な「コンテナ」を作成してみます。
以上を通してコンテナを構成する技術を少しでも身近に思えるようになることが目標です。

コンテナに対する漠然としたブラックボックス感を減らし、

  • コンテナ起動時に何が起こっているのか
  • コンテナがなぜ比較的軽量に立ちあげられるのか

といった点がなんとなく感じられるようになれば幸いです。

検証環境

M2 MacBook (MacOS 13.7) 上の Ubuntu 24.04 VM (multipass 環境) で実験しています。
環境の詳細は以下の折り畳みを参照してください。

検証環境の詳細
# Macbook ホスト環境
% sw_vers
ProductName:            macOS
ProductVersion:         13.7
BuildVersion:           22H123

% uname -a
Darwin MacBookL39HP-3.local 22.6.0 Darwin Kernel Version 22.6.0: Wed Jul 31 21:37:05 PDT 2024; root:xnu-8796.141.3.707.4~1/RELEASE_ARM64_T8112 arm64

# Ubuntu VM 環境 (multipass)
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo

$ uname -a
Linux primary 6.8.0-48-generic #48-Ubuntu SMP PREEMPT_DYNAMIC Fri Sep 27 14:35:45 UTC 2024 aarch64 aarch64 aarch64 GNU/Linux

$ gcc --version
gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ chroot --version
chroot (GNU coreutils) 9.4
Copyright (C) 2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Roland McGrath.

$ unshare --version
unshare from util-linux 2.39.3

また、コンテナのルートファイルシステムとして alpine:3.20.3 イメージのものを使用しました。実行コマンドを説明するコードブロック内では Mac, Ubuntu, alpine の各環境内でのシェル上での実行例が出てくることがありますが、プロンプトをそれぞれ (%, $, / #) と表記していることで区別してください [1][2]。プロンプト文字が # の場合、レンダリング上コメント行のように表示されてしまい若干見づらいですがご容赦ください 🙇

「コンテナ」とは

「コンテナ」という言葉はよく使いますが、注目する側面によっていくつかの意味を持っているように感じられます。
アプリケーションの配布やデプロイという文脈では、「実行に必要な環境一式をバンドルした可搬性のあるパッケージ」というような意味合いが強いかもしれません。以下の、Google Cloud の コンテナとは にある以下の定義などはこちらが近いように思われます。

コンテナとは、アプリケーション コードに、ソフトウェア サービスの実行に必要な特定バージョンのプログラミング言語ランタイムやライブラリなどの依存関係を加えた軽量のパッケージを指します。

一方、コンテナの実行時の姿という観点で考えると、コンテナは「ホストから隔離されたプロセスの実行環境」を提供するものと考えることができます。コンテナ環境内では、ホストとは異なるファイルシステムが見えますし、ホスト側のプロセスも基本的に見えなくなり、ホスト側からある程度独立した実行環境が提供されます。
今回はこの実行時の振る舞いに注目し、ホストからの隔離がどのように実現されているかを少し見てみたいと思います。

Open Container Initiative とコンテナ関連の仕様

コンテナ関連の仕様については重要な部分を Open Container Initiative (OCI) という組織が管理しています。現在 OCI が管理する仕様 (specification) は以下の 3 つがあります。

この中で比較的身近なのは Image Spec と Runtime Spec ではないかと思います。
Image Spec は「コンテナイメージ」の形式を定義するもので、ファイルシステム (レイヤ) の情報や実行のための設定 (e.g. 実行コマンド) などを含めた配布可能な形式を定義します。
Runtime Spec はコンテナの実行のための仕様で、ルートファイルシステムと実行のための設定 (config.json) を受けとってコンテナを実際にホスト上で実行するコンテナランタイムが満たすべき仕様を定義します。

今回の記事で紹介する部分は Runtime Spec を実装する runc などのコンテナランタイムが行っている仕事の一部になります。

コンテナが隔離するもの/しないもの

コンテナはホストからある程度隔離された環境を実現しますが、ホストと共有している部分もあります。以下のような部分はコンテナ環境によって隔離されます。

  • ルートファイルシステム
  • プロセス/マウント/ネットワーク/ユーザー/... 名前空間 (namespace)
  • ハードウェア等の資源割り当て (cgroups)

ルートファイルシステム (/) はコマンド類、共有ライブラリ、OS・アプリケーションの設定ファイル等を含めたシステム上のすべてのファイルを含みます。したがって、ルートファイルシステムを隔離することで、ホスト側のシステムディレクトリに影響を与えずにパッケージをインストールしたり、ホストからある程度独立したクリーンな環境でテストしたりすることが可能になります。

Namespace は通常プロセス間で共有されるプロセス ID テーブル、ファイルシステムマウント、ネットワーク等の資源を隔離するための Linux の機能です。man 7 namespaces には以下のように説明があります。

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. Changes to the global resource are visible to other processes that are members of the namespace, but are invisible to other processes.

一方、コンテナ環境では基本的には Linux カーネル自体はホスト側から隔離されておらず、ホストと共有しています。例えば、コンテナ内部からのシステムコールの処理は基本的にホストと共通のカーネルが処理します [3]

chroot と unshare で簡易コンテナ環境を作成してみる

コンテナという用語についてある程度整理できたところで、以下では実際に chroot(2)unshare(2) を利用して簡易的なコンテナ環境を作成してみます。

chroot はルートファイルシステムを変更するためのシステムコールで、これを変更することでホスト側からファイルシステムを隔離することができます。
また、unshare システムコールは新しく namespace を作成することで (他のプロセスの所属する) 元の namespace からプロセスを切り離すことができます。

まずはシェル上で chroot(1)[4] コマンドや unshare(1) コマンドを用いて簡易コンテナ環境を作成してみます。
その後、ほぼ同等の内容を (空行・コメント除き) 30 行程度の C 言語のプログラムで実装してみます。

ルートファイルシステムの準備

chroot を使う前準備として、chroot 環境内で使うためのルートファイルシステムを用意する必要があります。方法のひとつとして、docker container export という docker コンテナ内のルートファイルシステムを tar アーカイブとしてエクスポートする機能を用いることができます。ここでは例として alpine コンテナのルートファイルシステムをエクスポートして rootfs/ というディレクトリに展開してみます。

$ docker container create alpine
a928c619283538e51492dda66af3d7c6fa257f9f79353647eac1149c01211194
$ docker container export a928c6 >rootfs.tar
$ mkdir rootfs
$ tar -C rootfs -xf rootfs.tar
$ ls rootfs
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
$ cat rootfs/etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"

もちろん、docker を使わないで他の何らかの方法でルートファイルシステムの tar を入手しても構いません。以下では、rootfs/alpine のルートファイルシステムが用意されていることを前提とします。

シェル上から簡易コンテナ作成

まずは chroot コマンドを実際に使ってルートファイルシステムを変更してみます。

chroot [OPTION] NEWROOT [COMMAND [ARG]...]
# NEWROOT: 新しくルートディレクトリに設定するディレクトリ
# COMMAND: ルートディレクトリが変更された chroot 環境内で実行するコマンド

以下の例では、先ほど alpine のルートファイルシステムを用意した rootfs/ 環境内で sh を実行する例です。

$ sudo chroot rootfs/ sh
/ # ls
bin    etc    lib    mnt    proc   run    srv    tmp    var
dev    home   media  opt    root   sbin   sys    usr
/ # cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"

/etc/os-release の内容から、ルートファイルシステムが alpine のものに置き代わっていることが確認できます。

ルートファイルシステムが隔離されたことでだいぶコンテナらしくはなりましたが、この時点では例えばプロセス ID 空間などはホストと隔離されていません。これは、例えば以下のように proc ファイルシステムをマウントして ps コマンドでプロセス一覧を見ることで確認できます。

/ # mount -t proc proc /proc
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:12 /usr/lib/systemd/systemd --system --deserialize=25
    2 root      0:00 [kthreadd]
  ... (中略)
33029 root      0:00 sudo chroot rootfs/ sh
33030 root      0:00 sudo chroot rootfs/ sh
33031 root      0:00 sh
33034 root      0:00 ps
/ # umount /proc

次に、unshare コマンドを利用してルートファイルシステムの変更と合わせて namespace の隔離を行ってみます。以下に今回用いるオプションを簡単に説明します。

  • --root rootfs: ルートファイルシステムを設定
    • 少なくとも今回試したバージョンでは内部的に chroot(2) が使われる
  • --mount-proc: mount namespace を分離 + 新 namespace 内で proc ファイルシステムをマウント
  • --pid --fork: pid namespace を分離 [5]
$ sudo unshare --root rootfs --mount-proc --pid --fork sh
/ # cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    3 root      0:00 ps

今回は pid namespace を分離したため「コンテナ環境」内からホスト側のプロセスの情報が見えなくなっていることがわかります。

C 言語で実装

上で chroot(1) を用いて行った操作を C 言語で実装してみます。以下のファイルを container_demo.c というファイル名で保存します。

// container_demo.c
#define _GNU_SOURCE
#include <errno.h>      // errno
#include <sched.h>      // unshare
#include <stdio.h>      // printf
#include <stdlib.h>     // exit
#include <string.h>     // strerror
#include <sys/mount.h>  // mount
#include <sys/wait.h>   // wait
#include <unistd.h>     // chdir, chroot, execl, fork

void exit_error_if(char *msg, int b) {
    if (b) {
        printf("%s: %s\n", msg, strerror(errno));
        exit(1);
    }
}

int main() {
    // (1) Create new mount+pid namespace and mount procfs
    exit_error_if("unshare", unshare(CLONE_NEWNS|CLONE_NEWPID) == -1);
    // (2) Make all mount private in the new mount namespace (see mount_namespaces(7))
    exit_error_if("mount", mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL) == -1);

    // (3) Change filesystem root and set working directory to new root
    exit_error_if("chroot", chroot("./rootfs") == -1);
    exit_error_if("chdir", chdir("/") == -1);

    // (4) fork() is required for shell to run in new pid namespace
    pid_t pid = fork();
    exit_error_if("fork", pid == -1);
    if (pid == 0) {
        // (5) procfs mount should be done in new pid namespace
        exit_error_if("mount", mount("proc", "/proc", "proc", 0, NULL));
        // (6) Execute a shell in a container
        execl("/bin/sh", "/bin/sh", NULL);
    } else {
        // Parent should wait child to not to lose controlling terminal
        wait(NULL);
    }
}

コード中のコメントと重複する部分がありますが、簡単に内容について補足します。

  • (1): unshare(2) を用いて namespace を分離します。
    • CLONE_NEWNS は mount namespace, CLONE_NEWPID は pid namespace を分離するためのフラグです。
  • (2): この呼び出し以後に (旧) ルートファイルシステム下に新たに作成されるマウントが mount namespace の外に見えなくなるように設定しています[6]
  • (3): chroot(2) でルートディレクトリを変更した後、新しいルートディレクトリに移動します。
  • (4): 新規 pid namespace 内に最初のプロセスを作成するため fork します。
  • (5): 新規 pid namespace を参照する proc ファイルシステムをマウントします。
  • (6): 「コンテナ環境」内でシェルを起動します。

コンパイルして動作確認してみます。

$ gcc container_demo.c
$ sudo ./a.out
/ # cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    3 root      0:00 ps

こちらのプログラムでもルートファイルシステムの入れかえと、pid namespace の隔離が実現できていることが確認できました。

おわりに

今回の記事では、まずコンテナが何を実現しているかを少し整理し、さらに簡易的な「コンテナ環境」を実装してみました。
実際に簡易的な「コンテナ環境」を作成してみることで、いくつかの特定のシステムコールの呼び出しによってホストから隔離された環境が実現できるということが実感できたのではないかと思います。また、コンテナの起動処理の中身に触れることで、ゲスト OS をブートしたりするような VM の構成と比べるとコンテナが軽量に実行できることについても直感的に感じられるのではないでしょうか。
この記事を通してコンテナ技術について少しでも身近に感じられるようになっていただけたら幸いです。

また、今回の記事ではカバーできなかったトピックも多くあります。例えば cgroups はコンテナが利用できる CPU、メモリ等の資源を制限するために使われており、コンテナの重要な構成と言えるでしょう。このようなコンテナ関連の他のトピックを調べてみたり、runc, youki 等の実装を読んでみたりするのも面白いと思います。

参考

脚注
  1. それぞれの実行環境での zsh, bash, sh のプロンプト文字に由来します。 ↩︎

  2. 正確には / #/ の部分はカレントディレクトリです。cd 等で移動すれば変化します。 ↩︎

  3. 軽量な VM を用いる Kata Container やアプリケーションカーネルを用いる gVisor のような例外もあります ↩︎

  4. 環境によっては chroot(8) になっている場合もあります。 ↩︎

  5. unshare(CLONE_NEWPID) を呼び出しただけだと呼び出し元のプロセスは新規 namespace 内には移動されず、呼び出し後に作成された最初の子プロセスが新規 pid namespace 内の最初のプロセスになるため、--fork が必要です。詳しくは man 7 pid_namespaces 等を参照してください。 ↩︎

  6. これをやらないとシステム上のマウントの設定によっては (5) で作成する procfs のマウントが namespace の外に漏れてしまいます。この部分は正直かなり細かい配慮なのであまり気にしなくても良いです。詳しくは man 7 mount_namespaces の SHARED SUBTREES のセクションなどを参照してください ↩︎

GitHubで編集を提案

Discussion