🦁

Docker の healthcheck を初めて使った話

2023/06/14に公開

結論の先取り

  • Dockerには Healthcheck 機能があり、Dockerfileやcompose.yamlで指定できる。
  • Docker Composeでは、depends_onconditionを使って、依存関係にあるコンテナのヘルスチェックを元に、コンテナの実行タイミングを制御できる。

compose.yaml (一部)

services:
  app:
    depends_on:
      mysql:
        condition: service_healthy   # here
    build: .
    volumes:
      - .:/opt/app/
    ports:
      - 80:3000

  mysql:
    image: mysql:8.0.32-oracle
    env_file:
      - ./.env
    ports:
      - 3306:3306
    healthcheck:   # here
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$MYSQL_ROOT_PASSWORD"]
      interval: 30s
      timeout: 5s
      retries: 6
      start_period: 30s

volumes:
  mysql-store:

挨拶

Myosotis です。日頃はRubyやPython、Dockerとライトに戯れたり、友人にPullRequestのレビューを早くしてほしいと言われながら生活しています。

課題背景

Ruby on Rails とそのデータベースとしてのMySQLを使った開発環境を構築する過程で、初めてDockerのヘルスチェック機能に触りました。日頃からDocker(や Docker Compose)と戯れている私にとっては全体としては難しくなく、更にヘルスチェックが必須だったかと言われると疑問ですが、ヘルスチェック機能のノウハウについて勉強がてらまとめようと思います。

注意

  • ブログを書くためにエラーになることを再現しようとしたところ、再現しなくなりました。スクリーンショットなどの記録も無いことが悔やまれます。この記事はかなり大部分が記憶を頼りに書かれています。(スクショ大切...)
  • 開発環境, テスト環境向けのcompose.yamlについて説明しています。また、ここでの"開発環境", "テスト環境"はRailsの意味する開発環境,テスト環境の意味です。
  • この記事では、MySQLのパスワードを含むクレデンシャルな情報を環境変数に格納し、コマンドで利用していますが、本番環境では別途秘匿化することが必要になります。

検証環境

  • macOS (Apple Silicon)
  • Docker Desktop 4.17.0 (99724)
  • Docker version 20.10.23, build 7155243
  • Docker Compose version v2.15.1

補足: (2023/04/18 時点)

  • 以上の検証環境のため、Composeファイル形式は最新の The Compose Specification に基づきます。
  • 可能な限り The Compose Specification に準ずるドキュメントを参照し構成していますが、抜けがあるかもしれないです。もしなにかあればご指摘いただけるとありがたいです。

課題の整理

要点

  • RailsのコンテナとMySQLのコンテナがあり、RailsはデータベースとしてMySQLを利用する。
  • MySQLのプロセスが(クエリを受け結果を返すという意味で)正常に動作するまで待たずに、Railsがデータベースへの接続(やクエリの実行)を試み、失敗する。
  • MySQLの次にRailsコンテナが起動するように、compose.yamlでRailsコンテナにdepends_onでMySQLを指定している。

詳細

よくあるDBとフルスタックなアプリケーションサーバの2コンテナ構成です。

compose.yamlのRailsコンテナの項目には、depends_onでMySQLコンテナを指定していましたが、データベースの接続がコケることがありました。(注: 必ずコケるわけでもなかった)

compose.yaml (一部抜粋)

services:
  app:
    depends_on:
      - mysql   # <- MySQL を指定, 起動順序が担保される
    build:
      context: .
      dockerfile: ./Dockerfile
    image: "rails_app:ruby2-rails6-dev-1.0"
    volumes:
      - .:/opt/app/
    ports:
      - 80:3000

  mysql:
    image: mysql:8.0.32-oracle
    env_file:
      - ./.env
    ports:
      - 3306:3306

こんな感じで、connectionに問題がある旨が表示される。(名前解決できてないわけじゃなさそう)

screenshot_connection_error

以下 標準出力のエラー

...(省略)

Can't connect to MySQL server on 'mysql' (115)
Couldn't create 'toyapp_development' database. Please check your configuration.
rails aborted!
Mysql2::Error::ConnectionError: Can't connect to MySQL server on 'mysql' (115)

...(省略)

参考 : hostが識別できないができないとこうなる

