トンネルコンテナは動いており、docker compose exec vpn curl -s https://ifconfig.me はプロバイダーのアドレスを返します。それでもデスクトップのブラウザーは ISP から出たままです。この差は想定内です。VPN クライアントを動かすコンテナが保持するのはネットワーク名前空間、ルート、トンネルインターフェースであり、HTTP や SOCKS を話すものではありません。ポートを公開しても、存在しなかったリスナーは作れません。

クライアントをどこで動かすかが設計を決めます。兄弟コンテナはトンネルの名前空間に移れますが、デスクトップアプリは移れず、ルーターにはプロキシ欄ではなくルートが必要です。対応を誤れば症状は同じです。接続拒否か、古い経路を黙って通る通信です。

クライアントを動かす場所に構成を合わせる

Compose を編集する前に一つ問いかけてください。クライアントを VPN コンテナのネットワーク名前空間の内側で起動できるでしょうか。すでに Linux コンテナとして動いているものは可能です。デスク上のブラウザー、IDE、CLI は不可能です。プロキシ設定が期待するのはプロトコルのサービスであり、名前空間ではないからです。

ClientArrangementWhat must already exist
Another Compose serviceShares the vpn namespace via network_mode: service:vpnA working tunnel and correct start order
Host browser, IDE or CLIProxy listener inside the namespace, published to host loopbackA configured SOCKS or HTTP proxy and its access rules
Router, VM or phoneThe container as a layer-3 gatewayForwarding, NAT and a return route

この三つは混ざりません。コンテナのブリッジアドレスを指す HTTP プロキシ欄は、そこにプロキシが待ち受けていなければ何にも届きません。またプロキシリスナーはルーターの通信をルーティングしません。まず決めてから、該当する節に従ってください。

トンネルの名前空間を兄弟コンテナと共有する

プロキシプロトコルが関与しないため、これが最も安価な選択肢です。アプリは vpn サービスの名前空間に入り、そのインターフェース、ルート、ポート空間を引き継ぎます。

services:
  vpn:
    image: your-vpn-client
    cap_add:
      - NET_ADMIN
    healthcheck:
      test: ["CMD-SHELL", "ip link show tun0 >/dev/null"]
      interval: 10s
      retries: 5
  app:
    image: your-app
    network_mode: service:vpn
    depends_on:
      vpn:
        condition: service_healthy

名前空間が別のサービスに属することから三つの制約が生じます。アプリは独自の networks キーを持ちません。接続すべき名前空間がないからです。ポート公開は vpn 側の責務です。名前空間がそこに住んでいるからです。そして vpn を作り直すとアプリが入った名前空間が破壊されるため、docker compose up -d --force-recreate vpn は app も作り直すことを要求します。単純な再起動では足りないことがあります。Compose のサービスリファレンス はこの構成とポート公開の制約を扱っています。

リゾルバーも付いてくると考えないでください。/etc/resolv.conf はコンテナごとのファイルであり、ネットワーク名前空間の一部ではないため、パケットがトンネルから出ていてもアプリはホストのリゾルバーに問い合わせられます。コンテナの内側でそれを読み、プライベート名をトンネル経由で解決する必要があるときはアプリに dns: を設定してください。

docker compose exec app ip route
docker compose exec app cat /etc/resolv.conf
docker compose exec app curl -s https://ifconfig.me

ルート一覧にトンネルインターフェースがあり、アドレスがプロバイダーのものであれば、アプリはトンネルの内側にいます。インターフェースがなければ、vpn にトンネルができる前に app が起動しているので、vpn が正常だと報告したら app を作り直してください。

ホスト側クライアントのために名前空間へ本物のプロキシリスナーを置く

デスクトップのブラウザーは名前空間に入れないため、トンネルはサービスとして公開する必要があります。vpn 名前空間の内側で本物の SOCKS または HTTP プロキシを動かし、そのポートだけを公開します。

services:
  vpn:
    ports:
      - "127.0.0.1:1080:1080"
  proxy:
    image: your-proxy
    network_mode: service:vpn
    command: ["--socks", "0.0.0.0:1080"]

ホスト側のアドレスがこの対応の重要な半分です。左側の 127.0.0.1:1080 はリスナーを LAN の外に保ちます。裸の "1080:1080" は、あなたのマシンに届くあらゆる端末へ開いたプロキシを公開してしまいます。

コンテナ側には独自の罠があります。公開ポートはブリッジ上のコンテナアドレスに届けられ、その loopback には届きません。そのため名前空間の内側で --socks 127.0.0.1:1080 で起動したプロキシは兄弟からの接続を受け付け、公開ポート経由で来るものはすべて拒否します。0.0.0.0 に束縛し、分離はホスト側の loopback バインドに任せてください。同じ規則はプロキシ設定ファイルの待ち受けアドレスにも当てはまります。

次に、名前をどこで解決するかを決めます。curl では --socks5 がホスト名をローカルで解決し、--socks5-hostname は名前をプロキシへ送ります。ブラウザーも同じ切り替えを別のラベルで提供しています。ローカル解決は、ページがトンネル経由で読み込まれても問い合わせを漏らします。

戻り経路がある場合にだけコンテナをゲートウェイとして扱う

ルーター、VM、スマートフォンは HTTP プロキシ欄を使わず、プロキシ変数を完全に無視するアプリもあります。それらの通信をルーティングするにはレイヤー 3 が必要です。コンテナがパケットを転送し、トンネルインターフェースでアドレスを変換します。

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

見落としやすい点が二つあります。クライアントには既定ゲートウェイをこのホストへ向けるルートが必要で、応答は戻る道を見つけなければなりません。上流ルーターの静的ルートか、クライアント側を向いたインターフェースの NAT のどちらかです。どちらもなければ、トンネルは要求を外へ運び、応答は消えます。

影響範囲も最も広くなります。LAN 全体のゲートウェイになるホストは、設定を誤ったすべてのクライアントをあなたの VPN アカウント経由で送り出します。トンネルが落ちると、kill switch に応じて LAN が暗くなるか ISP に戻ります。

出口経路を検証し、その後は順番に巻き戻す

この順序で確認し、最初の失敗で止めてください。名前空間内の出口アドレス、ホストからプロキシ経由の出口アドレス、次にプライベートホストの名前解決です。

docker compose exec vpn curl -s https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 http://internal.example/health

最初の二つのアドレスはプロバイダーと一致すべきで、三つ目は名前エラーで失敗するのではなくプロキシ経由で解決されるべきです。一行目が一致して二行目が失敗するなら、壊れているのはリスナーか公開ポートであり、トンネルではありません。

巻き戻しは設定の逆です。プロキシの環境変数をシェルプロファイルと systemd ユニットから外し、スタックを下げて共有名前空間を所有者ごと消します。公開ポートはプロキシサービスを消す前に削除してください。そうしないとホストに誰も読まない loopback リスナーが残ります。

先へ進む前に四つを確認してください。対象のクライアントが実際にトンネルへ届くこと、ポートを公開するのは名前空間の所有者だけであること、プライベート名が意図した場所で解決されること、loopback でないホストアドレスで何も待ち受けていないことです。どれも本番では静かに失敗し、どれも上の一行チェックで分かります。

関連記事