MySQLの正しいhealthcheck

こんにちは、 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 localhost specially, 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_oncondition: 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 の明示が必要です。

おわり


参考: