🐳

Dockerではなくcontainerd+nerdctlを使ってみる

に公開

そもそもcontainerdって?

Docker APIを叩いてDockerにcontainerdを操作してもらうよりも、直接containerdに命令したほうがシンプルだよね、という考えが広がってきている。

(推奨)まずはこの記事を読む:Dockerは非推奨じゃないし今すぐ騒ぐのをやめろ

Dockerからcontainerd+nerdctlに移行すべき?

→ 移行するメリットはそれほどないが、移行のハードルも限りなく低い。つまり移行したい人だけがすればよい。

もしも私なら、Dockerでセットアップ済のサーバでは引き続きDockerを使うけれど、新規に環境構築するサーバにはDockerを入れずにcontainerd+nerdctlを入れてみるかもしれない。

Kubernetesの内部ではcontainerdを使っている。とはいえKubernetesにデプロイする開発者の環境はDockerのままで何の問題もない。

nerdctlではDockerfilecompose.yamlはほぼそのまま使えるし、Docker Hubから各種ライブラリのimageをpullする流れもそのまま。nerdctlのコマンドはdockerコマンドとだいたい互換性があるので、学習コストも低い。

本番運用に関しても、現在GitLabやGitHubなどのCI/CDでコンテナレジストリにビルドしているとして、それはすでにDocker等のコンテナエンジンに依存していないOCIイメージになっているはずなので、何も影響はない。

一方ローカル開発環境でコンテナをbuild, pull, runするときにだけ、環境移行の恩恵がある。

Docker Engineと比べて、起動時間の短縮、CPU/メモリ効率の向上、高いセキュリティがメリットとされている。

とはいえWeb上の有識者の方々の感想を見ている限りでは、ほとんどのユーザーはその差を実感できるほどではないようだった。一概にDockerが劣っているわけではない。

移行する明確なデメリット

  • VSCodeの便利な拡張機能DockerDev Containersが使えなくなる。
    • 個人的にDocker拡張機能はとくに重宝しており、マウスクリックだけでコンテナを起動してログを閲覧できたり、コンテナのステータスを眺められて非常に便利だった。

なので拡張機能を元々使わずにCLIで操作していた人はスムーズに移行できるものの、GUI頼みだった人が移行するのはちょっと辛いかもしれない。

せっかくなので体験してみる

Docker環境セットアップ済のLinuxサーバをnerdctl環境に移行

OS: Ubuntu 24.04 LTS amd64

前述の通り、すでにDocker環境が構築済なら、わざわざnerdctlに移行するメリットはあまりない。

今回は勉強のため、構築済のDocker環境を壊してcontainerd+nerdctlの環境を作ってみる。

しかもオプションであるrootless containerd modeに挑戦してみた。

ポイント:

  • nerdctl-fullバイナリにはcontainerd本体やrunc、CNI プラグインなどが同梱されており、個別にcontainerdをインストールする必要はない。
  • nerdctlの最新のリリースバージョンを確認し、それをインストールする。
bash
# 1. Docker環境の完全削除
sudo systemctl disable --now docker.service docker.socket
sudo apt remove --purge -y docker-ce docker-ce-cli \
     docker-buildx-plugin docker-compose-plugin containerd.io
sudo rm -rf /var/lib/docker /var/lib/containerd
sudo rm -rf /etc/systemd/system/docker.service.d
sudo groupdel docker 2>/dev/null || true
sudo apt autoremove -y && sudo apt autoclean

# 2. nerdctl-full を`.local/bin/nerdctl`に展開(任意のバージョンを指定)
$ mkdir -p "$HOME/.local/bin/nerdctl"
$ curl -L https://github.com/containerd/nerdctl/releases/download/v{任意のバージョン}/nerdctl-full-{任意のバージョン}-linux-amd64.tar.gz \
       -o /tmp/nerdctl.tgz
$ tar -xzvf /tmp/nerdctl.tgz -C "$HOME/.local/bin/nerdctl"
$ rm /tmp/nerdctl.tgz

