こんにちは、 Legalscape (採用情報) で法情報のグラフデータを構築・運用するチームに所属するあざらし🦭です。
Docker ComposeでMySQLコンテナのhealthcheckを書くとき、mysqladmin ping を使うのが定番だと思います。ただこの方法には落とし穴があり、healthcheckが通っているのにTCPで接続すると Connection refused になるケースがあります。
本記事では、なぜそうなるのかと、正しいhealthcheckの書き方を紹介します。
よくあるhealthcheck
ネット上でよく見かけるのはこのような定義です。
services: mysql: image: mysql:8 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-P", "3306"]
この書き方だと、healthcheckが通った直後にホストや別コンテナからTCPで接続しようとすると Connection refused になることがあります。-h localhost はUNIXドメインソケットでの疎通しか確認しないため、TCPポートの準備が間に合っていないタイミングが生じるためです。
あるいは -h 127.0.0.1 としている場合です(こちらの方が多いかもしれません)。
services: mysql: image: mysql:8 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-P", "3306"]
こちらは正しくTCPで確認しているように見えますが、MySQLクライアントのフォールバック挙動により、実際にはUNIXドメインソケット経由で成功してしまう場合があります。結果として同じく Connection refused に遭遇します。
ついでにCIワークフローだと、whileループを回すパターンもあるかと思います。
- name: Wait for mysql run: | docker compose exec -T mysql sh -c \ 'while ! mysqladmin ping -h 127.0.0.1 -P 3306 -u root -proot_password --silent; do sleep 1; done'
一つ上の例と実質的に同じなので同じ問題を抱えています。
mysqladmin ping が見ているもの
MySQLクライアントには、--host の値によって接続プロトコルを切り替える仕様があります。
--host の値 |
Unix系OSでの接続方法 |
|---|---|
localhost |
UNIXドメインソケット |
127.0.0.1 |
TCP/IP |
公式ドキュメントにはこう書いてあります。
On Unix, MySQL programs treat the host name
localhostspecially, in a way that is likely different from what you expect compared to other network-based programs: the client connects using a Unix socket file.
つまり -h localhost のhealthcheckはUNIXドメインソケット経由での疎通しか見ていません。ホストや別コンテナからTCPで接続する構成では、healthcheckが通った時点でTCPポートの準備が完了している保証がないことになります。
では -h 127.0.0.1 を指定すれば解決するかというと、そうとも限りません。
-h 127.0.0.1 でも安心できない場合
-h 127.0.0.1 を指定すればTCPで接続するはずですが、MySQLクライアントにはTCPでの接続に失敗したとき、UNIXドメインソケットが利用可能であればそちらにフォールバックする挙動があります。
この挙動は MySQL Bug #58811 で報告されています。「--port と --host を明示しているのにソケット経由で繋がるのはおかしい」という内容ですが、MySQLチームの回答は Not a Bug(仕様通り)でした。
フォールバックが起きるのはTCP接続が失敗したときです。たとえば ports: 3307:3306 のようにホスト側を非標準ポートにマッピングしている構成で、healthcheckに -h 127.0.0.1 -P 3307 と書いた場合、コンテナ内のMySQLは3306でlistenしているので3307へのTCP接続は常に失敗し、常にソケットにフォールバックします。この場合、healthcheckはTCPの状態を一切見ていないのと同じです。
ポートが正しい場合(-P 3306)でも問題は起き得ます。mysqldの起動過程では、UNIXソケットのlistenがTCPポートのlistenよりも先に始まります。この短い時間帯にhealthcheckが走ると、TCPへの接続が失敗してソケットにフォールバックし、pingは成功を返します。結果として以下のようなタイムラインが生じ得ます。
T+0 mysqld 起動開始 T+1 UNIXソケットがlistenを開始 → mysqladmin ping 成功(ソケット経由) T+2 TCPポートはまだ準備中 → ホストからのTCP接続は Connection refused T+3 TCPポート(3306)がlisten開始
healthcheckはT+1で通りますが、ホスト側からTCPで繋ぎにいくタイミングがT+2に当たると失敗します。CIで「たまに落ちる」テストの原因がこれだったりします。
正しいhealthcheck
--protocol=TCP を明示します。
services: mysql: image: mysql:8 healthcheck: test: [ "CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--protocol=TCP", ] # 以下は適宜調整 # 起動するまでの30sは1s間隔で確認する start_period: 30s start_interval: 1s # 起動後は30s間隔で最大5回リトライする interval: 30s timeout: 1s retries: 5
--protocol=TCP を付けるとUNIXソケットへのフォールバックが発生しなくなるので、TCPで実際に繋がる状態になるまでhealthcheckは通りません。
CIのワークフロー側では、whileループの代わりに Docker Compose v2 の --wait フラグが使えます。
# before - name: Wait for mysql run: | docker compose exec -T mysql sh -c \ 'while ! mysqladmin ping -h 127.0.0.1 -P 3306 --silent; do sleep 1; done' # after - name: Wait for mysql run: docker compose up -d --wait mysql
--wait はコンテナのhealthcheckが通るまでコマンドが返ってこないので、docker-compose.yml側で正しいhealthcheckを定義しておけばCI側にポーリングのロジックを書く必要がなくなります。healthcheckの定義が一箇所に集約されるので、ローカル開発とCIで同じ条件になるのもうれしいところです。
depends_on の condition: service_healthy と組み合わせれば、アプリケーションコンテナの起動順制御にも使えます。
services: app: depends_on: mysql: condition: service_healthy
まとめ
| healthcheckの書き方 | TCPの準備を待てるか |
|---|---|
mysqladmin ping -h localhost -P 3306 |
いいえ(UNIXソケットのみ、-P は無視される) |
mysqladmin ping -h 127.0.0.1 -P 3306 |
条件による(フォールバックの可能性あり) |
mysqladmin ping -h 127.0.0.1 -P 3306 --protocol=TCP |
はい |
mysqladmin ping を使ったhealthcheckは広く使われていますが、TCPの準備完了を正しく待つには --protocol=TCP の明示が必要です。
おわり
参考: