Docker の healthcheck を初めて使った話
結論の先取り
- Dockerには Healthcheck 機能があり、Dockerfileやcompose.yamlで指定できる。
- Docker Composeでは、
depends_on
とcondition
を使って、依存関係にあるコンテナのヘルスチェックを元に、コンテナの実行タイミングを制御できる。
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に問題がある旨が表示される。(名前解決できてないわけじゃなさそう)
以下 標準出力のエラー
...(省略)
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のドキュメントを見に行くことに。
depends_on
使用時に注意すべき点 :
depends_on
では、web
を開始する前にdb
とredis
の「準備」が整うのを待ちません。単に、順番通り開始するだけです。サービスの準備が調うまで待つ必要がある場合、この問題を解決する方法は 開始順番の制御 をご覧ください。- (省略)
やはり開始順、停止順しか保証されない模様。ただ、解決策も 開始順番の制御 に書いてありそうですね。公式ドキュメントありがたい。
解決策の検討
2つの解決策を検討し、デバッグの容易さや追加のコンポーネントが不要であることから、2番目を選択しました。
解決策1
上に示した、公式ドキュメントの 開始順番の制御 にある方法です。
具体的には wait-for-it や dockerize を導入し、サービスコマンドをラップしすることでネットワーク越しのサービスが利用可能になるのを待つといった方法です。
解決策2
compose.yaml
にヘルスチェック結果を元にコンテナを開始する旨の条件を定義する方法です。本件ではこちらを採用します。
具体的には、
- 依存される側のコンテナの
Dockerfile
か、compose.yaml
の依存される側のサービスにヘルスチェックを定義する -
compose.yaml
の依存する側のコンテナのdepends_on
にcondition
を追記する
の2段階で、次項で実施することとします。
解決策の実施
1. 依存される側にヘルスチェックを定義する
Dockerには Healthcheck 機能がありDockerfile
で定義できる他、compose.yaml
も healthcheck を定義できるようです。
本件では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
healthcheck
のtest
の項目では、"CMD"
から始まる形で語句を列挙することでコンテナ内のシェルに対してコマンドを発行できます。本件ではMySQLのサービスが利用できることを確認するために、mysqladmin
のping
コマンド(リファレンス)を使うことにしました。
interval
やtimeout
など他のオプションも設定可能のようです。実際に計測してデータをもとに値を設定するべきなのだろうと思いますが、勉強のために一旦ざっくり設定しました。記事の目的から逸れそうなので説明は保留します。詳しくは(リファレンス)を参照してください。
ヘルスチェックのステータスについては以下のとおりです(リファレンスより意訳)
ヘルスチェックが定義されている場合、通常のステータスに加えて、ヘルスステータスを持ち、最初は
starting
で、ヘルスチェックが成功するたびにhealthy
になり、retries
オプションの回数分(default: 3)連続して失敗するとunhealthy
になる。
また、上に挙げたテストの有効性について疑問点が残っている旨のレビューを頂いたので、Appendix として記事の最後に追記したいと思います。
depends_on
にcondition
を追記する
2. 依存する側のサービスのcompose.yaml
のdepends_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_healthy
とservice_started
の2種類が指定でき、service_started
はサービスが起動完了するまで待ち(depends_on
の従来の挙動)、service_healthy
はヘルスチェックが成功するまでコンテナの起動を待つ(リファレンス)ようです。
本件ではヘルスチェックの結果が成功するまで待ちたいので、service_healthy
を選択しました。
結果/結論
ヘルスチェック結果がhealthyになるまでRailsのサービスが待機することが観察できました。何度か実行してもRailsがデータベースへの接続に失敗することはなかったです。
最終的な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つの解があると考えています。
- MySQLサーバに接続した結果、認証が弾かれている点で、MySQLサーバが接続に対して応答を返すかどうかのチェックになっていて大きな問題ではない。
- 厳密に
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 リファレンス でも触れられているのでご確認ください。
※ この記事では先に述べたようにクレデンシャルな情報の扱いについては保留します。よって画像の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
として終了するようです。
このコマンドを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
Discussion