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
をそもそも使わない
- cgroups,croot 等が必要となる
- とりあえず docker が食える tar 作れば良いんでしょ
→Docker Daemon 依存がないので特権不要で安全
docker-rooless
Linux 一般ユーザを利用者に割り当てることが出来る場合に利用可能な手法です。 現在の Docker は一定の条件で一般ユーザとして Docker daemon が実行可能です。
- rootless
- 現在の Docker には
--privileged
が不要な Docker daemon が存在する - 条件を満たしていれば一般ユーザでも実行が可能
- 現在の Docker には
- ネストしたコンテナ内での実行
- Docker 内で実行する場合は
--security-opt seccomp=unconfined --security-opt apparmor=unconfined
が必要 - 若干セキュリティに不安感
- Docker 内で実行する場合は
→ 出来ればコンテナの中に閉じ込めたいが、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 の新機能に追従できるか不安
参考資料等