🐳

Dockerコンテナ内からホストのlocalhostにアクセスする

2023/11/22に公開

この記事ではホスト側で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にアクセスできないようになっています。

エラーの対処方法

いくつか対処方法は考えられます

  1. Dockerコンテナと同じネットワーク内でサーバーを立てる
  2. UNIXソケットを使ってTCP通信を中継する
  3. Rootless Dockerでの--disable-host-loopbackの指定を解除する
  4. 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の以下の投稿を参考にしました。

https://stackoverflow.com/questions/72500740/how-to-access-localhost-on-rootless-docker/74979409#74979409

まず以下のようなシェルスクリプト(docker-port-forward.sh)を作成します。

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つのことをしています。

  1. socat UNIX-LISTEN:"$SOCKFILE",fork TCP:127.0.0.1:$DEST
    このコマンドは/run/user/xxxx/forward-docker2host-*.sockのようなUNIXのソケットファイルを作成し、そこに接続がある度にその接続を127.0.0.1:$DESTに送ります

  2. 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を見ると、それが確認できます。

 102exec $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