mTLS(相互TLS)完全ガイド|マイクロサービス・ゼロトラスト・サービスメッシュ
- mTLS(相互TLS)は、マイクロサービス間で「通信相手が許可されたワークロードであること」をTransport層で証明するゼロトラストの基盤だ。
- 実装の定石は、SPIFFE ID + 短期証明書、受信側の
requestCert: true+ CA検証、Envoy/nginx ならrequire_client_certificate/ssl_verify_client on。 - 小規模ならOpenSSLとNode.jsで足りるが、サービス数が増えたら SPIRE または Istio/Linkerd で自動ローテーションに移行する。
- ブラウザ向けの多層防御(CSP・HSTS等)はmTLSとは別層——外部はHTTPヘッダー、内部はmTLSで役割分担する。
- TLS 1.3の性能最適化(0-RTT等)はmTLS通信でも設定方針を検討する。
mTLS(相互TLS)とは
TLS(Transport Layer Security) は、HTTP 通信を暗号化し、サーバー証明書で「接続先が本物のサーバーであること」をクライアントが検証する仕組みです。ECサイトやAPIの HTTPS は、この一方向認証(サーバー→クライアントへの証明)が標準です。
mTLS(mutual TLS / 相互TLS) は、クライアント側も X.509 証明書 を提示し、サーバー(またはプロキシ)が クライアントの身元を検証する 双方向認証です。日本語のロングテールキーワード「mTLS マイクロサービス」「相互TLS 設定」で検索される実装ニーズの中心は、Kubernetes 上の Pod 間通信 や VPC 内の east-west トラフィック を、ネットワーク境界(ファイアウォール・セキュリティグループ)だけに頼らず保護することです。
通常TLSとmTLSの違い
| 観点 | TLS(HTTPS) / mTLS |
|---|---|
| 認証の方向 | TLS: クライアントがサーバーを認証 / mTLS: サーバーとクライアントが相互に認証 |
| 証明書の提示者 | TLS: サーバーのみ / mTLS: サーバー + クライアント |
| 主な用途 | TLS: ブラウザ→Web/API / mTLS: サービス→サービス、B2B API、ゼロトラスト |
| クライアント側 | TLS: ブラウザ(OSのルートCA) / mTLS: ワークロード(内部CA・SPIFFE) |
| 失敗時の挙動 | TLS: 警告画面 / mTLS: 接続拒否(401/403ではなくTLSハンドシェイ失敗) |
| 主体識別 | TLS: ドメイン名 / mTLS: CN・SAN・SPIFFE ID(サービスアカウント単位) |
TLS ハンドシェイの流れ(mTLS)を簡略化すると次のとおりです。
sequenceDiagram
participant C as Client Pod
participant S as Server Pod
C->>S: ClientHello
S->>C: ServerHello + サーバー証明書
C->>C: サーバー証明書をCAで検証
S->>C: CertificateRequest(クライアント証明書を要求)
C->>S: クライアント証明書 + 署名
S->>S: クライアント証明書を内部CAで検証
C->>S: Finished(暗号化通信開始)
mTLS 検証に失敗すると TCP/TLS 層で接続が切れる ため、Express の req すら届きません。openssl s_client、Envoy/Istio のアクセスログ、nginx の error.log(SSL_verify 関連)で切り分けます。
TLS 1.3 と mTLS の関係
マイクロサービス間通信では TLS 1.2 以上(実務では TLS 1.3)が推奨です。TLS 1.3 はハンドシェイが短く、暗号スイートが AEAD に統一されています。ただし 0-RTT(early data) はリプレイ攻撃の受け口になり得るため、内部 API でも非冪等な POST/PUT を early data で受け付けない設計が必要です。詳細は TLS 1.3 0-RTT(Early Data)のリスク を参照してください。
| 設定項目 | 推奨(mTLS east-west) | 理由 |
|---|---|---|
| min TLS version | TLS 1.2 以上(可能なら 1.3) | 弱い暗号の排除 |
| 0-RTT / early data | 原則 無効 | リプレイ耐性 |
| 証明書寿命 | 24h〜90d + 自動更新 | 侵害時の影響窓縮小 |
| CRL/OCSP | 大規模なら検討 | 緊急失効の伝播 |
ゼロトラストとマイクロサービス
ゼロトラスト(Zero Trust) の原則は「ネットワークの内側=安全」という暗黙の信頼を捨て、すべての通信主体を明示的に認証・認可する ことです。Google の BeyondCorp や NIST SP 800-207 が参照モデルとして知られています。
マイクロサービスアーキテクチャでは次の理由で mTLS が実務的に重要になります。
| リスク | mTLSなしの典型 | mTLSによる緩和 |
|---|---|---|
| Pod侵害後の横展開 | 侵害Podから同一クラスタ内のDB APIへ平文HTTP | 許可証明書を持たないPodからの接続をTLS層で拒否 |
| なりすまし | curl http://payment-svc:8080 で内部API直呼び | クライアント証明書なしではハンドシェイ不可 |
| 通信の盗聴・改ざん | クラスタ内スニッフィング | TLS 1.2/1.3 による暗号化 |
| 監査・トレーサビリティ | IPだけでは「どのサービスか」不明確 | 証明書の CN/SAN/SPIFFE ID で主体を識別 |
アプリケーション層の JWT や OAuth2 は依然として必要です。mTLS は 「そのリクエストが許可されたサービスから来ているか」 を Transport 層で保証し、JWT は 「その操作をユーザー/サービスアカウントが実行してよいか」 をアプリ層で判断します——補完関係 にあり、JWT だけでは「侵害された別サービスからの内部呼び出し」を防ぎきれません。
導入ロードマップ
Phase 1: ステージングで OpenSSL 内部CA + Node.js requestCert による PoC を構築し、openssl s_client で相互接続を検証する
Phase 2: cert-manager または SPIRE で短期証明書の自動発行・ローテーションを導入し、overlap 期間と監視アラートを設定する
Phase 3: Envoy サイドカーまたは Istio/Linkerd で STRICT mTLS を有効化し、アプリは平文 HTTP のまま透過的に保護する
Phase 4: AuthorizationPolicy / SPIRE Entry 等で SPIFFE ID 単位の L7 認可を追加し、NetworkPolicy と組み合わせる
Phase 5: 失効ドリル・ローテーション障害シミュレーション・TLS 1.3 0-RTT 方針の定期レビューを運用 Runbook に組み込む
SPIFFE / SPIRE 概要
手動 OpenSSL CA は学習に最適ですが、本番の数十〜数百 Pod では SPIFFE(Secure Production Identity Framework for Everyone) とその参照実装 SPIRE が de facto 標準に近づいています。
SPIFFE ID とは
SPIFFE ID はワークロード(Pod・VM・プロセス)に付与される URI 形式の一意識別子 です。
spiffe://trust-domain.example/ns/production/sa/orders-api
└─ Trust Domain ─┘ └─ パス(K8sなら ns + SA) ─┘
X.509 証明書の Subject Alternative Name (SAN) に URI:spiffe://... が入り、Envoy・Istio・Linkerd はこの ID を principal として AuthorizationPolicy に使います。
SPIRE のアーキテクチャ
| コンポーネント | 役割 |
|---|---|
| SPIRE Server | Trust Domain の CA。エントリ登録・証明書署名 |
| SPIRE Agent | 各ノードに1つ。ワークロードに SVID(SPIFFE Verifiable Identity Document)を配布 |
| Workload API | Unix socket 経由で短期証明書・秘密鍵を Pod に提供 |
| Registration Entry | 「この Selector(K8s SA 等)のワークロードにこの SPIFFE ID を付与」を定義 |
flowchart TB
subgraph control [コントロールプレーン]
SS[SPIRE Server]
SE[Registration Entries]
end
subgraph node [Kubernetes Node]
SA[SPIRE Agent]
P1[Pod: orders-api]
P2[Pod: checkout-bff]
end
SE --> SS
SS --> SA
SA -->|Workload API| P1
SA -->|Workload API| P2
P1 -->|mTLS SVID| P2
SPIRE Registration Entry 例
# spire-entries/orders-api.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: spire-entry-orders-api
namespace: spire
data:
entry.hcl: |
entry {
spiffe_id = "spiffe://prod.example/ns/production/sa/orders-api"
parent_id = "spiffe://prod.example/spire/agent/k8s_psat/production/node-1"
selectors {
type = "k8s"
value = "sa:production:orders-api"
}
ttl = "3600"
}
Pod 内のアプリは Workload API から証明書を取得します。Node.js では @peculiar/x509 や SPIRE の公式 SDK、Envoy では SDS(Secret Discovery Service) 経由が一般的です。
Istio / Linkerd との関係
Istio は内部で istiod が各ワークロードに証明書を配布し、SPIFFE 互換の spiffe://cluster.local/ns/{ns}/sa/{sa} 形式の ID を使います。Linkerd も同様に自動 mTLS を提供します。SPIRE を直接使うか、メッシュに任せるかは 既存メッシュの有無 と 非 K8s ワークロード(VM・バッチ)の混在 で決めます。
Trust Domain を prod.example と staging.example で分離し、本番 CA がステージングに流用されないよう ルート CA も分離 してください。マルチクラスタでは フェデレーション で Trust Bundle を交換する設計になります。
サービスメッシュの基礎
サービスメッシュ(Service Mesh) は、各 Pod に サイドカープロキシ(Envoy 等) を注入し、サービス間通信の mTLS・リトライ・メトリクスを データプレーン で透過的に処理するアーキテクチャです。
コントロールプレーンとデータプレーン
| 層 | 役割 | 代表例 |
|---|---|---|
| コントロールプレーン | 証明書発行、ルーティング設定、ポリシー配布 | istiod、Linkerd control plane |
| データプレーン | 実際のプロキシ。TLS 終端、mTLS、L7 ルーティング | Envoy、Linkerd-proxy |
アプリは http://orders-api:8080 のように 平文 HTTP で呼び出してよく、サイドカーが outbound を mTLS にアップグレードします。これにより アプリコードを変えず east-west 暗号化が可能です。
主要メッシュ製品の mTLS 比較
| 製品 | mTLS の特徴 |
|---|---|
| Istio | PeerAuthentication STRICT。istiod が自動証明書。AuthorizationPolicy で SPIFFE principal 認可 |
| Linkerd | デフォルト mTLS オン。軽量プロキシ。設定がシンプル |
| Consul Connect | Consul CA + Envoy サイドカー。マルチクラウド VM 向け |
| Cilium | eBPF ベース。WireGuard 暗号化 + mTLS オプション |
メッシュなし vs メッシュあり
| 観点 | ネイティブ mTLS(アプリ/Envoy 直) | サービスメッシュ |
|---|---|---|
| 導入コスト | 低(サービス数が少ない場合) | 中〜高(CRD・サイドカー注入) |
| ローテーション | 自前(cert-manager + reloder 等) | 自動(istiod / linkerd) |
| 可観測性 | 自前メトリクス | 組み込み(Golden metrics) |
| L7 認可 | アプリ or ゲートウェイで実装 | AuthorizationPolicy 等 |
| 向いている規模 | PoC、数サービス | 数十〜数百サービス |
証明書設計:内部CAと属性
本番の mTLS では 自社内部CA または SPIFFE/SPIRE によるワークロードIDが標準です。
X.509 証明書に含めるべき属性
- Subject Alternative Name (SAN) —
DNS:orders-api.internal,URI:spiffe://prod.example/ns/production/sa/orders-api - Common Name (CN) — レガシー互換。SAN を優先し、CN もサービス名に揃える
- 有効期間 — 内部CAは 90日以下 の短期 + 自動ローテーションが推奨
- Key Usage / Extended Key Usage — サーバー認証
serverAuth、クライアント認証clientAuth
証明書ローテーション — 設計と運用
証明書ローテーションは mTLS 運用で 最も障害を起こしやすい 領域です。計画的な overlap と監視なしに短期化すると、更新漏れで全サービス間通信が止まります。
ローテーション戦略
| 方式 | 概要 | 向いているケース |
|---|---|---|
| 固定期限 + 手動更新 | 825日証明書を期限前に差し替え | PoC のみ |
| 短期 + 自動更新 | cert-manager / SPIRE / Istio が 24h〜90d で更新 | 本番標準 |
| ダブル CA overlap | 新旧 CA を並行信頼し、全クライアント更新後に旧 CA 廃止 | CA ルート更新時 |
| 緊急失効 | 侵害 SA の Entry 削除、CRL/OCSP、証明書 Secret 削除 | インシデント対応 |
overlap タイムライン(90日証明書の例)
Day 0 ── 新証明書発行(有効90日)
Day 75 ── renewBefore: 15日前に次期証明書を先行発行
Day 75-90 ── 新旧両方有効(クライアント・サーバーがどちらでも接続可)
Day 90 ── 旧証明書失効(更新漏れPodは接続失敗 → アラート)
cert-manager による自動ローテーション
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca-issuer
spec:
ca:
secretName: internal-ca-key-pair
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: orders-api-tls
namespace: production
spec:
secretName: orders-api-tls
duration: 2160h
renewBefore: 360h
commonName: orders-api
dnsNames:
- orders-api
- orders-api.production.svc.cluster.local
uris:
- spiffe://prod.example/ns/production/sa/orders-api
usages:
- server auth
- client auth
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
Pod では Secret を volumeMount します。Node.js / nginx は SIGHUP または reloder で再読込が必要な場合があります。Envoy SDS なら ホットリロード が可能です。
監視すべきメトリクス
- TLS handshake failures — Envoy
tls.inspection、nginxssl_verifyエラー - 証明書期限 —
x509_cert_not_afterPrometheus exporter、Certificate Ready=False - ローテーション成功率 — cert-manager CertificateRenewal イベント
- クライアント SPIFFE ID 別 RPS — 想定外の主体からの試行
ローテーション障害ドリル
本番前に必ず実施するチェックリストです。
- 意図的に renewBefore を短縮 し、ステージングで自動更新を観察
- 旧 Secret を削除 し、Pod が新証明書で再起動・再接続できるか確認
- CA 証明書をローテーション し、trust bundle 配布の lag を測定
- 1 サービスのみ更新停止 し、アラートが鳴るか検証
OpenSSL:CA・サーバー・クライアント証明書の生成
以下は ローカル開発・ステージング用 の完全な手順です。本番では HSM・Vault PKI・cert-manager 等で鍵を保護してください。
ディレクトリ構成
mkdir -p mtls-demo/{ca,server,client,configs}
cd mtls-demo
1. 内部CA(ルート)の作成
cat > configs/ca-openssl.cnf << 'EOF'
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
distinguished_name = dn
x509_extensions = v3_ca
[ dn ]
CN = MyOrg Internal mTLS Root CA
O = MyOrg
C = JP
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:1
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
EOF
openssl genrsa -out ca/ca.key.pem 4096
chmod 400 ca/ca.key.pem
openssl req -x509 -new -nodes \
-key ca/ca.key.pem \
-days 3650 \
-out ca/ca.cert.pem \
-config configs/ca-openssl.cnf
openssl x509 -in ca/ca.cert.pem -text -noout | head -20
2. サーバー証明書(orders-api 用)
cat > configs/server-openssl.cnf << 'EOF'
[ req ]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[ dn ]
CN = orders-api
O = MyOrg
C = JP
[ req_ext ]
subjectAltName = @alt_names
extendedKeyUsage = serverAuth, clientAuth
[ alt_names ]
DNS.1 = orders-api
DNS.2 = orders-api.internal
DNS.3 = localhost
URI.1 = spiffe://dev.example/ns/default/sa/orders-api
IP.1 = 127.0.0.1
[ v3_server ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
EOF
openssl genrsa -out server/orders-api.key.pem 2048
openssl req -new \
-key server/orders-api.key.pem \
-out server/orders-api.csr.pem \
-config configs/server-openssl.cnf
openssl x509 -req \
-in server/orders-api.csr.pem \
-CA ca/ca.cert.pem \
-CAkey ca/ca.key.pem \
-CAcreateserial \
-out server/orders-api.cert.pem \
-days 90 \
-extfile configs/server-openssl.cnf \
-extensions v3_server
cat server/orders-api.cert.pem ca/ca.cert.pem > server/orders-api.chain.pem
3. クライアント証明書(checkout-bff 用)
cat > configs/client-openssl.cnf << 'EOF'
[ req ]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[ dn ]
CN = checkout-bff
O = MyOrg
C = JP
[ req_ext ]
subjectAltName = @alt_names
extendedKeyUsage = clientAuth
[ alt_names ]
URI.1 = spiffe://dev.example/ns/default/sa/checkout-bff
[ v3_client ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = @alt_names
EOF
openssl genrsa -out client/checkout-bff.key.pem 2048
openssl req -new \
-key client/checkout-bff.key.pem \
-out client/checkout-bff.csr.pem \
-config configs/client-openssl.cnf
openssl x509 -req \
-in client/checkout-bff.csr.pem \
-CA ca/ca.cert.pem \
-CAkey ca/ca.key.pem \
-CAcreateserial \
-out client/checkout-bff.cert.pem \
-days 90 \
-extfile configs/client-openssl.cnf \
-extensions v3_client
4. 接続テスト(openssl s_client)
openssl s_client -connect localhost:8443 \
-CAfile ca/ca.cert.pem \
-cert client/checkout-bff.cert.pem \
-key client/checkout-bff.key.pem \
-servername orders-api \
-verify_return_error
openssl s_client -connect localhost:8443 \
-CAfile ca/ca.cert.pem \
-servername orders-api
内部CAの ca.cert.pem を開発者PCの「信頼されたルート証明書」に入れるのは 開発用の例外 です。本番ユーザー向けHTTPSは公開CA(Let’s Encrypt等)を使い、内部mTLS用CAとは 鍵もファイルも分離 してください。
Node.js:https.createServer でクライアント証明書検証
Node.js で mTLS を終端する最小構成です。許可されたクライアントCNのallowlist まで含めた完全例を示します。
server.mjs(orders-api)
import https from 'node:https';
import fs from 'node:fs';
const PORT = 8443;
const ALLOWED_CLIENT_CN = new Set(['checkout-bff', 'admin-gateway']);
const ALLOWED_SPIFFE = new Set([
'spiffe://dev.example/ns/default/sa/checkout-bff',
'spiffe://dev.example/ns/default/sa/admin-gateway',
]);
const tlsOptions = {
key: fs.readFileSync('server/orders-api.key.pem'),
cert: fs.readFileSync('server/orders-api.cert.pem'),
requestCert: true,
rejectUnauthorized: true,
ca: fs.readFileSync('ca/ca.cert.pem'),
minVersion: 'TLSv1.2',
};
function extractSpiffeUri(peerCert) {
const san = peerCert.subjectaltname || '';
const match = san.match(/URI:spiffe:\/\/[^\s,]+/);
return match ? match[0].replace('URI:', '') : null;
}
const server = https.createServer(tlsOptions, (req, res) => {
const socket = req.socket;
const authorized = socket.authorized;
const peerCert = socket.getPeerCertificate();
if (!authorized) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'client certificate required or invalid',
authorizationError: socket.authorizationError,
}));
return;
}
const clientCn = peerCert?.subject?.CN;
const spiffeUri = extractSpiffeUri(peerCert);
const cnOk = ALLOWED_CLIENT_CN.has(clientCn);
const spiffeOk = spiffeUri && ALLOWED_SPIFFE.has(spiffeUri);
if (!cnOk && !spiffeOk) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'client identity not allowed',
cn: clientCn,
spiffe: spiffeUri,
}));
return;
}
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', client: clientCn, spiffe: spiffeUri }));
return;
}
if (req.method === 'GET' && req.url?.startsWith('/orders/')) {
const id = req.url.split('/').pop();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ orderId: id, servedBy: 'orders-api', caller: spiffeUri || clientCn }));
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(PORT, () => {
console.log(`orders-api mTLS listening on https://localhost:${PORT}`);
});
client.mjs(checkout-bff からの呼び出し)
import https from 'node:https';
import fs from 'node:fs';
const options = {
hostname: 'localhost',
port: 8443,
path: '/orders/ord_123',
method: 'GET',
servername: 'orders-api',
key: fs.readFileSync('client/checkout-bff.key.pem'),
cert: fs.readFileSync('client/checkout-bff.cert.pem'),
ca: fs.readFileSync('ca/ca.cert.pem'),
rejectUnauthorized: true,
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
console.log('status', res.statusCode);
console.log('body', body);
});
});
req.on('error', (err) => console.error('request failed', err.message));
req.end();
node server.mjs
node client.mjs
Express では https.createServer(options, app) とします。req.socket.getPeerCertificate() は TLS終端後 にのみ有効 — リバースプロキシ後段の平文HTTPでは X-Forwarded-Client-Cert 等のヘッダー設計が別途必要です。
Envoy:Downstream / Upstream mTLS 設定
Envoy は Istio・Linkerd のデータプレーンとしても使われ、スタンドアロン でもサイドカー・ゲートウェイとして mTLS を終端できます。
Downstream mTLS(クライアント証明書を要求)
# envoy-downstream-mtls.yaml
static_resources:
listeners:
- name: orders_api_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8443
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_certificates:
- certificate_chain:
filename: /etc/envoy/certs/server/orders-api.chain.pem
private_key:
filename: /etc/envoy/certs/server/orders-api.key.pem
validation_context:
trusted_ca:
filename: /etc/envoy/certs/ca/ca.cert.pem
match_typed_subject_alt_names:
- san_type: URI
matcher:
exact: spiffe://dev.example/ns/default/sa/checkout-bff
- san_type: URI
matcher:
exact: spiffe://dev.example/ns/default/sa/admin-gateway
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: orders_api
route_config:
name: local_route
virtual_hosts:
- name: orders_api
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: orders_api_local }
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: orders_api_local
type: STATIC
load_assignment:
cluster_name: orders_api_local
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 3000
require_client_certificate: true で mTLS を強制し、match_typed_subject_alt_names で SPIFFE URI allowlist を設定しています。
Upstream mTLS(Envoy がクライアント証明書を提示)
# envoy-upstream-mtls.yaml(cluster 部分)
clusters:
- name: payment_svc_mtls
type: STRICT_DNS
load_assignment:
cluster_name: payment_svc_mtls
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: payment-svc.internal
port_value: 8443
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
sni: payment-svc.internal
common_tls_context:
tls_certificates:
- certificate_chain:
filename: /etc/envoy/certs/client/checkout-bff.cert.pem
private_key:
filename: /etc/envoy/certs/client/checkout-bff.key.pem
validation_context:
trusted_ca:
filename: /etc/envoy/certs/ca/ca.cert.pem
match_subject_alt_names:
- exact: payment-svc.internal
SDS による証明書ホットリロード
本番ではファイルパス直指定より SDS で SPIRE / cert-manager Secret から動的取得する構成が一般的です。
common_tls_context:
tls_certificate_sds_secret_configs:
- name: orders_api_cert
sds_config:
api_config_source:
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: sds_cluster
validation_context_sds_secret_config:
name: client_ca
sds_config:
api_config_source:
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: sds_cluster
証明書更新時に Envoy プロセスを再起動せずに 新 SVID を反映 できるため、ローテーション downtime を最小化できます。
admin インターフェースでのデバッグ
curl -s localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Secret"))'
curl -s localhost:9901/stats | grep ssl
nginx:proxy_ssl_verify による mTLS
nginx を エッジまたはサービス間ゲートウェイ として使い、inbound / outbound 両方で mTLS を設定する例です。
パターンA:inbound mTLS(クライアント証明書を要求)
upstream orders_api_upstream {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 8443 ssl;
server_name orders-api.internal;
ssl_certificate /etc/nginx/mtls/server/orders-api.chain.pem;
ssl_certificate_key /etc/nginx/mtls/server/orders-api.key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_client_certificate /etc/nginx/mtls/ca/ca.cert.pem;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
proxy_pass http://orders_api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
}
}
パターンB:outbound mTLS(上流へクライアント証明書を提示)
upstream payment_svc_mtls {
server payment-svc.internal:8443;
keepalive 16;
}
server {
listen 443 ssl;
server_name checkout.example.com;
ssl_certificate /etc/letsencrypt/live/checkout.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/checkout.example.com/privkey.pem;
location /api/pay {
proxy_pass https://payment_svc_mtls;
proxy_http_version 1.1;
proxy_set_header Host payment-svc.internal;
proxy_ssl on;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_server_name on;
proxy_ssl_name payment-svc.internal;
proxy_ssl_certificate /etc/nginx/mtls/client/checkout-bff.cert.pem;
proxy_ssl_certificate_key /etc/nginx/mtls/client/checkout-bff.key.pem;
proxy_ssl_trusted_certificate /etc/nginx/mtls/ca/ca.cert.pem;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
}
}
Istio:STRICT mTLS と AuthorizationPolicy
Istio を使う場合、アプリコードを変えず PeerAuthentication で STRICT にできます。
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-api-allow
namespace: production
spec:
selector:
matchLabels:
app: orders-api
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/production/sa/checkout-bff"
to:
- operation:
methods: ["GET", "POST"]
paths: ["/orders/*"]
principals は mTLS で交換された SPIFFE ID に基づきます。PERMISSIVE モードから STRICT への移行は、全サイドカー注入完了後 に行い、段階的に namespace 単位で切り替えます。
PERMISSIVE から STRICT への移行
全 Pod にサイドカーが注入されていることを istioctl proxy-status で確認する
ステージング namespace で PeerAuthentication STRICT を適用し、e2e テストを実行する
本番は namespace ごとに STRICT をロールアウトし、TLS handshake failure メトリクスを監視する
レガシー平文呼び出しが残るサービスを特定し、サイドカー注入またはネイティブ mTLS 化する
全 namespace STRICT 完了後、PERMISSIVE 設定を削除し、NetworkPolicy で平文ポートを閉じる
障害切り分けとデバッグ
よくあるエラーと対処
| 症状 | 原因 | 対処 |
|---|---|---|
unknown ca | クライアントが異なるCAで署名された証明書 | CA 配布の統一、trust bundle 確認 |
certificate has expired | ローテーション漏れ | renewBefore アラート、自動更新 |
hostname mismatch | SAN に接続先DNSがない | CSR の subjectAltName 修正 |
connection reset | rejectUnauthorized: true で検証失敗 | s_client で -verify_return_error 再現 |
nginx 502 | 上流 proxy_ssl_verify 失敗 | error.log の upstream SSL 行を確認 |
Envoy 403 / TLS alert | SPIFFE SAN allowlist 不一致 | config_dump で validation_context 確認 |
curl・OpenSSL デバッグチートシート
openssl s_client -connect orders-api.internal:8443 -servername orders-api </dev/null 2>/dev/null \
| openssl x509 -noout -subject -dates -ext subjectAltName
curl -v --cacert ca/ca.cert.pem \
--cert client/checkout-bff.cert.pem \
--key client/checkout-bff.key.pem \
https://localhost:8443/orders/ord_123
curl -v --cacert ca/ca.cert.pem https://localhost:8443/health
openssl x509 -noout -modulus -in client/checkout-bff.cert.pem | openssl md5
openssl rsa -noout -modulus -in client/checkout-bff.key.pem | openssl md5
ゼロトラスト多層防御:mTLS と HTTP セキュリティ
mTLS は サービス間・B2B API の Transport 層を守ります。エンドユーザー向けブラウザ には別の防御層が必要です。
- CSP / HSTS / X-Frame-Options — ブラウザが解釈するレスポンスヘッダー。セキュリティヘッダー一覧と設定ガイドで nginx 一括設定と Report-Only 段階導入を解説
- mTLS —
checkout-bff→orders-apiの east-west 通信。Pod 侵害時の横展開を抑止 - JWT / OAuth2 — ユーザー操作の認可。サービスアカウントトークン(Workload Identity)
- TLS 1.3 0-RTT — 性能とリプレイリスクのトレードオフ。0-RTT リスクガイドで無効化判断
flowchart LR
subgraph browser [ブラウザ]
U[ユーザー]
end
subgraph edge [エッジ]
GW[API Gateway / nginx]
end
subgraph mesh [Kubernetes クラスタ]
BFF[checkout-bff]
ORD[orders-api]
PAY[payment-svc]
end
U -->|HTTPS + セキュリティヘッダー| GW
GW --> BFF
BFF -->|mTLS SPIFFE| ORD
BFF -->|mTLS SPIFFE| PAY
外部は HTTPS + セキュリティヘッダー、内部は mTLS — 層を混同せず、それぞれのチェックリストで棚卸しするのが実務的です。
セキュリティ上の注意
- 秘密鍵を Git にコミットしない —
.gitignoreに*.key.pem、本番は Secret Manager / KMS rejectUnauthorized: falseを本番で使わない — 中間者攻撃を許容するssl_verify_client optionalは避ける — 証明書なし接続が残る- CN allowlist だけに頼らない — SPIFFE ID / AuthorizationPolicy 等、より強い主体識別を検討
- 平文 HTTP をクラスタ内に残さない — メッシュ STRICT または NetworkPolicy + mTLS でカバー
- 0-RTT を安易に有効化しない — 内部 API でもリプレイリスクを評価する
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
まとめ
| 項目 | 推奨 |
|---|---|
| 小規模/PoC | OpenSSL で CA 発行 → Node.js requestCert / Envoy require_client_certificate |
| 中〜大規模 | SPIRE または Istio/Linkerd STRICT mTLS + AuthorizationPolicy |
| 証明書寿命 | 90日以下 + 自動ローテーション + overlap |
| 主体識別 | SPIFFE ID(URI SAN)を CN より優先 |
| ブラウザ向け | セキュリティヘッダー設定(CSP・HSTS 等) |
| TLS 1.3 設定 | 0-RTT リスク評価 |
| 検証 | openssl s_client、curl --cert、ローテーション障害ドリル |
mTLS は「ネットワークの内側は安全」という前提を捨て、すべてのサービス通信を明示的に認証する ゼロトラストの実装パターンです。「mTLS マイクロサービス」「相互TLS 設定」で求められる OpenSSL コマンド、Node.js https.createServer、Envoy downstream/upstream TLS、nginx proxy_ssl_verify の完全な設定例を本記事で揃えました。まずステージングで CA から手動構築し、運用負荷が見えた段階で SPIRE・cert-manager・サービスメッシュへ移行するのが現実的なロードマップです。