Unknown MySQL server host 'mysql' (-2)
Couldn't create 'toyapp_development' database. Please check your configuration.
rails aborted!
Mysql2::Error::ConnectionError: Unknown MySQL server host 'mysql' (-2)

depends_on の挙動の調査

一つ心当たりがあったこととして、depends_onはコンテナの開始順番を管理するだけでサービス開始を待つわけではない... と記憶していました。のでDockerのドキュメントを見に行くことに。

あった : Docker-docs-ja

depends_on 使用時に注意すべき点 :

  • depends_on では、 web を開始する前に dbredis の「準備」が整うのを待ちません。単に、順番通り開始するだけです。サービスの準備が調うまで待つ必要がある場合、この問題を解決する方法は 開始順番の制御 をご覧ください。
  • (省略)

やはり開始順、停止順しか保証されない模様。ただ、解決策も 開始順番の制御 に書いてありそうですね。公式ドキュメントありがたい。

解決策の検討

2つの解決策を検討し、デバッグの容易さや追加のコンポーネントが不要であることから、2番目を選択しました。

解決策1

上に示した、公式ドキュメントの 開始順番の制御 にある方法です。

具体的には wait-for-itdockerize を導入し、サービスコマンドをラップしすることでネットワーク越しのサービスが利用可能になるのを待つといった方法です。

解決策2

compose.yamlにヘルスチェック結果を元にコンテナを開始する旨の条件を定義する方法です。本件ではこちらを採用します。

具体的には、

  1. 依存される側のコンテナのDockerfileか、compose.yamlの依存される側のサービスにヘルスチェックを定義する
  2. compose.yamlの依存する側のコンテナのdepends_onconditionを追記する

の2段階で、次項で実施することとします。

解決策の実施

1. 依存される側にヘルスチェックを定義する

Dockerには Healthcheck 機能がありDockerfileで定義できる他、compose.yamlhealthcheck を定義できるようです。

本件ではcompose.yamlのMySQLのserviceにhealthcheckを定義しました(DockerHub公式のMySQLイメージを利用し、Dockerfileを作成していないため)。

compose.yaml (抜粋)

  mysql:
    image: mysql:8.0.32-oracle
    env_file:
      - ./.env
    ports:
      - 3306:3306
    healthcheck:   # add healthcheck
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$MYSQL_ROOT_PASSWORD"]
      interval: 30s
      timeout: 5s
      retries: 6
      start_period: 10s

healthchecktestの項目では、"CMD"から始まる形で語句を列挙することでコンテナ内のシェルに対してコマンドを発行できます。本件ではMySQLのサービスが利用できることを確認するために、mysqladminpingコマンド(リファレンス)を使うことにしました。

intervaltimeoutなど他のオプションも設定可能のようです。実際に計測してデータをもとに値を設定するべきなのだろうと思いますが、勉強のために一旦ざっくり設定しました。記事の目的から逸れそうなので説明は保留します。詳しくは(リファレンス)を参照してください。

ヘルスチェックのステータスについては以下のとおりです(リファレンスより意訳)

ヘルスチェックが定義されている場合、通常のステータスに加えて、ヘルスステータスを持ち、最初はstartingで、ヘルスチェックが成功するたびにhealthyになり、retriesオプションの回数分(default: 3)連続して失敗するとunhealthyになる。

また、上に挙げたテストの有効性について疑問点が残っている旨のレビューを頂いたので、Appendix として記事の最後に追記したいと思います。

2. 依存する側のサービスのdepends_onconditionを追記する

compose.yamldepends_on以下にconditionを定義することで、依存される側のコンテナのヘルスチェック結果を元に依存するコンテナの開始タイミングを制御できます。

本件ではcompose.yamlのRailsアプリのserviceのdepends_on以下を修正しました。

compose.yaml (抜粋)

services:
  app:
    tty: true
    depends_on:
      mysql:   # fix
        condition: service_healthy   # add
    build:
      context: .
      dockerfile: ./Dockerfile
    env_file:
      - ./.env
    ports:
      - 80:3000

depends_on配下で利用するconditionにはservice_healthyservice_startedの2種類が指定でき、service_startedはサービスが起動完了するまで待ち(depends_onの従来の挙動)、service_healthyはヘルスチェックが成功するまでコンテナの起動を待つ(リファレンス)ようです。

本件ではヘルスチェックの結果が成功するまで待ちたいので、service_healthyを選択しました。

