アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

docker-compose の depends_on と healthcheck について

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_onhealthcheck について

depends_ondocker-compose.yml で定義されている service 間の依存関係を定義するものです。
例えば、次の様な定義すると、other_serviceone_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_oncondition プロパティを付けることもでき、次のいずれかの値を設定します。

  • service_started :依存サービスが開始するまで待つ
  • service_healthy :依存サービスのヘルスチェックがパスするまで待つ
  • service_completed_successfully :依存サービスの実行が成功で終了するまで待つ

service_startedcondition プロパティを指定しない場合と同じ動作です。
ここで注意すべきは「サービスが開始する」ことと「他のサービスからのアクセスを受け付けられる」ことは異なるということです。例えば、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 状態となります。
それを判定する testhealthy なら終了ステータス 0 を返し、 unhealthy の場合には終了ステータス 1 を返すコマンドまたはスクリプトを設定する必要があります。
ヘルスチェックはコンテナ起動直後から始まるようですが start_period の間は失敗しても無視します。
サービスの起動処理中は失敗しても止むを得ないということですね。start_period の時間内でもチェックに成功した場合はすぐ healthy となります。

実験

depends_onhealthcheck により各サービスの起動がどの様に制御されるのかを実験してみようと思います。 そのために極簡単な 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>

表示するとこんな感じです。

f:id:HeRo:20220220180114p:plain
app/index.erb の表示結果

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

起動時のログは次の通りです。

f:id:HeRo:20220220180211p:plain
`depends_on` を設定しない場合の起動ログ

タイムスタンプを見ると3つのサービスが同時に起動しているのがわかると思います。

watch docker-compose up で起動の様子を見たものが次の通りです。

f:id:HeRo:20220220180441g:plain
depends_on を設定しない場合の docker-compose ps の変化の様子

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 に依存

起動時のログは次の通りです。 タイムスタンプを見ても web1web2web3 の順で起動しているのがわかります。 順番は制御できますが、コンテナの起動を待つだけなので依存サービスが準備完了となるのは待っていません。

f:id:HeRo:20220220180929p:plain
depends_on を設定した場合の起動ログ

watch docker-compose up の様子は次のとおりです。

f:id:HeRo:20220220181913g:plain
depends_on を設定した場合の docker-compose ps の変化の様子

ログの通りの順に起動しているのがわかります。

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

起動時のログは次の様になります。

f:id:HeRo:20220220182523p:plain
depends_on と healthcheck を設定した場合の起動ログ

WEBRick が起動してリクエストを受けられるようになり Ready になるまで後続のサービスの起動を待っているのがわかります。

watch docker-compose up は次の通りです。

f:id:HeRo:20220220184121g:plain
depends_on と healthcheck を設定した場合の docker-compose ps の変化の様子

healthcheck を設定すると STATUS にコンテナの状態に加えてヘルスチェックの状態も表示されます。 各サービスともコンテナはほぼ同時に作成されますが、その後は依存サービスが healthy になるのを待って起動していく様子が観察できます。

ヘルスチェックに失敗したらどうなるのか?

web2 の healthcheck.testcurl -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

起動時のログは次の通りです。

f:id:HeRo:20220220183758p:plain
ヘルスチェックに失敗する場合の起動ログ

Web1 に依存している Web2 は起動してきますが、Web2 のヘルスチェックが通らないので Web3 は起動してきません。

watch docker-compose up は次の通りです。

f:id:HeRo:20220220185716g:plain
healthcheck が失敗する場合の docker-compose ps の変化の様子
(web2 が unhealthyになるまで30秒程度かかります)

しばらくして web2 が unhealthy となるのがわかります。 そして web2 に依存している web3 は created のまま起動しません。

docker-compose の v2 と v3

このエントリで示した docker-compose.yml では version: '2.4' としています。
というのもv3 の公式ドキュメントには depends_oncondition の記述がないからです。 しかし、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_onhealthcheck の組み合わせの動作を試してみました。
単純に depends_on に依存サービスを指定するだけでは起動順が制御できるだけですが、 healthcheck と組み合わせることで、アクセス可能状態になるまで待ち合わせができることを確認しました。
ちなみに停止するときには起動の逆の順番で止まります。

docker-compose.yml の v3 を利用する場合、今後もずっと使えるのか? と思うところもありますが、手軽にサービス間の待ち合わせが実現できるので便利だと思います。

最後に

アクトインディではエンジニアを募集しています。

actindi.net


  1. ruby3 からWEBrick が Ruby の標準ライブラリとしてバンドルされなくなったので 2.7.5 を利用しています。

  2. https://docs.docker.com/compose/compose-file/compose-versioning/#version-2x-to-3x

  3. まあ、そもそも記述がないのは気にはなるんですが…。