コンテンツにスキップ

Step 04 — Egress sidecar で mTLS を肩代わりさせる (Envoy / HAProxy)

ゴール

  • アプリ (Go クライアント) から TLS コードを すべて剥がし、平文 TCP で sidecar プロキシに喋らせる。
  • プロキシ側で クライアント証明書を提示する mTLS の起点 (originate) を構成する。
  • 同じ構図を EnvoyHAProxy の 2 通りで動かし、L4 mTLS sidecar の設定が製品によってどう書き分けられるかを比べる。
  • Envoy の主要構成要素 (listener / filter chain / cluster / endpoint / transport_socket / admin) を、最小の YAML を読みながら把握する。

実運用の service mesh (Istio / Linkerd) が「アプリの隣に sidecar を立てて mTLS を肩代わりさせる」のと同じ構図を、ローカルで一番小さく再現します。本編は Envoy 版を中心に解説し、HAProxy 版は末尾の節で「同じことを HAProxy で書くとどうなるか」を設定対応表とともに示します。

構成図

    graph LR
    subgraph host["ホスト (ターミナル B)"]
        client["Go client\n(go run)"]
    end

    subgraph compose["compose ネットワーク (ターミナル A)"]
        proxy["envoy または haproxy\n(片方だけ起動)"]
        server["server\n(Step03 無改変)"]
    end

    admin["envoy admin\n:9901 (envoy 版のみ)"]

    client -- "平文 TCP\n:9445" --> proxy
    proxy -- "mTLS\n:9444" --> server
    proxy -. "host へ公開" .-> admin
  
  • クライアント: ホスト側で go runlocalhost:9445平文 TCP で繋ぐだけ。crypto/tlscrypto/x509 も import しない。Envoy 版・HAProxy 版で同一バイナリ・同一コードを使う (どちらに繋いでいるかをクライアントは知らない)。
  • サーバー: Step03 の tutorial/step03-mtls/server無改変のままgolang:1.26-alpine イメージにバインドマウントして go run で起動。compose ネットワーク内では server:9444 で名前解決される。
  • プロキシ: 以下のどちらか片方を起動する (ホストポート :9445 を奪い合うので同時起動不可):
    • Envoy 版: envoyproxy/envoy:v1.33-latest:9445 (listener) と :9901 (admin) をホストに公開。
    • HAProxy 版: haproxy:2.9-alpine:9445 (frontend) のみ公開 (admin/stats は本ステップでは未設定)。
  • 証明書: tutorial/step03-mtls/certs/ をどちらのプロキシにもマウントして再利用 (新規生成しない)。

プロキシとサーバーが同じ compose ネットワーク内に並ぶ姿は、Kubernetes における Pod 内 sidecar の絵にほぼ一致します (Pod 内のコンテナ群が同じ network namespace を共有するのと、compose ネットワークでサービス名解決が効くのは構造的に近い)。

前提

# Step03 で生成した証明書がそのまま要る
make certs

# Docker (Desktop) と docker compose v2 が動いていること
docker version
docker compose version    # 2.x が出れば OK

tutorial/step03-mtls/certs/ 直下に ca.crt, server.crt, server.key, client.crt, client.key が並んでいれば OK (サーバーもコンテナで動かすので server 系も要る)。

ファイル

ファイル役割
client/main.go:9445 に平文 TCP で繋ぐだけのクライアント (ホスト側で動かす)。Envoy/HAProxy 共通
envoy/envoy.yamlEnvoy の静的設定。listener / cluster / transport_socket / admin
envoy/compose.yamlEnvoy + Step03 サーバーを 1 ネットワーク内で立ち上げる定義
haproxy/haproxy.cfgHAProxy の設定 (frontend bind + backend server ssl crt ca-file verify required)
haproxy/compose.yamlHAProxy + Step03 サーバーを 1 ネットワーク内で立ち上げる定義。entrypoint で client.crt + client.key を結合 PEM に変換

動かす (Envoy 版)

ターミナルを 2 つ使います。HAProxy 版の起動方法は本ページ末尾の節を参照。

ターミナル A — Envoy + サーバーを compose で起動

リポジトリルートから:

docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml up

または cd してから:

cd tutorial/step04-egress-mtls/envoy
docker compose up

ログに以下の 2 行が両方揃ってからクライアントを叩きます。

server-1  | step03 mTLS server listening on :9444 (mutual auth)
envoy-1   | starting main dispatch loop

