upinetree's memo

Web系技術の話題や日常について。

Docker-in-Docker で複数の docker-compose を同時に立ち上げる

課題設定

  • やりたいこと
    • project-a, project-b それぞれが docker-compose.yml を持っている状況
    • どちらも一緒に立ち上げて、協調動作させたい
    • リクエストは nginx で受けられると最高(最終的に https でつなげたい)
  • 条件
    • このための各プロジェクトの設定変更はなるべくしたくない
    • 各プロジェクトの変更で動かなくなるリスクが低い
    • 各プロジェクトの変更に追従するコストが低い

この課題に対し、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 を指定して実行する

どちらの方法も、以下の問題がある。

そこで、第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 つのサービスを用意している。

となっている。つまり、親となる dind コンテナの中で、サブディレクトリの compose file で定義される子コンテナたちが動作し、その動作の司令を compose コンテナから行うといった構図。

project-a-compose サービスでは DOCKER_HOSTtcp//docker:2376 を指定して、 docker-compose が参照する docker daemon がどこにあるかを教えている。

  • ホスト名 docker
  • ポート番号 2376
    • dind コンテナが docker デーモンを起動するデフォルトの番号

両コンテナには ./project-a ディレクトリをマウントしている。それぞれ project-a/docker-compose.yml で定義した子コンテナの立ち上げの過程で、次の目的で利用している。

  • project-a-dockerd では web 子コンテナが /app 上で yarn install するため
  • project-a-compose では backend 子コンテナの Dockerfile を参照するため

以上の概略を表した構成図がこちら(雑なメタモデリングなのはご容赦をば…)。

f:id:upinetree:20210221154607p:plain
dind-with-compose 概略図

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 にマウント
    • dind は TLS 証明書一式を DOCKER_TLS_CERTDIR に自動生成する
    • dind が親とする docker イメージは DOCKER_TLS_CERTDIR/certs指定する
    • クライアント用の証明書と鍵は /certs/client生成する
    • volume を定義するのではなく、ローカルディレクトリをマウントしてもよい
      • ホストから直接内部の docker を叩いて状況を確認したり実験したりするのに使える
  • 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"

このあたりの処理によるものだろう。

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 コンテナのディレクトリ構成をホストと同じにするか、その逆の状況を作らないといけない。