Step 04 — Egress sidecar で mTLS を肩代わりさせる (Envoy / HAProxy)
ゴール
- アプリ (Go クライアント) から TLS コードを すべて剥がし、平文 TCP で sidecar プロキシに喋らせる。
- プロキシ側で クライアント証明書を提示する mTLS の起点 (originate) を構成する。
- 同じ構図を Envoy と HAProxy の 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 run。localhost:9445に 平文 TCP で繋ぐだけ。crypto/tlsもcrypto/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 は本ステップでは未設定)。
- Envoy 版:
- 証明書:
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 が出れば OKtutorial/step03-mtls/certs/ 直下に ca.crt, server.crt, server.key, client.crt, client.key が並んでいれば OK (サーバーもコンテナで動かすので server 系も要る)。
ファイル
| ファイル | 役割 |
|---|---|
client/main.go | :9445 に平文 TCP で繋ぐだけのクライアント (ホスト側で動かす)。Envoy/HAProxy 共通 |
envoy/envoy.yaml | Envoy の静的設定。listener / cluster / transport_socket / admin |
envoy/compose.yaml | Envoy + Step03 サーバーを 1 ネットワーク内で立ち上げる定義 |
haproxy/haproxy.cfg | HAProxy の設定 (frontend bind + backend server ssl crt ca-file verify required) |
haproxy/compose.yaml | HAProxy + 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 経由) | |
|---|---|---|
| import | crypto/tls, crypto/x509 あり | net だけ |
| 鍵/証明書のロード | アプリで LoadX509KeyPair | プロキシがファイルから読む |
| 接続呼び出し | tls.Dial(...) | net.Dial("tcp", ...) |
| 接続先 | サーバー本体 :9444 | sidecar のローカル 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 chain の tcp_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: server は compose.yaml の services.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.Config | Envoy YAML |
|---|---|
Certificates | tls_certificates (chain + key) |
RootCAs | validation_context.trusted_ca |
ServerName (SNI) | sni |
| SAN マッチ (Go 1.15+ の標準動作) | match_typed_subject_alt_names |
UpstreamTlsContext は「自分が クライアント として上流に繋ぐとき」の TLS 設定。逆に、Envoy をサーバーとして使う場合は DownstreamTlsContext で require_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/netshoot は tcpdump / tshark / dig などが同梱された診断用イメージで、サーバー/Envoy のイメージには何も足さずに済みます。別ターミナルでクライアント (go run ./tutorial/step04-egress-mtls/client) を 1 往復走らせ、Ctrl-C で tcpdump を止めると、カレントディレクトリに 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 || trueKubernetes の 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特に /clusters に cx_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 envoyUpstreamTlsContext の検証は 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 serverconnect_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 localhostmode tcp (defaults で指定) の frontend で平文 TCP を受け、backend の server 行で 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_address | frontend ... bind *:9445 |
| L4 透過プロキシ | envoy.filters.network.tcp_proxy フィルタ | mode tcp (defaults) |
| 上流のアドレス | clusters[].load_assignment ... endpoint.address | server 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 ファイル) |
| 上流サーバー証明書を検証する CA | validation_context.trusted_ca | ca-file /certs-ro/ca.crt + verify required |
| SNI | sni: localhost | sni str(localhost) |
| サーバー証明書のホスト名検証 | match_typed_subject_alt_names | verifyhost localhost |
| 上流接続のタイムアウト | connect_timeout: 1s | timeout 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.ConfigとUpstreamTlsContextのフィールド対応が頭に入った状態で、Istio / Consul Connect / Linkerd など実運用の mesh 設定を読める下地ができる。
注意 (本番運用へ向けて)
- 平文 listener (
:9445) は 必ずループバックか Unix domain socket に閉じ込める。今回の0.0.0.0バインドは「Docker のネットワーク境界の中だから」許容しているだけ。 - admin インターフェースは 同じく外部公開しない。
/quitquitquitでプロセスを落とせるなど強力な口が並んでいる。 - 証明書のローテーションは、本番では SDS (Secret Discovery Service) で動的に配るのが標準。ファイル直マウントは教材限定の手抜き。