初回は golang:1.26-alpine (≈100MB) と envoyproxy/envoy:v1.33-latest の pull が走ります。サーバー側は go run で起動時に毎回コンパイルが走るので、コンテナ起動から listening まで数秒かかります。depends_onコンテナ起動順 しか揃えないため、Envoy のほうが先に “starting main dispatch loop” を出すことがあります。サーバーの listening ログを待ってからクライアントを動かしてください。

ターミナル B — ホスト側で平文クライアント

リポジトリルートから:

go run ./tutorial/step04-egress-mtls/client
# => connected to localhost:9445 (plaintext) — Envoy will originate mTLS to upstream
hi
hello alice@example.com, you said: hi

サーバー側ログ (compose の server-1) には Step03 と同じく peer CN=alice@example.com が出ます。サーバーにとっては「直接の相手は envoy コンテナ」だが、提示された証明書の CN は alice@example.com という構図です。

撤収

# Ctrl-C で compose プロセスを止めるか、別ターミナルから:
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml down

クライアント側コードの変化

Step03 client と Step04 client を見比べると、tls.Config の組み立てと tls.Dial がまるごと消えています。

Step03 (直接 mTLS)Step04 (sidecar 経由)
importcrypto/tls, crypto/x509 ありnet だけ
鍵/証明書のロードアプリで LoadX509KeyPairプロキシがファイルから読む
接続呼び出しtls.Dial(...)net.Dial("tcp", ...)
接続先サーバー本体 :9444sidecar のローカル listener :9445
行数 (おおよそ)70 行強35 行

mTLS 設定の責任が アプリのバイナリ から プロキシの設定ファイル に動いただけで、ネットワーク上で起きていることは Step03 と同じです (Envoy 版でも HAProxy 版でも違いはない)。

Envoy 設定の読み方

envoy/envoy.yaml の構造を下から順に読み解きます。

listener — 入口

listeners:
  - address: { socket_address: { address: 0.0.0.0, port_value: 9445 } }
    filter_chains:
      - filters:
          - name: envoy.filters.network.tcp_proxy
            typed_config:
              cluster: mtls_upstream

:9445 で TCP を受け、filter chaintcp_proxy がそれをそのまま mtls_upstream クラスタへ流します。echo は HTTP ではないので L7 (http_connection_manager) は使いません — L4 透過プロキシです。

cluster — 上流の論理グループ

clusters:
  - name: mtls_upstream
    type: STRICT_DNS
    load_assignment:
      endpoints:
        - lb_endpoints:
            - endpoint: { address: { socket_address: { address: server, port_value: 9444 } } }

cluster は「同じ役割の上流ホストの集合」を表す Envoy の単位です。今回はエンドポイントが 1 つだけ (Step03 サーバー)。複数ある場合はここで負荷分散の対象になります。

address: servercompose.yamlservices.server という名前 を Docker の組み込み DNS が解決します。Kubernetes における Service 名による DNS と同じ発想で、IP を直接書かないので Pod/コンテナの再起動で IP が変わっても追従します。

type: STRICT_DNS にする理由
type: STATIC は address にリテラルの IP アドレスしか受け付けません。server のようなホスト名を指定すると “malformed IP address” エラーで起動に失敗します。
STRICT_DNS にすると Envoy が DNS を引いて IP に解決するため、compose ネットワーク内の server サービス名が正しく使えます。

transport_socket — 上流との間の暗号化

    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": .../UpstreamTlsContext
        sni: localhost
        common_tls_context:
          tls_certificates:
            - certificate_chain: { filename: /certs/client.crt }
              private_key:       { filename: /certs/client.key }
          validation_context:
            trusted_ca: { filename: /certs/ca.crt }
            match_typed_subject_alt_names:
              - san_type: DNS
                matcher: { exact: localhost }

ここが Step03 client の tls.Config と等価な部分です。対応関係:

Step03 tls.ConfigEnvoy YAML
Certificatestls_certificates (chain + key)
RootCAsvalidation_context.trusted_ca
ServerName (SNI)sni
SAN マッチ (Go 1.15+ の標準動作)match_typed_subject_alt_names

UpstreamTlsContext は「自分が クライアント として上流に繋ぐとき」の TLS 設定。逆に、Envoy をサーバーとして使う場合は DownstreamTlsContextrequire_client_certificate 等を組みます (このステップでは使いません)。

admin — 観察口

admin:
  address: { socket_address: { address: 0.0.0.0, port_value: 9901 } }

:9901 でメトリクスや設定ダンプを返す管理 API。Envoy のデバッグはまずここを叩く のが定石です (後述)。

