kaniko,makisが安全にコンテナ内でコンテナをビルドする方法

この記事は kb Advent Calendar 2020 9 日目の記事です。 https://adventar.org/calendars/5280

CI 等の環境において、安全でない Dockerfile をビルドするのは意外と難しいです。 Docker daemon は通常 root 権限で Docker Image をビルドするので、任意の Dockerfile が与えられれば root 権限でコマンド実行・ファイル読み取り等を行うことが出来ます。 どのようにして CI のパブリックサービス・OSS ツールがこれらの危険性を排除しているのかを少し覗いてみます。

前提知識: docker image

  • docker image は Dockerfile のビルドステップ毎に tar で固められたファイル群
  • RUN 毎にレイヤーが作成さる
  • overlayfs 等を利用してマウントする
$ docker save -o nginx.tar nginx
$ tar -xf nginx.tar
$ tree
.
├── 0adbcf5498ce34d0cda9d5e0cf7309e949c561e2a99a01386805b8f589d191b6
   ├── VERSION
   ├── json
   └── layer.tar
├── 1bc6c95c052bfb6673fbd03e8eda24cadcf121da3c04d0d7354ed91e6571db2d
   ├── VERSION
   ├── json
   └── layer.tar
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
├── e5dcd7aa4b5e5d2df8152b9e58aba32a05edd9b269816f5d8b7ced535743d16c.json
├── f524400fa284c4683c183fb29fcea77d719b3d60c114e8bc7e4bc161b26036f5
   ├── VERSION
   ├── json
   └── layer.tar
├── manifest.json
├── nginx.tar
└── repositories

VM 内でのビルド

まず最初に最も単純な例です。ユーザに EC2,GCE,qemu 等のマシンを 1 台割り当ててしまいます。

  • ユーザに Ubuntu 等の VM を提供した上で、中で普通の Docker を普通に利用する
  • Docker 以外にもカーネル機能を利用するツールも動作可能で利便性が高い
  • GitHub Actions, Travis 等が採用
  • 起動を高速化するのは難しい・構成管理が複雑になりがち

→ デメリットは有るものの、安全で様々な用途に利用可能

docker in docker

Docker コンテナの内部で Docker daemon を動かす手法です。 元々 Docker 自体の開発のために作られた仕組みで、CI には向かないとの事です。

  • Docker の中で Docker を動かす
  • docker:dind イメージで実行が可能
  • --privileged が必要で、セキュリティ的に危険
  • パフォーマンスが悪い
  • Docker と cgroups はネスト環境でバグが発生しやすい(らしい(ホンマか?))

→ 危険だし遅いし良いことはない

参考: http://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/

Docker outside of Docker

先の dind の代替として推奨されているパターンです。 ホストマシンの Docker Daemon や、他のマシンで動く Docker Daemon を TCP/Unix Domain Socket で接続するという方法です。

  • docker build はアドレスを指定すれば他のマシンの Docker daemon で行える
    • ファイル等はソケットを通じて転送されるので、外部マシンだと遅くなる
  • 肝心の Docker daemon はセキュリティ分離するなら VM 上で実行する必要が有る
  • CircleCI, GCP Cloud Build が利用しているアプローチ
  • Docker でビルド環境が提供可能で構成管理が楽

→ 利用者としては構成管理が楽・Docker Daemon をユーザごとに異なる VM で実行する必要が有る

Bazel rules_docker

Dockerfile のビルドは行えないが、アプローチとしては面白いので紹介します。 Docker で起動可能なイメージファイルを作れば勝ちというスタンスです。

  • 例の Google が好きなビルドツール
  • rules_docker と言いつつ、Docker daemon を必要としない
  • Bazel で作ったバイナリファイルを含めた tar のイメージファイルを作る
    • cgroups,croot 等が必要となる RUN をそもそも使わない
  • とりあえず docker が食える tar 作れば良いんでしょ

→Docker Daemon 依存がないので特権不要で安全

docker-rooless

