Docker container からホストでListenしているポートにアクセスする

Docker Desktop ではコンテナから host.docker.internal を使って、ホストにアクセスすることができる。

index.js:

const express = require("express");
const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("OK\n");
});

app.listen(port, () => {
  console.log("Start");
});
$ node index.js
$ docker run -it --rm nginx curl http://host.docker.internal:3000
OK

だが、これはあくまでも Docker Desktop が提供する機能なので、それ以外のDocker環境では使用できない。また Docker Desktop でも WSL2 backend のものでは使用できないようだ。

しかし、Docker Engine 10.20.0 から --add-host="host.docker.internal:host-gateway" オプションを指定することで、同様のことができるようになった1。このオプションは、コンテナの /etc/hosts に docker network上でのホストのIPを host.docker.internal として登録する。ここで、オプションとしては :host-gatewayの部分が重要で、host.docker.internalの部分は任意の値にできる。

$ node index.js
$ docker run -it --rm --add-host="host.docker.internal:host-gateway" nginx curl http://host.docker.internal:3000
OK
$ docker run -it --rm --add-host="host:host-gateway" nginx curl http://host:3000
OK

また、--add-host オプションは docker-compose.yml では extra_hosts で指定できる。

---
version: '3.8'
services:
  service1:
    image: image-name
    extra_hosts:
      - host.docker.internal:host-gateway

ユースケース

これを用いると、たとえば以下のようなことができるようになる。

  • ローカルでポート3000にListenしている Next server をSSL化する
  • k8sクラスタで起動しているMySQLに、devcontainerのコンテナからアクセスする

ローカルでポート3000にListenしている Next server をSSL化する

今日の開発環境では広く用いられている Docker だが、フロントエンド開発では Docker を使わないことが多い。これは Docker 環境を用意する必要性があまりないことと、主に Docker Desktop for Mac のホストディレクトリをマウントしたときのIO性能が低いためである(WebpackなどはIOヘビー)。

しかし、たとえばモバイル向けのSPAで写真撮影のためにカメラデバイスを使いたいといった場合、PCで起動している Next server にモバイル端末がアクセスするためにはPCの Private IP を使って(たとえば 192.168.1.2)、http://192.168.1.2:3000 にアクセスする必要があるが、カメラデバイスにアクセスするためには localhost や fileスキームでアクセスした場合を除き、SSLで保護されている必要がある2

ここで、SSL化するのに手っ取り早いのは mkcert でCAインストール/証明書を作成して、Docker上にnginxを立ててSSLを有効にしたserverから Next server にリバースプロキシしてやることだが、上で述べたように、フロントエンド環境をDocker上に構築したくはない。

そこで、host.docker.internal を使って、nginx では http://host.docker.internal:3000/ にリバースプロキシしてやることで実現する。

docker-compose.yml:

version: '3.7'
services:
  nginx:
    image: nginx
    ports:
      - 0.0.0.0:80:80
      - 0.0.0.0:443:443
    volumes:
      - type: bind
        source: ./default.conf
        target: /etc/nginx/conf.d/default.conf
      - type: bind
        source: ./certs
        target: /etc/certs
    extra_hosts:
      - host.docker.internal:host-gateway

default.conf:

server {
    listen 443 ssl default;
    ssl_certificate /etc/certs/localhost+2.pem;
    ssl_certificate_key /etc/certs/localhost+2-key.pem;

    proxy_set_header  Host                $host;
    proxy_set_header  X-Real-IP           $remote_addr;
    proxy_set_header  X-Forwarded-Host    $host;
    proxy_set_header  X-Forwarded-Server  $host;
    proxy_set_header  X-Forwarded-For     $proxy_add_x_forwarded_for;

    location / {
        proxy_pass http://host.docker.internal:3000/;
    }
}

server {
    listen 80;
    return 301 https://$host$request_uri;
}
$ mkcert -install
$ (cd certs && mkcert localhost 127.0.0.1 192.168.1.2)
$ node index.js
$ docker-compose up
$ curl https://192.168.1.2/
OK

なお、モバイル端末にも mkcert のCAをインストールする必要があるため、たとえばiOS端末の場合、以下の手順を踏む。

  1. キーチェーンアクセスを起動し、mkcertでインストールしたCAを右クリックして、PEM形式で書き出す
  2. 書き出したPEMをAir DropなどでiOS端末に送信し、インストールする
  3. [設定] → [一般] → [情報] → [証明書信頼設定] → ルート証明書を全面的に信頼する と進み、インストールした証明書をONする

また、SSLは使っていないが、これに近いサンプルは sandbox/docker-host-docker-internal にある。

k8sクラスタで起動しているMySQLに、devcontainerのコンテナからアクセスする

以下のような開発開発があったとする。

  • 開発開発がk8sクラスタ
  • 開発対象のDockerイメージを使ってdevcontainer用のdocker-composeを立ち上げ、VS Code を使って開発

このような場合、普通はMySQLなどのDBもdevcontainer用docker-compose上に用意するか、DBアクセスはあきらめる必要があるが、host.docker.internal と Kube Forwarder などをつかって k8s上の MySQL service をホストにポートフォワーディングすることで、devcontainer から k8s 上のMySQLにアクセスすることができる。

.devcontainer/devcontainer.json:

{
    "name": "go-app",
    "dockerComposeFile": [
        "../docker-compose.yml"
    ],
    "service": "app",
    "remoteUser": "root",
    "workspaceFolder": "/app",
    "extensions": [
        "golang.go"
    ]
}

Dockerfile:

FROM golang:1.18.2
RUN go install golang.org/x/tools/gopls@latest
WORKDIR /app

docker-compose.yml:

version: '3.8'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    command: sleep infinity
    volumes:
      - type: bind
        source: .
        target: /app
    environment:
      DB_HOST: host.docker.internal
    extra_hosts:
      - host.docker.internal:host-gateway

main.go:

import (
    "fmt"
    "database/sql"

    "github.com/go-sql-driver/mysql"
)

func main() {
    c := mysql.Config{
        DBName: "dbname",
        User:   "root",
        Passwd: "",
        Addr:   fmt.Sprintf("%s:3306", os.Getenv("DB_HOST")),
        Net:    "tcp",
    }
    dsn := c.FormatDSN()
    db, _ := sql.Open("mysql", dsn)
    var value string
    db.QueryRow("SELECT 1").Scan(&value)
    fmt.Println(value)
}
# k8sクラスタ起動して、Kube Forwarder で MySQL service をローカルにポートフォワーディング
[host]$ skaffold dev

# Dockerコンテナを起動し、VS Code で開く
[host]$ devcontainer open .

# VS Code で開発・実行
[container]$ go run main.go
1

なお、今回はk8sクラスタとしたが、別のdocker-composeで起動したMySQLに接続するといったことも同様の手順で行える。

Docker Desktop WSL2 backend での注意点

Docker Desktop WSL2 backend では host.docker.internal はWSL2を指すのではなくWindows側となるようで、コンテナからWSL2上でlistenしているポートにアクセスするためには、WSL2のlocalhostForwarding機能を有効にする必要があるようだ。

環境

  • Mac:
    • OS: macOS Ventura 13.0.1
    • Docker Desktop: 4.12.0
    • Docker Engine: 20.10.17
  • Windows:
    • OS: Windows 11 Pro 22H2
    • Docker Desktop: 4.14.1
    • Docker Engine: 20.10.21
    • WSL: 1.0.1.0
    • WSL version: 2
    • WSL distribution: Ubuntu 20.04.4 LTS

参考