ポートフォワードされたポートに対して Docker ホストから dockerize などしてもコンテナのプロセスのリッスンは待てない

タイトルが長い・・dockerize などのツールは普通は Docker ホスト上で実行するものではないですがこの記事ではあえてそうしています。

docker-compose などで複数のコンテナを実行するとき、あるコンテナがポートをリッスンするまで待つために [wait-for-it](https://github.com/vishnubob/wait-for-it) とか [dockerize](https://github.com/jwilder/dockerize) とか（あるいは簡易なシェルスクリプトとか）で待ちますが、これは Docker ホスト上でポートフォワードされたポートに対しては機能しません。

試した環境は下記のとおり。

- CentOS 7.8.2003
- Docker 19.03.8

例えば次のように MySQL のコンテナを実行したとき、

```sh
docker run -d --rm --name mysql -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
```

Docker ホスト上で dockerize を使っても MySQL のリッスンを待つことはできません。

```sh
./dockerize -wait tcp://localhost:3306
#=> 2020/07/08 22:28:53 Waiting for: tcp://localhost:3306
#=> 2020/07/08 22:28:53 Connected to tcp://localhost:3306
```

MySQL がどういう状態でも、コンテナが開始されていれば、これは即座に応答を返します。

## 理由

Docker ホスト上から localhost の転送されたポートへアクセスしたとき、ホスト上では実際には docker-proxy というプロセスがリッスンしていて、トラフィックをコンテナのポートへ転送します。そのため TCP セッションは「クライアント ～ docker-proxy」と「docker-proxy ～ コンテナ」の2段階です。

クライアントは docker-proxy とのハンドシェイクが終わった時点で ESTABLISH になりますが、docker-proxy の方は accept も何もしていなかったとしてもカーネルだけでクライアントとのハンドシェイクを終わらせて backlog に入れて ESTABLISH になります。この時点ではまだ docker-proxy からコンテナへの接続は始まってもいません。ので、つまりコンテナのプロセスがリッスンしててもして無くても関係なく、クライアント側は ESTABLISH になります。

極端な話、コンテナでリッスンしないポートでも転送さえしておけば ESTABLISH になります。

```sh
docker run -d --rm --name mysql -p 9999:9999 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
./dockerize -wait tcp://localhost:9999
#=> 2020/07/08 22:29:07 Waiting for: tcp://localhost:9999
#=> 2020/07/08 22:29:07 Connected to tcp://localhost:9999
```

この動作は Docker 特有のものではなく、例えば OpenSSH のポートフォワードとかでも同じです。ユーザーランドで TCP をポートフォワードしようとするとどうしてもそうなります。

## localhost 以外を指定

↑は localhost のとき、つまりループバックアドレスのときだけです。ホストの別のアドレスを指定すればコンテナのリッスンを待つことができます。

```sh
docker run -d --rm --name mysql -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
./dockerize -wait tcp://192.0.2.123:3306 -timeout 60s
#=> 2020/07/08 22:29:29 Waiting for: tcp://192.0.2.123:3306
#=> 2020/07/08 22:29:29 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:30 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:31 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:32 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:33 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:34 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:35 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:36 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:37 Problem with dial: dial tcp 192.0.2.123:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:29:38 Connected to tcp://192.0.2.123:3306
```

なぜなら docker-proxy が使用されるのは上記のようなホストのループバックアドレスを指定したときや、dockerd で --ip-forward や --iptables で false が指定されているときだけだからです。下記の記事が詳しく解説されておりわかりやすいです（英語）。

- [The docker\-proxy](https://windsock.io/the-docker-proxy/)

それ以外では iptables/netfilter の PREROUTING でコンテナのポートへ直接 DNAT されるため、クライアントとコンテナが直接 TCP で接続します。のでコンテナがリッスンしていなければクライアントからの TCP ハンドシェイクの時点で RST されます。

## dockerd --userland-proxy=false

dockerd を `--userland-proxy=false` で実行すれば docker-proxy が開始されず、ループバックアドレスでも DNAT されるようになるため、ホスト上でループバックアドレスを指定してもコンテナのリッスンを待つことができます。

```sh
sudo vim /etc/sysconfig/docker
# OPTIONS="--userland-proxy=false"

sudo systemctl restart docker

docker run -d --rm --name mysql -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
./dockerize -wait tcp://localhost:3306 -timeout 60s
#=> 2020/07/08 22:28:08 Waiting for: tcp://localhost:3306
#=> 2020/07/08 22:28:08 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:09 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:10 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:11 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:12 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:13 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:14 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:15 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:16 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:17 Problem with dial: dial tcp 127.0.0.1:3306: connect: connection refused. Sleeping 1s
#=> 2020/07/08 22:28:18 Connected to tcp://localhost:3306
```

通常であればループバックアドレスに対しては PREROUTING は適用されませんが、sysctl で `net.ipv4.conf.$DEVICE.route_localnet = 1` にすれば出来るようになります。これはデフォだと `0` ですが `--userland-proxy=false` を指定して dockerd を実行すると docker0 のような仮想ブリッジデバイスでは `1` に設定されます。

## さいごに

元々 OpenSSH のポートフォワードで存在しない宛先へ転送していても Connected になることを知っていたで Docker のポートフォワードでもそういうものだろうと思っていたのですが、何かの拍子に iptables の設定を見てみたら DNAT されていたので、あれーじゃあホストからでもコンテナプロセスのリッスンを待機できるの？ と思ったらやっぱりできなくて、どういうことかと思ったらこういうことでした。

本題とは関係ありませんが docker-compose で複数のコンテナを実行するときにアプリのコンテナで MySQL のコンテナを待つだけであれば dockerize をアプリのコンテナで実行するだけで十分です。同じネットワークのコンテナ同士が通信する分には docker-proxy は関係ありません。

余談ですが MySQL に限れば `mysqladmin ping --wait` で良いかも。アプリのコンテナから実行しようとすると mysql クライアントを入れる必要がありますけど。

```sh
docker run -d --rm --name mysql -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
docker exec mysql mysqladmin ping -h 127.0.0.1 --wait=10
```

MySQL のコンテナ自身でやるときは `-h 127.0.0.1` を指定しないとUnixドメインソケットに接続してしまうので注意。Docker オフィシャルのイメージだと MySQL は最初に skip-networking で起動し（TCP無効でUnixドメインソケットのみで接続できる）、初期化が完了したあとに networking を有効にして再起動します。`-h 127.0.0.1` が指定されていないと最初の初期化のために起動したタイミングで応答を返してしまいます。確実に初期化が完了して別のコンテナからの接続を受け入れる状態になっていることを確認するためには `-h 127.0.0.1` が必要です。