Docker-in-Docker で複数の docker-compose を同時に立ち上げる
課題設定
- やりたいこと
- 条件
- このための各プロジェクトの設定変更はなるべくしたくない
- 各プロジェクトの変更で動かなくなるリスクが低い
- 各プロジェクトの変更に追従するコストが低い
この課題に対し、Docker-in-Docker で解決を試みる!
前提プロジェクト
以下のように compose file が配置されていて、
$ tree . ├── project-a │ └── docker-compose.yml └── project-b └── docker-compose.yml
サブディレクトリの compose file はこうなっているとする。
# project-a/docker-compose.yml version: "3" services: backend: build: context: ./ dockerfile: backend/Dockerfile ports: - 3000:3000 web: image: node:12 volumes: - ./web:/app working_dir: /app command: yarn # project-a/docker-compose.yml version: "3" services: web: image: docker/getting-started ports: - 80:80
project-a, project-b を同時に docker-compose で立ち上げたい。どうする?
- a) それぞれ docker-compose を実行する
- b)
docker-compose -f
で両方の compose file を指定して実行する
どちらの方法も、以下の問題がある。
- local port が重複する可能性がある
- プロジェクトが増えるたびに重複を気にして調整したくない
- docker compose は各種ファイル参照をベース compose file からの相対パスで解決しようとする
- ベースじゃない方の compose file 内の相対パスが機能しなくなる可能性がある
- https://docs.docker.com/compose/extends/#understanding-multiple-compose-files
- https://github.com/docker/compose/issues/3874
そこで、第3の選択肢として Docker-in-Docker (DinD) を使った方法を試す。
Docker-in-Docker 用の compose file
実際に動作するサンプルリポジトリを作成した。
https://github.com/upinetree/dind-with-compose
このサンプルでは、トップディレクトリに DinD 用の compose file を配置している。ファイルツリーで表せば、
$ tree . ├── docker-compose.yml ├── project-a │ └── docker-compose.yml └── project-b └── docker-compose.yml
のようになる。DinD 用の docker-compose.yml の内容はこんな感じ。
version: "3" services: project-a-dockerd: image: docker:20-dind privileged: true volumes: - dind-certs-client-a:/certs/client - dind-data-a:/var/lib/docker - ./project-a:/project-a project-a-compose: image: docker/compose:latest environment: - DOCKER_HOST=tcp://docker:2376 - DOCKER_TLS_VERIFY=1 - DOCKER_CERT_PATH=/certs/client volumes: - dind-certs-client-a:/certs/client - ./project-a:/project-a working_dir: /project-a links: - project-a-dockerd:docker # dind の TLS 証明書の SAN に該当するようにエイリアス設定 command: up project-b-dockerd: project-b-compose: # (snip) project-a-xxx と同様 nginx: image: nginx volumes: - ./nginx.conf:/etc/nginx/nginx.conf ports: - 80:80 - 443:443 volumes: dind-data-a: dind-data-b: dind-certs-client-a: dind-certs-client-b:
docker-compose up
すると、両方のプロジェクトのコンテナが立ち上がっていることが確認できる。
$ docker-compose up -d $ docker-compose ps $ docker-compose exec project-a-compose docker-compose ps $ docker-compose exec project-b-compose docker-compose ps
※たまにコンテナが立ち上がるタイミングの都合で project-x-compose コンテナが異常終了していることがあるが、再度 docker-compose up -d すれば成功する。このタイミング問題の解決方法は後述
立ち上がった状態で http://a.project.localhost, http://b.project.localhost にアクセスすれば、nginx 経由でそれぞれレスポンスが返ってくるはず。
概略
以下、簡単のため project-a に絞って説明する。 project-a の設定のうち、関係性を把握するための最小限のパーツを抜粋するとこんな感じ。
services: project-a-dockerd: volumes: - ./project-a:/project-a project-a-compose: environment: - DOCKER_HOST=tcp://docker:2376 volumes: - ./project-a:/project-a working_dir: /project-a links: - project-a-dockerd:docker
project-a を動かすために 2 つのサービスを用意している。
- docker daemon を動かす project-a-dockerd サービス (docker:dind)
- docker compose を動かす project-a-compose サービス (docker/compose)
となっている。つまり、親となる dind コンテナの中で、サブディレクトリの compose file で定義される子コンテナたちが動作し、その動作の司令を compose コンテナから行うといった構図。
project-a-compose サービスでは DOCKER_HOST
に tcp//docker:2376
を指定して、 docker-compose が参照する docker daemon がどこにあるかを教えている。
- ホスト名
docker
- links 設定でエイリアスされた project-a-dockerd
- dind の TLS SAN が定義するホスト名になるようにしている。後述するが、docker デーモンへの通信は TLS で行われる
DOCKER_TLS_SAN
環境変数も使えるっぽい雰囲気があるが試していない
- ポート番号
2376
- dind コンテナが docker デーモンを起動するデフォルトの番号
両コンテナには ./project-a
ディレクトリをマウントしている。それぞれ project-a/docker-compose.yml
で定義した子コンテナの立ち上げの過程で、次の目的で利用している。
project-a-dockerd
ではweb
子コンテナが/app
上で yarn install するためproject-a-compose
ではbackend
子コンテナの Dockerfile を参照するため
以上の概略を表した構成図がこちら(雑なメタモデリングなのはご容赦をば…)。
dind の TLS 設定
docker:dind
コンテナはデフォルトで TLS を利用することになる。以下、関わる設定を中心に説明する。
services: project-a-dockerd: volumes: - dind-certs-client-a:/certs/client project-a-compose: environment: - DOCKER_TLS_VERIFY=1 - DOCKER_CERT_PATH=/certs/client volumes: - dind-certs-client-a:/certs/client volumes: dind-certs-client-a:
- TLS 証明書や鍵を project-a-compose に渡すため、
/certs/client
を volume にマウント - project-a-compose の
DOCKER_TLS_VERIFY
,DOCKER_CERT_PATH
は、docker-compose が dockerd を叩く際に、指定の鍵を使って TLS で接続するよということを教えている
開発環境にとっては正直面倒くさいし設定が複雑になるので、無効化しても良いかもしれない。と最初は思っていた。しかしこれはあまり良くないアイデアだった(後述)。
子コンテナのキャッシュ
volume の dind-data-a
, dind-data-b
は、 dind コンテナで動く docker イメージ等のキャッシュ用。親コンテナを作り直すたびに、小コンテナをダウンロードするところからやり直しになるのがつらいので作った。
project-a-dockerd: image: docker:20-dind volumes: - dind-data-a:/var/lib/docker
volume で定義したが、ホストにマウントして手軽にキャッシュ操作できるようにしてもよいかもしれない。キャッシュを削除するにはボリュームを削除すれば良い。
子コンテナのボリュームを削除したいなら、対象の compose コンテナから操作する。
docker-compose run --rm project-b-compose docker-compose down -v
これに限らず、子コンテナに対する操作を頻繁に行うようなら Makefile やシェルスクリプトでコマンドを作成しておくと便利。
nginx 設定
# (snip) http { server { listen 80; server_name a.project.localhost; access_log /dev/stdout; location / { proxy_pass http://project-a-dockerd:3000; proxy_set_header Host $host; } } # (snip) }
docker-compose が定義するデフォルトの network を使えば、proxy_pass は http://project-a-dockerd:3000
のように表せる。
dockerd の起動を待つ
状況によっては docker-compose の実行の前に dockerd が立ち上がっていないことがある。すると、以下のようなエラーとなって docker-compose が失敗する。
Couldn't connect to Docker daemon at https://docker:2376 - is it running?
dockerd が立ち上がってから再実行すれば問題なく起動できるのだが、高頻度で失敗するのが煩わしい場合がある。 解決策はシンプルで、dockerd が立ち上がってるのを確認できるまで docker-compose up を再実行すればよい。次のドキュメントが参考になる。
dockerize 等を使ってもよいが、まずは単純なスクリプトで十分だろう。
#!/bin/sh set -e cmd="$@" until docker ps 1>/dev/null; do >&2 echo "Docker daemon is unavailable - sleeping" sleep 1 done echo "Docker daemon is up - executing command" exec $cmd
project-a-dockerd: volumes: - ./wait-for-dockerd.sh:/wait-for-dockerd.sh command: - /wait-for-dockerd.sh - docker-compose - up
dind が特権モードを要求 (privileged: true) することについて
ローカル開発環境では dind コンテナ自体が露出することはないのでリスクは低い認識でいる。考えられるリスクは…
- 内部でさらに特権モードで野良イメージを動かしたときにホスト環境にいたずらされる可能性
- dind のアップデートで脆弱性が生じる可能性
くらいだろうか(本当?)。
ローカルに閉じておらず外部に露出しているコンテナであれば、インジェクション系の脆弱性を付いた攻撃の対策は必要になりそうだ。
dind の rootless 版があるので、もしそれで手っ取り早く対策できるなら、やらないよりやるほうがいいだろう。
https://docs.docker.jp/engine/security/rootless.html#rootless-docker-in-docker
なお、利用にはまだ experimental フラグが必要。しかし docker-compose での指定方法がわからん…よってまだ試せていない。config ファイルに指定する方法があるようなので、それを compose コンテナに配置すれば良いだろうか。
試したが微妙だったこと
dockerd への TLS 接続の無効化
ローカル開発環境のような、外部に露出しておらず、侵入される危険性も小さい環境では、TLS の設定を無効化して compose file をシンプルにしたい気持ちが高まる。 TLS 接続を無効化するには、README の次の記述通りにやればできそうだ。
To disable this image behavior, simply override the container command or entrypoint to run dockerd directly
しかし dockerd
を直接起動するためには、適切に --host
オプションを与えてやる必要がある。
デフォルトの entrypoint である dockerd-entrypoint.sh
は、 DOCKER_TLS_CERTDIR
が空のときにいい感じに TLS disabled な dockerd を立ち上げてくれるようだ。やったね。
https://github.com/docker-library/docker/blob/master/20.10/dind/dockerd-entrypoint.sh#L127-L133
env に DOCKER_TLS_CERTDIR=
を指定して無効化してみると、以下のような waring とともに dockerd の起動が遅くなった。
"Binding to an IP address without --tlsverify is deprecated. Startup is intentionally being slowed down to show this message" "Please consider generating tls certificates with client validation to prevent exposing unauthenticated root access to your network" "You can override this by explicitly specifying '--tls=false' or '--tlsverify=false'" "Support for listening on TCP without authentication or explicit intent to run without authentication will be removed in the next release"
このあたりの処理によるものだろう。
- https://github.com/moby/moby/blob/v20.10.2/cmd/dockerd/daemon.go#L676-L679
- https://github.com/moby/moby/pull/41285
warning 通りに --tls=false
すれば通常通り動くのだろうが、将来 binding できなくなる可能性が高いので、ここは素直に TLS にしておいたほうが良さそうだ。安全第一である。
ホストから子コンテナの docker-compose を実行する方法
以下のような compose file が配置されているとする。
# ./docker-compose.yml version: "3" services: project-a: image: docker:20.10.2-dind privileged: true tty: true ports: - 5301:2376 # docker (tls) - 9000:80 # nginx volumes: - ./certs/ca:/certs/ca - ./certs/client:/certs/client
# project-a/docker-compose.yml version: "3" services: nginx: image: nginx:latest ports: - 80:80
トップディレクトリで起動する DinD コンテナが親コンテナ。サブディレクトリで定義されているコンテナが、親コンテナの中で起動する子コンテナ。
この方法では、親コンテナが 5301:2376
でポートフォワーディングしているのを利用して、ホストのサブディレクトリから親コンテナに接続して docker-compose を実行する。
そのために、ホストで必要な環境変数を設定する。ここでは direnv を使う。
# ./project-a/.envrc export DOCKER_HOST="tcp://127.0.0.1:5301" export DOCKER_TLS_VERIFY=1 export DOCKER_CERT_PATH="../certs/client"
親→子の順で docker-compose を起動していく。
$ docker-compose up -d $ pushd project-a; docker-compose up -d; popd $ curl http://127.0.0.1:9000
レスポンスが期待通りなら成功。ここまではうまく動くはず。
次に、ホストのディレクトリを子コンテナまでマウントしたいときを考える。そのために、ホストのディレクトリを親コンテナにマウントし、それを子コンテナにマウントすることになる。
# 親コンテナ (docker-compose.yml) services: project-a: image: docker:20.10.2-dind volumes: - ./project-a:/project-a # 子コンテナ (project-a/docker-compose.yml) services: web: image: node:12 volumes: - ./web:/app working_dir: /app
これで子コンテナを立ち上げると、以下の問題が発生する。
- 中身が空の
/app
が出現する。つまりマウントに失敗している - 子コンテナに指定している
./
が、親コンテナ上のどこになるのかが不明
相対パスではなく絶対パス指定だとマウントできるようだ。しかし冒頭に示した条件のとおり、DinD のために子 compose file の内容を変更しないことが理想なので、絶対パス指定はしたくない…。
volumes: - - ./web:/app + - /project-a/web:/app # マウントに成功するがやりたくない
子コンテナを立ち上げる際に --project-directory
オプションを渡して、親コンテナ上のマウント先を指定すれば?
$ cd project-a $ docker-compose --project-directory /project-a up -d
うまくいったように思われる。ところが、これがこの構成の限界。
さらに、子コンテナ用の Dockerfile が親コンテナのマウント対象に含まれている場合を考える。docker-compose で独自の Dockerfile をビルドするケースはよくあるはず。しかし --project-directory /project-a
を指定していると、そのパス解決がうまくいかずビルドできなくなる。
# 子コンテナ (project-a/docker-compose.yml) services: backend: build: context: ./ dockerfile: backend/Dockerfile ports: - 9000:9000
$ cd project-a $ docker-compose --project-directory /project-a up -d ERROR: build path /project-a either does not exist, is not accessible, or is not a valid URL.
ビルド時の Dockerfile 参照はホストで行われているため、 --project-directory
の指定がホストにとっては誤りとなるようだ(ソースコード読んだわけじゃないので確定ではないが、動作からおそらくそういうことだと思われる)。
つまり、マウント時のコンテキストとイメージ作成時のコンテキストが同じ状況じゃないと docker-compose up
はできない。したがってこの構成で続行するためには、dind コンテナのディレクトリ構成をホストと同じにするか、その逆の状況を作らないといけない。