結果/結論

ヘルスチェック結果がhealthyになるまでRailsのサービスが待機することが観察できました。何度か実行してもRailsがデータベースへの接続に失敗することはなかったです。

Rails container is waiting MySQL container.
Health of MySQL container is starting.

最終的なcompose.yamlは以下の通りです。

services:
  app:
    tty: true
    depends_on:
      mysql:
        condition: service_healthy   # here
    build:
      context: .
      dockerfile: ./Dockerfile
    container_name: rails
    env_file:
      - ./.env
    volumes:
      - .:/opt/app/
    ports:
      - 80:3000

  mysql:
    image: mysql:8.0.32-oracle
    container_name: mysql
    env_file:
      - ./.env
    ports:
      - 3306:3306
    volumes:
      - mysql-store:/var/lib/mysql
    healthcheck:   # here
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$MYSQL_ROOT_PASSWORD"]
      interval: 30s
      timeout: 5s
      retries: 6
      start_period: 30s

volumes:
  mysql-store:

Appendix

先に挙げたhealthcheckのコマンドが、テストとして十分かどうかの検討をします。

上のコードブロックにて挙げたhealthcheckコマンドについて、例えば存在しないユーザー名を使って検証した場合もヘルスチェックが成功してしまう問題があるのでは無いかとレビューを頂きました。レビューしていただきありがとうございます。

問題の整理

この問題の起こる原因としてはとしては、以下の2点が挙げられます。

  • healthcheckではコマンドのexit-statusが0であるかどうか確認している
  • mysqladmin ping コマンドではアクセスが拒否されてもexit-statusが0になる(コマンド自体が正常終了扱いになる)

私の考える解の概要

この問題について、わたしは2つの解があると考えています。

  1. MySQLサーバに接続した結果、認証が弾かれている点で、MySQLサーバが接続に対して応答を返すかどうかのチェックになっていて大きな問題ではない。
  2. 厳密に mysqladmin ping コマンドの応答文字列の"mysqld is alive"の文字列を監視する必要がある。

1 の解であれば、デタラメなユーザー名とデタラメなパスワードの組でもアクセスが拒否された旨の応答が返ってくれば、応答が返ってきたという点のチェックになるので、今まで挙げてきたhealthcheckでもおおよそ問題がないと考えています。(むしろクレデンシャルな情報を扱いたくないのでデタラメな文字列をハードコードしても良いかもしれないです。)

2 の解について何ができるかを以下で考えようと思います。

mysqladmin ping コマンドの仕様の確認

まず mysqladmin ping コマンドの仕様を一旦明確にします。以下に画像を示します。

正しく認証情報を与えて、かつMySQLのサービスが使用可能な状態であれば "mysqld is alive" の文字列が返ってきます。念のため、exit-status を確認すると 0 であることが確認できます。

しかし先にも述べた通り、例えば存在しないユーザー名 "no-one" を与えたろころ、認証には失敗しましたが、exit-statusが 0 (正常終了を意味する)であることが確認できます。この仕様については mysqladmin ping / MySQL 8.0 リファレンス でも触れられているのでご確認ください。

mysqladmin_ping

※ この記事では先に述べたようにクレデンシャルな情報の扱いについては保留します。よって画像のWarningも議論に挙げません。

tips : exit-status は echo $? で確認できます。

参考 : Extracting the elusive exit code : Bash command line exit codes demystified / Red Hat

解の検討, 詳細

exit-statusが 0 ではhealthcheckにそのまま使えないので、例えば grep コマンドにパイプ | で結果を渡して "mysqld is alive" の文字列がマッチするかどうかを検証する方法を考えてみようと思います。 grep コマンドは何もマッチしなかったとき exit-status を 1 として終了するようです。

mysqladmin_grep

このコマンドをhealthcheckに与えることで、より厳格にMySQLのサービスが使用可能であるかどうかを検証することができるようになると考えています。

  mysql:
    image: mysql:8.0.32-oracle
    env_file:
      - ./.env
    ports:
      - 3306:3306
    healthcheck:   # change healthcheck
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$MYSQL_ROOT_PASSWORD | grep 'mysqld is alive'"]
      interval: 30s
      timeout: 5s
      retries: 6
      start_period: 10s
Sun* Developers

Discussion