# 3. パスを通してコマンド確認(バージョンが表示されればOK)
$ sudo nano .bashrc
## nano で下記内容を末尾にそのまま貼り付けて保存(Ctrl + S)→ 退出(Ctrl + X)
export NERDCTL_HOME="$HOME/.local/bin/nerdctl"
export PATH="$NERDCTL_HOME/bin:$PATH"
export CNI_PATH="$NERDCTL_HOME/libexec/cni"
$ source ~/.bashrc
$ nerdctl --version

# 4. rootless containerdのためのAppArmorの設定(Ubuntu 24.04 以降で非標準パスの場合のみ)
$ sudo nano /etc/apparmor.d/usr.local.bin.rootlesskit
## nano で下記内容をそのまま貼り付けて保存(Ctrl + S)→ 退出(Ctrl + X)
abi <abi/4.0>,
include <tunables/global>
/home/**/.local/bin/nerdctl/bin/rootlesskit flags=(unconfined) {
    userns,
}
$ sudo apparmor_parser -r -W /etc/apparmor.d/usr.local.bin.rootlesskit

# 5. [Optional] rootless containerdのセットアップ
$ sudo apt update
$ sudo apt install -y uidmap dbus-user-session fuse-overlayfs
$ containerd-rootless-setuptool.sh install
$ systemctl --user enable --now containerd
## ログアウト後も継続起動する
$ sudo loginctl enable-linger $USER
## buildkitのセットアップ
$ containerd-rootless-setuptool.sh install-buildkit
$ systemctl --user daemon-reload
$ systemctl --user restart containerd

# 6. [Optional] インターネットプロキシ設定
$ mkdir -p ~/.config/systemd/user/containerd.service.d
$ sudo nano ~/.config/systemd/user/containerd.service.d/proxy.conf
## nano で下記のように任意のプロキシ設定をして保存(Ctrl + S)→ 退出(Ctrl + X)
[Service]
Environment="HTTP_PROXY={プロキシサーバ}"
Environment="HTTPS_PROXY={プロキシサーバ}"
Environment="NO_PROXY=localhost,127.0.0.1"
$ systemctl --user daemon-reload
$ systemctl --user restart containerd

以上でセットアップ完了。

いつでもDocker環境に戻れるように、nerdctl関連ファイルはすべて新規作成した.local/bin/nerdctlディレクトリ内に隔離するように意識してセットアップした。
一方、そのせいでツールが本来想定している場所ではないところにファイルがあるため、手順3.でパスを適切に通す必要がある。

nerdctlコマンドを試してみる

まずは、いつものhello-worldイメージが実行できるか試してみる。

Dockerなら$ docker run hello-worldだが、それをただ$ nerdctl run hello-worldに変えればよいだけ。

bash
$ nerdctl run hello-world

docker.io/library/hello-world:latest:                                             resolved       |++++++++++++++++++++++++++++++++++++++| 
index-sha256:c41088499908a59aae84b0a49c70e86f4731e588a737f1637e73c8c09d995654:    done           |++++++++++++++++++++++++++++++++++++++| 
manifest-sha256:03b62250a3cb1abd125271d393fc08bf0cc713391eda6b57c02d1ef85efcc25c: done           |++++++++++++++++++++++++++++++++++++++| 
config-sha256:74cc54e27dc41bb10dc4b2226072d469509f2f22f1a3ce74f4a59661a1d44602:   done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:e6590344b1a5dc518829d6ea1524fc12f8bcd14ee9a02aa6ad8360cce3a9a9e9:    done           |++++++++++++++++++++++++++++++++++++++| 
elapsed: 4.0 s                                                                    total:  13.9 K (3.5 KiB/s)                                       

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

何の問題もなく実行できた。

このhello-worldイメージはDocker HubからこのUbuntuサーバにpullしてきたもの。よってDocker Hubも問題なく使えるようだ。

イメージ一覧も確認してみる。

bash
$ nerdctl images

REPOSITORY     TAG       IMAGE ID        CREATED          PLATFORM       SIZE       BLOB SIZE
hello-world    latest    c41088499908    4 minutes ago    linux/amd64    16.38kB    4.018kB

