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の新機能に追従できるか不安
参考資料等