Linux 一般ユーザを利用者に割り当てることが出来る場合に利用可能な手法です。 現在の Docker は一定の条件で一般ユーザとして Docker daemon が実行可能です。

  • rootless
    • 現在の Docker には --privileged が不要な Docker daemon が存在する
    • 条件を満たしていれば一般ユーザでも実行が可能
  • ネストしたコンテナ内での実行
    • Docker 内で実行する場合は --security-opt seccomp=unconfined --security-opt apparmor=unconfined が必要
    • 若干セキュリティに不安感

→ 出来ればコンテナの中に閉じ込めたいが、seccomp 等の保護が利用できなくなる

以下利用手順です。 ここでは moby/buildkit:rootless イメージを利用してビルドを行っています。 イメージが tar で吐き出された事を確認できるかと思います。

$ docker run \
  --name buildkitd \
  -d \
  --security-opt seccomp=unconfined \
  --security-opt apparmor=unconfined \
  --device /dev/fuse \
  moby/buildkit:rootless --oci-worker-no-process-sandbox
$ docker exec -it buildkitd sh

https://github.com/moby/buildkit/blob/master/docs/rootless.md#docker https://matsuand.github.io/docs.docker.jp.onthefly/engine/security/rootless/

/tmp $ echo "FROM nginx" > Dockerfile

/tmp $ buildctl build --frontend dockerfile.v0 --local context=. --local dockerfile=. --output type=tar,dest=img.tar
[+] Building 9.9s (5/5) FINISHED
 => [internal] load build definition from Dockerfile                                                                       0.0s
 => => transferring dockerfile: 31B                                                                                        0.0s
 => [internal] load .dockerignore                                                                                          0.0s
 => => transferring context: 2B                                                                                            0.0s
 => [internal] load metadata for docker.io/library/nginx:latest                                                            1.0s
 => [1/1] FROM docker.io/library/nginx@sha256:31de7d2fd0e751685e57339d2b4a4aa175aea922e592d36a7078d72db0a45639             6.3s
 => => resolve docker.io/library/nginx@sha256:31de7d2fd0e751685e57339d2b4a4aa175aea922e592d36a7078d72db0a45639             0.0s
 => => sha256:ac39958ca6b1ac9821b2055605fde9cd59a6716c245809eaffa1c1606609a2f1 668B / 668B                                 0.5s
 => => sha256:a85e904c7548aa021b629632b31e56d150e020f0af0be292429911c65ba8fa30 904B / 904B                                 0.5s
 => => sha256:5928664fb2b31be4ad7f1899d7725b31a5e5f7fb45d2ed5fb76ec2d67fdf7b90 602B / 602B                                 0.5s
 => => sha256:bbce32568f491ce0d0b311f55a4d3483e11d66b03756224eb952fae665d19899 26.50MB / 26.50MB                           3.5s
 => => sha256:6ec7b7d162b24bd6df88abde89ceb6d7bbc2be927f025c9dd061af2b0c328cfe 27.10MB / 27.10MB                           3.8s
 => => extracting sha256:6ec7b7d162b24bd6df88abde89ceb6d7bbc2be927f025c9dd061af2b0c328cfe                                  0.8s
 => => extracting sha256:bbce32568f491ce0d0b311f55a4d3483e11d66b03756224eb952fae665d19899                                  1.6s
 => => extracting sha256:5928664fb2b31be4ad7f1899d7725b31a5e5f7fb45d2ed5fb76ec2d67fdf7b90                                  0.0s
 => => extracting sha256:a85e904c7548aa021b629632b31e56d150e020f0af0be292429911c65ba8fa30                                  0.0s
 => => extracting sha256:ac39958ca6b1ac9821b2055605fde9cd59a6716c245809eaffa1c1606609a2f1                                  0.0s
 => exporting to client                                                                                                    8.8s
 => => sending tarball                                                                                                     2.5s

/tmp $ ls
Dockerfile  img.tar

kaniko

本題です。 他の手法と異なり、特権等が必要とならないアプローチを採用しているようです。

  • 非特権・Docker コンテナ内で安全にコンテナをビルド
    • 他の手法で利用されている chroot, overlayfs, cgroups 等を使わない
    • k8s などで pod として実行が可能
  • CI ツール tekton などで使われている

→ 安全そう・でもどうやってんの??

https://github.com/GoogleContainerTools/kaniko