おお、dockerコマンドがそのまま動く。

これなら追加の学習コストもほぼ無いだろう。素晴らしい。

他にも気になっていた、プライベートレジストリへのログインもできるか試してみる。

bash
$ nerdctl login {プライベートレジストリ} -u <USERNAME>

Enter Password: ********[Enter]

WARNING! Your credentials are stored unencrypted in '/home/ubuntu/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/

Login Succeeded

無事ログインできた。

ただしWarning文の通り、nerdctlもdockerと同じく.docker/config.jsonファイルにクレデンシャルを平文保存する仕様になっている。

もちろんこれではサーバ侵入時の盗難リスクがあるので、別途Credential Helperの仕組み(passなど)でGPG暗号ストアに保存したほうがよいだろう。

CUDAをrootlessで動かす

どんどんレベルを上げていく。次はNVIDIA/CUDAのベースイメージを起動してみる。

Dockerと同様にnvidia-container-toolkitのインストールが必要となるが、いままで--runtime=dockerとしていたオプションを--runtime=containerdとすればよいとのこと。

bash
$ sudo apt-get update
$ sudo apt-get install -y nvidia-container-toolkit
$ sudo nvidia-ctk runtime configure --runtime=containerd  # dockerの場合は`--runtime=docker`
$ systemctl --user daemon-reload
$ systemctl --user restart containerd

それでは実行してみる。

bash
$ nerdctl run --gpus all nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi

docker.io/nvidia/cuda:12.8.1-base-ubuntu24.04:                                    resolved       |++++++++++++++++++++++++++++++++++++++| 
index-sha256:133c78a0575303be34164d0b90137a042172bdf60696af01a3c424ab402d86e2:    done           |++++++++++++++++++++++++++++++++++++++| 
manifest-sha256:e711c99333fdfe8ae1e677b4972be6c5021f0128a1d31f775c7e58d88921b6a9: done           |++++++++++++++++++++++++++++++++++++++| 
config-sha256:b51a337dea83e0a5d246b5eafa204b6f0782a60dbf48cbbf05a731a2928fd4a2:   done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:5a7813e071bfadf18aaa6ca8318be4824a9b6297b3240f2cc84c1db6f4113040:    done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:a102f36d092c0e9e0bef8c97854f606af9156aa36ab408e6fa4b88e27124a7e6:    done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:73389fbd088f5ed5d9fd258baced59de092978b4f483920ea6d074522a105119:    done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:05ec76e31584ec109785cc7045bd88df0240411233c2fcdad66b621c662034c0:    done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:398182656c471d6ecca3c2d6d30e97193b40ffc8028a94515093960322f3d64e:    done           |++++++++++++++++++++++++++++++++++++++| 
elapsed: 7.4 s                                                                    total:  96.3 M (13.0 MiB/s)                                      
Tue Apr 29 11:06:58 2025       
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.144                Driver Version: 570.144        CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 3090        Off |   00000000:08:00.0 Off |                  N/A |
|  0%   22C    P8              7W /  350W |       1MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  NVIDIA GeForce RTX 3090        Off |   00000000:09:00.0 Off |                  N/A |
|  0%   20C    P8              8W /  350W |       1MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
                                                                                         
+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+

GPUも認識できている!rootlessだと相性問題に悩まされるかと思いきや、すんなり通って驚いた。

既存のDockerfileでビルドしてみる

次に、これまでの遺産が流用できるのか試していく。

NVIDIA/CUDAベースイメージに、癖の強いvllm(ローカルLLMの高速推論サーバを構築するPythonライブラリ)を入れて動かすという、依存関係がなかなか厳しいDockerfileを用意してビルドしてみる。

Pythonライブラリはパッケージマネージャ"uv"のpyproject.toml/uv.lockで管理し(詳細は割愛)、下記のようなDockerfileが置いてあるプロジェクトルートでビルドコマンドを実行してみた。

Dockerfile
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04

