Dockerコンテナ内からホストのlocalhostにアクセスする
この記事ではホスト側でlocalhostに立てたサーバーにDockerコンテナ内からアクセスする方法を紹介します。--add-host host.docker.internal:host-gatewayを指定するのはもちろん、Rootless Docker使用時に発生するFailed to connect to host.docker.internal port xxxx after 0 ms: Connection refusedのエラーに対処する方法も紹介します。
--add-hostオプションでコンテナ内からアクセス可能なホストを追加する
--add-hostオプションを使うとコンテナ内からアクセス可能なホストを追加できます。例えば以下のようなコマンドでUbuntuのコンテナを立ち上げます。
docker container run --rm --name ubuntu -it --add-host host.docker.internal:host-gateway ubuntu bash
この状態でコンテナ内で/etc/hostsを確認すると、しっかりと名前解決ができていることが確認できます。
root@xxxx:/# cat /etc/hosts
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.1 host.docker.internal
172.17.0.1がコンテナ内から見たホストのIPアドレスで、それにhost.docker.internalが割り当てられています。
このため、以下のようにコンテナ内でpingをすればアクセスすることが確認できます。
root@xxxx:/# apt update && apt install -y iputils-ping
root@xxxx:/# ping host.docker.internal
PING host.docker.internal (172.17.0.1) 56(84) bytes of data.
64 bytes from host.docker.internal (172.17.0.1): icmp_seq=1 ttl=64 time=0.037 ms
64 bytes from host.docker.internal (172.17.0.1): icmp_seq=2 ttl=64 time=0.040 ms
64 bytes from host.docker.internal (172.17.0.1): icmp_seq=3 ttl=64 time=0.040 ms
せっかくなので、ホスト側でAPIを立てて、コンテナからアクセスしてみます。最近、流行りのBun経由でElysiaを使っていきます。何かしらのサーバーが立っていて、何かしらのレスポンスさえ返ってくればいいので、ExpressでもNext.jsのAPIでも何でもいいです。
bun create elysia hi-elysia
cd hi-elysia/
bun dev
これで、localhost:3000にサーバーが立ち上がります。ホスト側からアクセスできるか確認してみましょう。
curl http://localhost:3000
Hello Elysia
ホスト側でアクセスが確認できたら、今度はDockerコンテナ内からホスト上のエンドポイントにアクセスできるか確認してみましょう。
root@640beec078ff:/# apt install curl
root@640beec078ff:/# curl http://host.docker.internal:3000
curl: (7) Failed to connect to host.docker.internal port 3000 after 0 ms: Connection refused
curlでのアクセスが失敗してしまいました。次の節でこのエラーの原因と対処方法を紹介します。
Failed to connect to host.docker.internal port xxxx after 0 ms: Connection refused
RootlessモードのDockerを使用していると上のようなエラーが出て、アクセスを拒否されてしまいます。
エラーの原因
RootlessモードのDockerはslirp4netnsというプログラムを使ってsudoを使わなくてもコンテナがネットワークに接続できるようにしています。ただし、ホスト側の全てのネットワークポートにアクセスできてしまうのは問題であるため、デフォルトではhost.docker.internalにアクセスできないようになっています。
エラーの対処方法
いくつか対処方法は考えられます
- Dockerコンテナと同じネットワーク内でサーバーを立てる
- UNIXソケットを使ってTCP通信を中継する
- Rootless Dockerでの
--disable-host-loopbackの指定を解除する - ngrokなどを使ってグローバルに公開する
Dockerコンテナと同じネットワーク内でサーバーを立てる
「Dockerコンテナ内からホストのlocalhostにアクセスする」という本記事のタイトルとは矛盾してしまいますが、おそらく、これが一番、素直な方法だと思います。Dockerコンテナと同じネットワーク内でサーバーを立てれば、host.docker.internalを使わずにアクセスできます。
例えば、Elysiaを使った例なら以下のようなdocker-compose.ymlを書けばコンテナ同士で通信できます。
version: '3.9'
services:
some-service:
image: curlimages/curl:8.4.0
command: 'curl http://api:3000'
depends_on:
- api
api:
image: 'oven/bun'
# override default entrypoint allows us to do `bun install` before serving
entrypoint: []
# execute bun install before we start the dev server in watch mode
command: "/bin/sh -c 'bun install && bun run --watch src/index.ts'"
# expose the right ports
ports: ['3000:3000']
# setup a host mounted volume to sync changes to the container
volumes: ['./:/home/bun/app']
docker composeは自動でネットワークを構築してくれるのでhttp://<service name>:<port>でアクセスできます。
docker compose up
✔ Container hi-elysia-api-1 Created 0.0s
✔ Container hi-elysia-some-service-1 Created 0.0s
Attaching to hi-elysia-api-1, hi-elysia-some-service-1
hi-elysia-api-1 | bun install v1.0.13 (f5bf67bd)
hi-elysia-api-1 |
hi-elysia-api-1 | Checked 9 installs across 10 packages (no changes) [6.00ms]
hi-elysia-api-1 | 🦊 Elysia is running at localhost:3000
hi-elysia-some-service-1 | % Total % Received % Xferd Average Speed Time Time Time Current
hi-elysia-some-service-1 | Dload Upload Total Spent Left Speed
100 12 100 12 0 0 7389 0 --:--:-- --:--:-- --:--:-- 12000
hi-elysia-some-service-1 | Hello Elysia
hi-elysia-some-service-1 exited with code 0
curlを実行しているサービスに関しては適宜、APIを叩こうとしているサービスに置き換えて構築してください。
UNIXソケットを使ってTCP通信を中継する
Docker Composeを構築するほど大げさな解決策を必要としていない場合、UNIXソケットを使ってTCPの通信を中継する方法が使えます。
StackOverflowの以下の投稿を参考にしました。
まず以下のようなシェルスクリプト(docker-port-forward.sh)を作成します。
#!/bin/bash
# https://stackoverflow.com/a/74979409
set -e
PORTS=($(echo "$1" | grep -oP '^\d+(:\d+)?$' | sed -e 's/:/ /g'))
if [ -z $PORTS ]; then
cat <<EOF
Usage:
$(basename "$0") SRC[:DEST]
SRC: will be the port accessible inside the container
DEST:
the connection will be redirected to this port on the host.
if not specified, the same port as SRC will be used
EOF
exit 1
fi
SOURCE=${PORTS[0]}
DEST=${PORTS[1]-${PORTS[0]}}
SOCKFILE="$XDG_RUNTIME_DIR/forward-docker2host-${SOURCE}_$DEST.sock"
socat UNIX-LISTEN:"$SOCKFILE",fork TCP:127.0.0.1:$DEST &
nsenter -U --preserve-credentials -n -t $(cat "$XDG_RUNTIME_DIR/docker.pid") -- socat TCP4-LISTEN:$SOURCE,reuseaddr,fork "$SOCKFILE" &
echo forwarding $SOURCE:$DEST... use ctrl+c to quit
sleep 365d
そして、コンテナ内からアクセスしたいポートを以下のように指定して起動します。
./docker-port-forward.sh 3000
上のスクリプトでは主に2つのことをしています。
-
socat UNIX-LISTEN:"$SOCKFILE",fork TCP:127.0.0.1:$DEST
このコマンドは/run/user/xxxx/forward-docker2host-*.sockのようなUNIXのソケットファイルを作成し、そこに接続がある度にその接続を127.0.0.1:$DESTに送ります -
nsenter -U --preserve-credentials -n -t $(cat "$XDG_RUNTIME_DIR/docker.pid") -- socat TCP4-LISTEN:$SOURCE,reuseaddr,fork "$SOCKFILE"
このコマンドはまずnsenterコマンドを使って、Dockerを実行しているプロセスの名前空間に入ります。さらにこの名前空間の中でsocatコマンドを使用して、$SOURCEのポートに接続がある度に$SOCKFILEに送ります。
つまり、UNIXのソケットファイルを通じてホスト上の特定のポートの通信を中継しているということです。
こうすることで、コンテナ内からホストに立てたエンドポイントにアクセスすることができます。
Rootless Dockerでの--disable-host-loopbackの指定を解除する
rootlessモードのDockerは slirp4netns のホストへアクセスを禁止するオプション(--disable-host-loopback)を設定しています。
/usr/bin/dockerd-rootless.shを見ると、それが確認できます。
102 │ exec $rootlesskit \
103 │ --net=$net --mtu=$mtu \
104 │ --slirp4netns-sandbox=$DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX \
105 │ --slirp4netns-seccomp=$DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP \
106 │ --disable-host-loopback --port-driver=$DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER \
107 │ --copy-up=/etc --copy-up=/run \
108 │ --propagation=rslave \
109 │ $DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS \
110 │ "$0" "$@"
--disable-host-loopbackを外せば、host.docker.internalにアクセスできるようになります。ただし、これはオススメしません。なぜなら、このオプションを外すと、コンテナ内からホストの全てのポートにアクセスできるようになってしまうからです。デバイスにアクセスできないからとprivilegeオプションをつけるようなものなので、良い方法とは言えません。
ngrokなどを使ってグローバルに公開する
Dockerもsocatも分からないし、--disable-host-loopbackをつけるのもなんだか怖い、10秒以内に解決する方法を教えてくれと言われたら、おそらくこれをオススメします。
ngrokなどを使って、ローカルのサーバーをグローバルに公開する方法です。ngrokは無料で使えるので、とりあえず試してみるのには良いと思います。
npm install ngrok -g
ngrok http 3000
これでとりあえず、グローバルにアクセス可能なエンドポイントが作成されるので、コンテナ内からでもアクセスできるようになります。ただし、有料版でない場合は実行する度にドメインが変わってしまう上に、URLを知っている人なら誰でもアクセス可能なので、利用時はその点に注意してください。
結論
以上がDockerコンテナ内からホストのlocalhostにアクセスする方法です。オススメはDocker Composeを使って同一ネットワーク内にサーバーを立てる方法です。ただ、この方法はサーバーのソースコードをbindする必要が合ったり、開発環境によってはホットリロードが遅くなったりするので、その点は注意してください。手軽な方法としてはUNIXソケットを使ってTCP通信を中継する方法が良いと思います。
もっと良い方法があれば、コメントでぜひ教えてください。
Discussion