試しに Docker 内で実行してみます。 --privileged --security-opt seccomp=unconfined 等のフラグを渡す必要は有りません。 :debug のタグだとシェルが入っているようです。

$ docker run --rm -it --entrypoint= gcr.io/kaniko-project/executor:debug sh

ここからはコンテナ内部のシェルです。 Dockerfile を適当にビルドしてみます。 ビルドが終了したタイミングで ls 等を叩いてみると、FROM で指定した nginx のファイルが展開されていることが分かります。 まさかのルートに展開されているようです。

/tmp # echo -e "FROM nginx\nRUN id" >> Dockerfile
/tmp # cat Dockerfile
FROM nginx
RUN id

/tmp # /kaniko/executor --context . --dockerfile Dockerfile --destination=image-name --no-push --tarPath out.tar
INFO[0000] Retrieving image manifest nginx
INFO[0000] Retrieving image nginx
INFO[0002] Retrieving image manifest nginx
INFO[0002] Retrieving image nginx
INFO[0004] Built cross stage deps: map[]
INFO[0004] Retrieving image manifest nginx
INFO[0004] Retrieving image nginx
INFO[0006] Retrieving image manifest nginx
INFO[0006] Retrieving image nginx
INFO[0009] Executing 0 build triggers
INFO[0009] Unpacking rootfs as cmd RUN id requires it.
INFO[0013] RUN id
INFO[0013] Taking snapshot of full filesystem...
INFO[0014] cmd: /bin/sh
INFO[0014] args: [-c id]
INFO[0014] Running: [/bin/sh -c id]
uid=0(root) gid=0(root) groups=0(root)
INFO[0014] Taking snapshot of full filesystem...
INFO[0014] No files were changed, appending empty layer to config. No layer added to image.

/tmp # ls /etc/nginx/
conf.d          koi-utf         mime.types      nginx.conf      uwsgi_params
fastcgi_params  koi-win         modules         scgi_params     win-utf

kaniko は以下のように Dockerfile をビルドします

  • Dockerfile をパース
  • 外部イメージをフェッチ
    • FROM HOGE/kaniko/<image name>/... に展開して置く
    • COPY --from=HOGE/kaniko/stages/<image name> に tar を置く
  • ステージ毎に build
    • ベースイメージを / に展開する (/var/run /kaniko 等は保護される)
    • 最初の snapshot を作成
      • / 以下のすべてのファイル内容・メタデータをメモリ上に保持
      • 今持っているファイルを無視リストに追加
    • 各コマンドの実行
      • 普通に RUN にかかれているコマンドを exec する
      • コマンド実行後に都度 snapshot を作成
        • 全てのファイルをスキャン・変更箇所を tar の layer ファイルを作る
    • 最終的なステージ成果物 tar を /kaniko/stages/<stage id> に置く

chroot, overlayfs, cgroups なんて要らなかったんや。 しかしながら、コンテナの外で実行すると / が破壊されて死ぬので注意が必要です。

makisu

イメージの作り方は kaniko とほぼ同じです。 レイヤキャッシュをサポートしている点が異なります。

  • Uber が作ってる
  • レイヤ毎のキャッシュをサポートしている
    • kaniko ではレイヤキャッシュは行えず、最終成果物のキャッシュのみ保持可能
    • makisu ではローカルファイル・Redis・HTTP API 等を利用してレイヤ毎のキャッシュを保持できる
    • これによりビルド時間の短縮が可能

https://github.com/uber/makisu

まとめ

  • マルチテナント環境で docker build するために各社色々頑張ってやってる
  • kaniko, makis はネストしたコンテナを作成しない
    • ファイル構造をメモリ上に持ってレイヤー FS をシミュレーションする
    • コンテナの中だけでしか出来ないハックっぽい
    • 仕組み上並列ビルドが行えないので、パフォーマンスは本家 docker build(buildkit)に劣る場合が有るだろう
  • kaniko, makisu の互換性を信用して良いものか
    • Dockerfile を食って OIC イメージを吐く機械という性質は Docker 一緒
    • 今後の docker, buildkit の新機能に追従できるか不安

参考資料等