ENV PYTHONUNBUFFERED=1 \
    UV_COMPILE_BYTECODE=1 \
    CUDA_HOME=/usr/local/cuda \
    PATH=${CUDA_HOME}/bin:${PATH} \
    LD_LIBRARY_PATH=${CUDA_HOME}/lib64:${LD_LIBRARY_PATH}

WORKDIR /app

# 必要なパッケージをインストール
RUN apt-get update && apt-get install -y \
    ninja-build \
    && rm -rf /var/lib/apt/lists/*

# Python依存関係の2段階インストール
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY pyproject.toml uv.lock .python-version /app/
RUN uv sync --extra build --frozen --no-editable
RUN uv sync --extra build --extra compile --frozen --no-editable

# Hugging Face のキャッシュディレクトリを作成
RUN mkdir -p /root/.cache/huggingface/hub

EXPOSE 8001

CMD ["/bin/bash"]

docker buildコマンドと使い方は全く同じ。

bash
$ nerdctl build -f Dockerfile -t vllm-container . 2> build.log

unpacking docker.io/library/vllm-container:latest
Loaded image: docker.io/library/vllm-container:latest

ビルドが速くなった、などの変化は実感できなかったが(むしろより時間がかかったような気がする)、とりあえずこれまでのDockerfileに何一つ手を加えずにイメージをビルドできた。

既存のcompose.yamlとcomposeコマンドでコンテナを起動してみる

最後に、先ほどビルドした"vllm-container"イメージを、下記のようなcompose.yamlファイルをもとに起動してみる。

これで問題なければ、これまで私がローカルのDocker環境でやっていた作業はnerdctl環境でもこなせると言えそうだ。

懸念点は、LLMの重みファイルをホストマシンからコンテナのvolumesにマウントしており、元々Docker環境ではrootユーザー実行だったためマウント先のパスも/root/.cache/huggingface/hubになっているところだ。
rootless containerdでこのまま実行するとエラーになりそうな気がするが、果たして。

compose.yaml
services:
  vllm-server:
    image: vllm-container
    container_name: vllm-server
    runtime: nvidia
    shm_size: '10gb'
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 2
              capabilities: [gpu]
    ports:
      - "8001:8001"
    volumes:
      - /home/${USER}/.cache/huggingface/hub/models--Qwen--Qwen3-4B:/root/.cache/huggingface/hub/models--Qwen--Qwen3-4B
    restart: always
    stdin_open: true
    tty: true

OCIランタイムエラーになってしまった。

bash
$ nerdctl compose up -d

FATA[0000] failed to create shim task: OCI runtime create failed: unable to retrieve OCI runtime error (open /run/containerd/io.containerd.runtime.v2.task/default/7e73d1e92e349c45e18461a552b04773d45d6c72ee49627a15e1d2570cd550bf/log.json: no such file or directory): exec: "nvidia": executable file not found in $PATH: <nil> 
  • 原因: compose.yaml ファイルの runtime: nvidia 指定により、コンテナ実行時に "nvidia" という名前の OCI ランタイムを呼び出そうとし、その実行ファイルが存在しないためエラーとなった。
  • 背景: nerdctl(containerd)では、GPU サポートを有効化する際に NVIDIA Container Toolkit がデフォルトで介在するが、runtime: があると自動フックではなく OCI ランタイム登録を優先する仕様になっている。
    → つまりruntime: nvidiaをコメントアウトすればよさそうだ。

コメントアウトして、再実行してみる。

bash
$ nerdctl compose up -d

==========
== CUDA ==
==========

CUDA Version 12.8.1

Container image Copyright (c) 2016-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.

This container image and its contents are governed by the NVIDIA Deep Learning Container License.
By pulling and using the container, you accept the terms and conditions of this license:
https://developer.nvidia.com/ngc/nvidia-deep-learning-container-license

A copy of this license is made available in this container at /NGC-DL-CONTAINER-LICENSE for your convenience.

root@vllm-server:/app# 

無事に起動した。

おや、rootlessモードで起動したはずなのにroot表記になっているのはどういうことだろうか。

調べたところ、
私の知識が浅すぎてDockerfile の USER 指定containerd の rootless モードを混同して捉えていたことに気が付いた。

【解説】Dockerfile の USER 指定

→ コンテナ内部の実行ユーザー設定

  • Dockerfile の USER 命令や、docker run --user (あるいは Compose の user:)は、コンテナ内でプロセスがどの UID/GID で動くかを指定する。
  • これはあくまでコンテナ内部のユーザーであり、ホスト上での containerd デーモンや kernel の権限には影響しない。

【解説】rootless mode(ユーザー名前空間による非特権実行)

→ ホスト上での非特権実行

  • rootless mode では、containerd(または Docker daemon)自体を一般ユーザー権限で起動し、同一ユーザー名前空間内でコンテナを管理する。
  • コンテナ内の UID 0 は「ユーザー名前空間内での root」であり、ホスト側では /etc/subuid/etc/subgid で定義したサブUIDにマッピングされるため、ホスト上の本当の root 権限は持たない。

なるほど。

これまでDocker環境でもDockerfileUSER指定でnon-rootなユーザーを作って実行するようにしてきたが、どうやらそれだけでは悪意あるコンテナ脱出攻撃(container breakout)が成功したときの対策になっていなかったことを初めて認識した。

つまり今回conatinerdのrootless modeを使うことで、コンテナブレイクアウトが起きたとしてもホストの root 権限が奪取される危険性が低減できるようだ。

nerdctlだけでなくDockerにもRootlessモードはあるが、いろいろデメリットがあり、特にネットワーク性能の低下がツラいとのこと。
一方でcontainerdのRootlessモードは圧倒的に高速とのこと。有用性がようやく理解できた。

表にするとこのようになる。(Powerd by ChatGPT o4-mini-high)

項目 Docker Rootless containerd (nerdctl) Rootless
デーモン実行 ユーザー権限で dockerd を起動 ユーザー権限で containerd を起動
ストレージドライバ 古い環境は fuse-overlayfs 標準でネイティブ overlayfs が使え、必要に応じて fuse-overlayfs
ネットワーク性能 ユーザースペース経由で数百Mbps 程度に制限される bypass4netns などで数十Gbps に近い高速通信になった例も
特権ポート(1024未満) バインド不可(回避に別途対応が必要) 同じく制限ありだが限定的に緩和設定可能
I/O 性能 fuse-overlayfs 使用時はレイヤー多いほど遅くなる ネイティブ overlayfs が動く環境では I/O 劣化が少ない
セキュリティ隔離 ホスト root 権限不要でデーモン・コンテナを隔離 同等の隔離を提供しつつ、構成がよりシンプルで攻撃面が小さい

感想

containerd+nerdctl を rootless で動かす、しかも GPU まで認識させる、という"沼"に割と気軽に飛び込めたので、私のレベルでも十分実用できると感じた。

正直「Docker で十分なんでしょ?」という先入観ありきだったが、rootless の仕組みを勉強してみると 「ホストを守りつつコンテナを遊ばせる」 という設計思想がようやく腑に落ちたので、よりrootless が使いやすい containerd+nerdctl に移行する意義もあるように思えてきた。

移行に関して、実際に試した範囲ではnerdctl のdocker互換性が高すぎてタイピングの指が Docker から切り替わったことに気付かないレベル。

VSCode拡張がまだ非対応な点が正直キツいが、CLIでやってできないこともないので、許容範囲内。

パフォーマンス面は期待しすぎ禁物。bypass4netns を入れたらベンチマークでは数字が跳ねたとのことだが、私の実用ワークロードでは「速い…のか?」くらい。逆に言えば 乗り換えても遅くならないしセキュリティは上がる ので、今後は 新規環境は containerd、既存は Docker 継続 の方針で使い分けてみようと思う。


最後に──「ただなんとなくDocker」のままでいいのだろうかと思い、今回 containerd+nerdctl に飛び込んでみた。その結果 rootless の意味や利点などの知見が広がったのが思わぬ収穫だった。

次は Podman を触ってみる予定。先人たちが作ってくれたコンテナ技術、もっと理解度を深めていきたい。

Discussion