morishitaです。
アクトインディでは開発環境で Docker Compose を利用しています。
Rails などで構築する一般的な Web アプリケーションは DB を必要とするとので最低でも次の2つのコンテナを含むと思います。
- DB のコンテナ
- アプリケーションコンテナ
アクトインディで実際に開発に使っているものでは nginx や webpack-dev-server のコンテナのあったりするので 3−4コンテナ動きます。
これらのコンテナは決まった順番に起動するしないとエラーになったりします。
例えば、Rails は DB に接続できないと起動に失敗します。
そんな起動順を制御する仕組みとして docker-compose
には depends_on
があります。
これまで各サービスの起動順を制御するものくらいのふわっとした理解で使ってきましたが、理解を深めるためいくつか試してみました。
試した環境は次の通りです。
- Docker Desktop 4.5.0 (on M1 mac)
- Docker version 20.10.12
- Docker Compose version v2.2.3
depends_on
と healthcheck
について
depends_on
は docker-compose.yml で定義されている service
間の依存関係を定義するものです。
例えば、次の様な定義すると、other_service は one_service に依存します。
version: '2.4' services: one_service: image: oneimage:latest other_service: image: other_image:latest depends_on: - one_service
他のサービスに依存しているものは依存サービスの起動後に起動します。 つまり上記の例では次の順で起動します。
- one_service
- other_service
また、 depends_on
は condition
プロパティを付けることもでき、次のいずれかの値を設定します。
service_started
:依存サービスが開始するまで待つservice_healthy
:依存サービスのヘルスチェックがパスするまで待つservice_completed_successfully
:依存サービスの実行が成功で終了するまで待つ
service_started
は condition
プロパティを指定しない場合と同じ動作です。
ここで注意すべきは「サービスが開始する」ことと「他のサービスからのアクセスを受け付けられる」ことは異なるということです。例えば、MySQL や PostgreSQL などはプロセス開始直後からすぐにクエリを実行できるわけではありません。プロセス開始後に起動処理が実行されクエリを受け付けることができる状態になるまでに少なくとも数秒かかると思います。
service_started
はサービスが開始されるのを待つだけなので、「他のサービスからのアクセスを受け付けられる」状態になっているかどうかは保証しません。
service_healthy
は適切なヘルスチェックを行うことにより「他のサービスからのアクセスを受け付けられる」状態になるまでそれに依存しているサービスは起動を待ちます。
service_completed_successfully
は依存サービスがなにか処理し、それが終了する(=コンテナが終了する)を待つものです。別サービスで前処理をする場合などに使うのだと思います。Kubernetes の Pod の initContainers の様に使うためにあるようです。
service_healthy
のために必要となるヘルスチェックは次の様に定義します。
healthcheck
以下がヘルスチェックの定義です。
この例では one_service
のヘルスチェックが通るのを待って other_service
が起動します。
version: '2.4' services: one_service: image: oneimage:latest healthcheck: test: "curl -f http://localhost:8080 || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s other_service: image: other_image:latest depends_on: web1: condition: service_healthy
healthcheck
は次のパラメータを持っています。
test
: ヘルスチェック方法をコマンド等で指定するinterval
: チェックの間隔timeout
: ヘルスチェックコマンドのタイムアウト時間retries
: リトライ回数start_period
: ヘルスチェックが fail しても無視する時間。
healthcheck
を設定したサービスは起動直後 starting
状態となります。
その後、ヘルスチェックに成功すれば healthy
状態 、retries
の回数失敗すれば unhealthy
状態となります。
それを判定する test
は healthy
なら終了ステータス 0
を返し、
unhealthy
の場合には終了ステータス 1
を返すコマンドまたはスクリプトを設定する必要があります。
ヘルスチェックはコンテナ起動直後から始まるようですが start_period
の間は失敗しても無視します。
サービスの起動処理中は失敗しても止むを得ないということですね。start_period
の時間内でもチェックに成功した場合はすぐ healthy
となります。
実験
depends_on
と healthcheck
により各サービスの起動がどの様に制御されるのかを実験してみようと思います。
そのために極簡単な Web サーバを実装し次の様なファイル構成で docker-compose アプリケーショ値を作りました。
dc-sample/ ├── app/ │ └── index.erb ├── docker-compose.yml └── entrypoint.rb
entrypoint.rb の中身は通りです。
#!/usr/local/bin/ruby require 'webrick' # 模擬的な起動処理 starting_wait = ENV.fetch('STARTING_WAIT', 0).to_i STDOUT.puts "[#{Time.new.strftime('%F %T')}] ---- SimpleWebServer: #{ENV['SERVICE_NAME']} starting... (sleep #{starting_wait} sec)" STDOUT.flush sleep(starting_wait) STDOUT.puts "[#{Time.new.strftime('%F %T')}] ---- SimpleWebServer: #{ENV['SERVICE_NAME']} Started !" STDOUT.flush # 実際の起動処理 config = { BindAddress: '0.0.0.0', Port: 8080, MimeTypes: { 'erb' => 'text/html' } } srv = WEBrick::HTTPServer.new(config) srv.mount('/', WEBrick::HTTPServlet::ERBHandler, '/app/index.erb') srv.mount('/not_found', WEBrick::HTTPServlet::FileHandler, '/app/not_found') # 404 を返すパス trap(:TERM){ srv.shutdown } srv.start
このサーバは WEBrick を利用しています1が、WEBrick を起動する前に環境変数 STARTING_WAIT
で指定した秒数だけ sleep
します。
一般的なサーバアプリケーションは起動後、実際にアクセスできるようになるまでに数秒〜数分かかったりしますがそれを模擬しています。
パス /
にアクセスすると /app/index.erb をレスポンスします。
パス /not_found
にアクセスすると 404 を返します。後でヘルスチェック失敗を試すのに使います。
/app/index.erb は表示するページで次の通りです。
<!DOCTYPE html> <html lang="ja-JP"> <head> <meta charset="utf-8"> <title>Simple Ruby Web Server: <%= ENV['SERVICE_NAME']%></title> </head> <body> <h1>Simple Ruby Web Server: <%= ENV['SERVICE_NAME']%></h1> <p><b>Starting Wait:</b> <%= ENV['STARTING_WAIT']%></p> <p><b>Time:</b> <%= Time.new%></p> </body> </html>
表示するとこんな感じです。
docker-compose.yml では entrypoint
に先程の entrypoint.rb を指定します。
以下、 depends_on
の有無、 healthcheck
の有無による動作の違いを見ていきます。
depends_on
なし
次の様に3つのサービスを定義したdocker-compose.yml を試します。
サービス間に depends_on
は設定しません。
version: '2.4' services: web1: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-1 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8081:8080 entrypoint: /entrypoint.rb web2: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-2 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8082:8080 entrypoint: /entrypoint.rb web3: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-3 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8083:8080 entrypoint: /entrypoint.rb
起動時のログは次の通りです。
タイムスタンプを見ると3つのサービスが同時に起動しているのがわかると思います。
watch docker-compose up
で起動の様子を見たものが次の通りです。
3 つのコンテナが同時に起動していますね。
depends_on
のみあり
続いて、次の様に web1 <- web2 <- Web3
の様に depends_on
を指定した docker-compose.yml を試してみます。
version: '2.4' services: web1: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-1 STARTING_WAIT: 10 TZ: Asia/Tokyo # ログのタイムスタンプを JST にするため ports: - 8081:8080 entrypoint: /entrypoint.rb web2: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-2 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8082:8080 entrypoint: /entrypoint.rb depends_on: - web1 # <==== Web2 は Web1 に依存 web3: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-3 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8083:8080 entrypoint: /entrypoint.rb depends_on: - web2 # <==== Web3 は Web2 に依存
起動時のログは次の通りです。
タイムスタンプを見ても web1
、web2
、web3
の順で起動しているのがわかります。
順番は制御できますが、コンテナの起動を待つだけなので依存サービスが準備完了となるのは待っていません。
watch docker-compose up
の様子は次のとおりです。
ログの通りの順に起動しているのがわかります。
depends_on
あり、healthcheck
あり
更に healthcheck
を追加します。
docker-compose.yml は次の通りです。
version: '2.4' services: web1: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-1 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8081:8080 entrypoint: /entrypoint.rb healthcheck: # <==== ヘルスチェックを追加 test: "curl -f http://localhost:8080/ || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s web2: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-2 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8082:8080 entrypoint: /entrypoint.rb depends_on: web1: condition: service_healthy # <==== Web2 は Web1 が healthy になるのを待つ healthcheck: # <==== ヘルスチェックを追加 test: "curl -f http://localhost:8080/ || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s web3: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-3 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8083:8080 entrypoint: /entrypoint.rb depends_on: web2: condition: service_healthy # <==== Web3 は Web2 が healthy になるのを待つ healthcheck: # <==== ヘルスチェックを追加 test: "curl -f http://localhost:8080/ || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s
起動時のログは次の様になります。
WEBRick が起動してリクエストを受けられるようになり Ready になるまで後続のサービスの起動を待っているのがわかります。
watch docker-compose up
は次の通りです。
healthcheck
を設定すると STATUS にコンテナの状態に加えてヘルスチェックの状態も表示されます。
各サービスともコンテナはほぼ同時に作成されますが、その後は依存サービスが healthy
になるのを待って起動していく様子が観察できます。
ヘルスチェックに失敗したらどうなるのか?
web2 の healthcheck.test
に curl -f http://localhost:8080/not_found || exit 1
と設定してしてみます。
パス /not_found
は 404 を返すだけのパスなのでヘルスチェックが必ず unhealthy
と判定されます。
docker-compose.yml は次の様になります。
version: '2.4' services: web1: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-1 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8081:8080 entrypoint: /entrypoint.rb healthcheck: test: "curl -f http://localhost:8080/ || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s web2: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-2 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8082:8080 entrypoint: /entrypoint.rb depends_on: web1: condition: service_healthy healthcheck: test: "curl -f http://localhost:8080/not_found || exit 1" # 必ず失敗するヘルスチェック条件 interval: 10s timeout: 10s retries: 3 start_period: 30s web3: image: ruby:2.7.5 volumes: - ./entrypoint.rb:/entrypoint.rb - ./app:/app environment: SERVICE_NAME: WEB-3 STARTING_WAIT: 10 TZ: Asia/Tokyo ports: - 8083:8080 entrypoint: /entrypoint.rb depends_on: web2: condition: service_healthy healthcheck: test: "curl -f http://localhost:8080/ || exit 1" interval: 10s timeout: 10s retries: 3 start_period: 30s
起動時のログは次の通りです。
Web1 に依存している Web2 は起動してきますが、Web2 のヘルスチェックが通らないので Web3 は起動してきません。
watch docker-compose up
は次の通りです。
しばらくして web2 が unhealthy
となるのがわかります。
そして web2 に依存している web3 は created
のまま起動しません。
docker-compose の v2 と v3
このエントリで示した docker-compose.yml では version: '2.4'
としています。
というのもv3 の公式ドキュメントには depends_on
の condition
の記述がないからです。
しかし、version: '3.8'
でも同様に動作しました。
mem_limit
などは docker swarm mode 向けの設定として別の場所に移動され、docker-compose では無視されると記載されています2 (でも実際に mem_limit
設定すると機能しました)。しかし、 condition
に関しては廃止とは書かれていません3。
なので使っても大丈夫かなと思いました。
どうせ docker-compose の構成に DB などを入れて、起動待ち合わせしたい環境ってローカル開発環境やステージングだと思いますし(本番データを格納する DB はコンテナの稼働環境の外に用意しますよね?)。
本番環境で docker-compose を使っており、サービス間で起動の待ち合わせをしたい場合に心配なら v2 を利用するか、Control startup and shutdown order in Compose で紹介されているスクリプトを利用する方法で対応すればいいかと思います。
まとめ
depneds_on
と healthcheck
の組み合わせの動作を試してみました。
単純に depends_on
に依存サービスを指定するだけでは起動順が制御できるだけですが、
healthcheck
と組み合わせることで、アクセス可能状態になるまで待ち合わせができることを確認しました。
ちなみに停止するときには起動の逆の順番で止まります。
docker-compose.yml の v3 を利用する場合、今後もずっと使えるのか? と思うところもありますが、手軽にサービス間の待ち合わせが実現できるので便利だと思います。
最後に
アクトインディではエンジニアを募集しています。
-
ruby3 からWEBrick が Ruby の標準ライブラリとしてバンドルされなくなったので 2.7.5 を利用しています。↩
-
https://docs.docker.com/compose/compose-file/compose-versioning/#version-2x-to-3x↩
-
まあ、そもそも記述がないのは気にはなるんですが…。↩