観察ポイント (実験してみよう)

1. クライアント → Envoy 区間が平文であることを目で見る

Envoy リスナーはホストに公開されているので、:9445 だけはホストの loopback で tcpdump できます。

sudo tcpdump -i lo0 -A 'tcp port 9445'
# クライアントを動かすと "hi" や "hello alice@example.com..." が生で見える

一方、Envoy → サーバーの mTLS 区間 (server:9444)compose の bridge ネットワーク内に閉じている ため、ホストの lo0 には流れず tcpdump -i lo0 では捕まえられません。「暗号化されている」ことを確かめる代替手段:

# Envoy のログで TLS ハンドシェイクの様子を見る
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml logs envoy | grep -i 'tls\|ssl\|handshake'

# admin の cluster 統計で "ssl.handshake" が増えていることを確認
curl -s 'localhost:9901/stats?filter=mtls_upstream.*ssl' | head
# 例: cluster.mtls_upstream.ssl.handshake: 1

実際に envoy ↔ server 区間のパケットを Wireshark で見る

tcpdump -i lo0 では捕まえられない」のは、compose ネットワークが コンテナの network namespace に閉じている ためです。サーバーコンテナの netns に サイドカーで間借り すれば、その eth0 をそのままキャプチャできます。macOS の Docker Desktop でも同じ手順が通ります (ホストから docker bridge は見えませんが、コンテナの中に潜り込めば中の NIC は触れます)。

# サーバーコンテナの netns を共有し、tcpdump で pcap を吐く
# (コンテナ名は `docker compose ps` で確認。compose.yaml の name: で固定してある)
docker run --rm --net container:step04-egress-mtls-envoy-server-1 \
    -v "$PWD":/out nicolaka/netshoot \
    tcpdump -i eth0 -s 0 -w /out/inter.pcap 'tcp port 9444'

nicolaka/netshoottcpdump / tshark / dig などが同梱された診断用イメージで、サーバー/Envoy のイメージには何も足さずに済みます。別ターミナルでクライアント (go run ./tutorial/step04-egress-mtls/client) を 1 往復走らせ、Ctrl-Ctcpdump を止めると、カレントディレクトリに inter.pcap ができます。ホストの Wireshark で開けば、ClientHello から始まる TLS ハンドシェイクと、その後に続く暗号化済み Application Data が見えます (Step02/03 の tls-dump.txt と同じ形)。

仕組みのキモは --net container:<name>。これは 「指定コンテナと同じ netns で新しいプロセスを起こす」 Docker のフラグで、Linux の setns(2) をラップしたものです。Pod 内サイドカーで localhost がアプリと共有される話と同じ仕組みで、Step 04 の sidecar パターンの理解そのものを実演する観察方法でもあります。

ポイントは 「プロセス境界 (= compose 内 envoy ↔ server コンテナ) を跨いだ瞬間に暗号化される」 こと。実環境では平文 listener (:9445) はループバックや Unix domain socket に閉じ込め、外に出さないのが定石です。

2. compose ネットワークの DNS で server が引けることを確認

Envoy が host.docker.internal を使わずに済んでいる本質は、compose の組み込み DNS にあります。実際に引いてみます:

docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml exec envoy nslookup server
# (envoy イメージに nslookup が無ければ getent でも可)
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml exec envoy getent hosts server

# envoy から server コンテナの :9444 が直接見えていることを確認
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml exec envoy nc -zv server 9444 || true

Kubernetes の Service 名解決と同じ発想です。Istio/Linkerd で envoy が productpage:9080 のようなアドレスを使っているのも、根は同じ仕組み。

3. Envoy admin API で構成と健全性を確認

# クラスタの状態 (上流が到達可能か)
curl -s localhost:9901/clusters | grep mtls_upstream

# mTLS 系の統計だけ抽出
curl -s 'localhost:9901/stats?filter=ssl|mtls'

# 起動中の有効設定をフルダンプ (YAML から展開された姿)
curl -s localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Cluster"))'

# リスナー一覧
curl -s localhost:9901/listeners

特に /clusterscx_total (接続総数) が出るので、クライアントを動かす前後で増えるのが確認できます。

4. 別 CA に差し替えて Envoy が拒否することを確認

Envoy の validation_context.trusted_ca別の CA にすると、上流サーバー証明書の署名検証に失敗します。簡単な確認方法:

# 本物の ca.crt を退避してから別 CA で上書きし、compose を再起動 (マウント先を読み直させる)
mv tutorial/step03-mtls/certs/ca.crt tutorial/step03-mtls/certs/ca.crt.good
openssl req -x509 -new -nodes -newkey rsa:2048 -days 1 \
    -subj "/CN=Wrong CA" -keyout /tmp/wrong.key -out tutorial/step03-mtls/certs/ca.crt

docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml restart envoy
go run ./tutorial/step04-egress-mtls/client
# Envoy ログに以下のような行が出る:
#   TLS error: ...:CERTIFICATE_VERIFY_FAILED
# クライアントからは TCP は通るが直後に切られる (= Envoy が上流に繋げず断)

# 後始末
mv tutorial/step03-mtls/certs/ca.crt.good tutorial/step03-mtls/certs/ca.crt
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml restart envoy

UpstreamTlsContext の検証は Step02 で見た RootCAs 検証と完全に同じ ロジックです (内部的には BoringSSL/OpenSSL の X509_verify)。

5. 上流を落とすと Envoy がどう振る舞うか

サーバーコンテナだけ止めてからクライアントを動かしてみます:

docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml stop server
go run ./tutorial/step04-egress-mtls/client
# クライアントは接続が即座に切れるか、何も応答せず閉じられる
# `/clusters` の health 表示も変わる:
curl -s localhost:9901/clusters | grep mtls_upstream

# 復旧
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml start server

connect_timeout: 1s を効かせているので、ハングはしません。

6. 設定をホットリロードする (発展)

Envoy はファイル監視によるホットリロードを直接やらず、xDS (動的設定 API) で外部の control plane (Istiod / Consul / 自作) から設定を流し込むのが標準です。本ステップは static_resources だけで完結させる最小構成。xDS は次のステップの題材になり得ます。

HAProxy 版で同じことをする

「同じ egress sidecar (= 平文を受けて mTLS で再送) を HAProxy で書くとどうなるか」を最小限で示します。ホストポート :9445 を Envoy 版と共有しているので、Envoy 版を起動中なら先に止めてから HAProxy 版を上げてください。

# Envoy 版を起動中なら先に停止
docker compose -f tutorial/step04-egress-mtls/envoy/compose.yaml down

# HAProxy 版を起動 (ターミナル A)
docker compose -f tutorial/step04-egress-mtls/haproxy/compose.yaml up

# クライアントは無改修で同じ (ターミナル B)
go run ./tutorial/step04-egress-mtls/client
# => connected to localhost:9445 (plaintext) — Envoy will originate mTLS to upstream
hi
hello alice@example.com, you said: hi

# 撤収
docker compose -f tutorial/step04-egress-mtls/haproxy/compose.yaml down

クライアントが出す Envoy will originate mTLS ... は単なるログ文言。実際に mTLS を originate しているのは HAProxy です。サーバー側ログには Envoy 版と同じく peer CN=alice@example.com が出ます。

設定の主要部分 (haproxy/haproxy.cfg)

frontend plaintext_in
    bind *:9445
    default_backend mtls_upstream

backend mtls_upstream
    server srv1 server:9444 ssl crt /tmp/client.pem ca-file /certs-ro/ca.crt verify required sni str(localhost) verifyhost localhost

mode tcp (defaults で指定) の frontend で平文 TCP を受け、backendserver 行で ssl を付けたうえでクライアント証明書 (crt) と上流検証用 CA (ca-file) を渡す。これだけで egress mTLS が完成します。

crt /tmp/client.pem の中身は 証明書 + 秘密鍵を連結した PEM で、compose.yaml の entrypoint で cat client.crt client.key > /tmp/client.pem して作っています (HAProxy の crt は連結 PEM を要求し、Envoy のように chain と key を別ファイルで指定できないため)。

Envoy ↔ HAProxy 設定対応表

観点Envoy (envoy/envoy.yaml)HAProxy (haproxy/haproxy.cfg)
入口 (listener)listeners[].address.socket_addressfrontend ... bind *:9445
L4 透過プロキシenvoy.filters.network.tcp_proxy フィルタmode tcp (defaults)
上流のアドレスclusters[].load_assignment ... endpoint.addressserver srv1 server:9444
上流側 TLS の有効化transport_socket: envoy.transport_sockets.tls (UpstreamTlsContext)server ... ssl
自分が提示するクライアント証明書tls_certificates: [{certificate_chain, private_key}] (chain と key を別ファイル)crt /tmp/client.pem (連結 PEM 1 ファイル)
上流サーバー証明書を検証する CAvalidation_context.trusted_caca-file /certs-ro/ca.crt + verify required
SNIsni: localhostsni str(localhost)
サーバー証明書のホスト名検証match_typed_subject_alt_namesverifyhost localhost
上流接続のタイムアウトconnect_timeout: 1stimeout connect 5s (defaults)
動的設定の流儀xDS (LDS/CDS/EDS/SDS)Runtime API + set ssl cert ... (本ステップでは未使用)
管理ポートadmin :9901(本ステップでは公開せず)

