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

参考資料等