UpstreamTlsContext のフィールド」と「server 行の SSL オプション」は 意味的にきれいに 1:1 対応 します。L4 mTLS sidecar を書くために本当に必要な情報は 「自分が出す鍵/証明書」「相手を信頼するための CA」「相手のホスト名 (SNI + 検証)」 の 3 つだけ — そこは製品共通のミニマルセットで、残りの差はシンタックスと運用機能 (動的設定・観測・WASM など) の話、というのがこの表の読みどころ。

観察実験について

「観察ポイント (実験してみよう)」で書いた 1〜5 (パケット平文確認, compose DNS 名前解決, 別 CA に差し替えて拒否, 上流 down 時の挙動) は、HAProxy 版でもほぼそのまま試せます。差分だけ列挙:

  • admin/stats ポートが無い: Envoy 版で :9901/stats:9901/clusters を叩いていた箇所は、HAProxy 版では使えません (本ステップのミニマル設定では stats listener を立てていないため)。代わりに docker compose -f tutorial/step04-egress-mtls/haproxy/compose.yaml logs haproxy で接続ログを見ます。
  • コンテナ名: netshoot で netns を間借りする実験は、HAProxy 版だと step04-egress-mtls-haproxy-server-1 が対象になります (envoy 版の step04-egress-mtls-envoy-server-1 から名前が変わるだけ)。
  • 別 CA に差し替えての拒否: HAProxy ログに verify failed: unable to verify the first certificate 系のエラーが出て backend が DOWN になります (Envoy 版と検出される現象自体は同じ)。

用語ミニまとめ

  • listener: Envoy が接続を受ける入口 (address + port)。Pod なら通常 1 つ、ホスト型運用なら複数。
  • filter chain / network filter: listener が受けた L4 接続をどう処理するかのパイプライン。tcp_proxy (透過プロキシ), http_connection_manager (L7) などが入る。
  • cluster: 「同じ役割の上流ホスト群」の論理単位。LB ポリシー、ヘルスチェック、サーキットブレーカ、TLS などはここに付く。
  • endpoint: cluster の中の実体 (host:port)。STATIC / STRICT_DNS / EDS などで決まり方が変わる。
  • transport_socket: 「相手との間でどう暗号化するか」。listener 側は DownstreamTlsContext、cluster 側は UpstreamTlsContext
  • SNI: クライアント (= 今回は Envoy) が ClientHello に乗せるホスト名。Step02 で出てきたものと同じ。
  • admin interface: 設定・統計・ログレベルを操作する管理ポート。本番では外部に晒さないこと
  • xDS: 設定を動的に配信する gRPC API 群 (LDS/CDS/EDS/RDS/SDS など)。本ステップでは使わない。
  • sidecar: アプリと同じネットワーク名前空間に同居して入出力を肩代わりするプロキシの配置パターン。

ここまでで身についたもの

  • 「mTLS の責任をどこに持たせるか」を、アプリ内 (Step03)プロセス境界の sidecar (Step04) の 2 通りで構成できる。
  • Envoy の最小構成 (listener → filter chain → cluster → transport_socket) を YAML から書ける。
  • 同じ egress mTLS sidecar を HAProxy (mode tcp + server ... ssl crt ca-file verify required) でも書けて、Envoy の UpstreamTlsContext と 1:1 で対応付けられる。
  • tls.ConfigUpstreamTlsContext のフィールド対応が頭に入った状態で、Istio / Consul Connect / Linkerd など実運用の mesh 設定を読める下地ができる。

注意 (本番運用へ向けて)

  • 平文 listener (:9445) は 必ずループバックか Unix domain socket に閉じ込める。今回の 0.0.0.0 バインドは「Docker のネットワーク境界の中だから」許容しているだけ。
  • admin インターフェースは 同じく外部公開しない/quitquitquit でプロセスを落とせるなど強力な口が並んでいる。
  • 証明書のローテーションは、本番では SDS (Secret Discovery Service) で動的に配るのが標準。ファイル直マウントは教材限定の手抜き。