概要 #
複数のGoogle Cloud Projectをまたぐ構成では、「別チームのVMへ自分のPodから届かない」という壁にぶつかります。
相手側の設定を変えられない、あるいは変えたくない場面もあります。自分側だけで解決したいところです。
この記事は、いったん公開した内容を実測で覆したものです。
当初は「routes-basedなクラスタでは Pod のルートが交換されないから失敗する」と説明していました。確かめたら、どちらも違いました。
| 当初の説明 | 実測 |
|---|---|
| routes-based なクラスタ | useIpAliases: true で VPC-native |
| Pod CIDR 宛のルートは交換されない | peering-route-... として交換されていた |
| 失敗の原因はルートの非交換 | 宛先側のファイアウォール |
結果そのものは変わりません。宛先側を触らず ConfigMap だけで復旧できます。変わったのは、なぜ直るのかの説明です。
Terraformの基本操作は前提としています。Dataplane V2 などのハードニング設定は前回のNetworkPolicy記事で扱っています。
検証すること #
- 2プロジェクト構成で、PodからPeering先のVMへのcurlが失敗すること(Step 1: 再現)
- 宛先側の設定を変えず、ip-masq-agentのConfigMapだけで成功すること(Step 2: 解決)
- クラスタは routes-based なのか VPC-native なのか
- Pod CIDR 宛のルートは Peering で交換されているのか
- 失敗の原因はルートなのかファイアウォールなのか(対照実験)
前提環境 #
- Google Cloud CLI、Terraform、Docker
- Billingが有効な検証用Google Cloud Project 2つ
- GKE、Compute Engine、VPC Peeringを操作できるGoogleアカウント
- 検証時のバージョン:Terraform 1.14.3、google 7.46.0
GKE・ノードイメージ・ip-masq-agent のバージョンは記録していません。ip-masq-agent が既定で入るかどうかも、既定の nonMasqueradeCIDRs も、このあたりで変わります。再現するなら次を控えておきます。
gcloud container clusters describe CLUSTER --zone ZONE \
--format="value(currentMasterVersion,currentNodeVersion,nodeConfig.imageType)"
kubectl -n kube-system get daemonset ip-masq-agent \
-o jsonpath='{.spec.template.spec.containers[0].image}'
クラスタは routes-based ではなかった #
当初はこう書いていました。
ip_allocation_policyにセカンダリレンジ名ではなくCIDRを直接指定すると、routes-basedなクラスタになります。
provider の資料は違います。
Defaults to VPC_NATIVE for new clusters.
provider 5.0.0 以降、新規クラスタは既定で VPC-native です。 ip_allocation_policy を書かないだけでは routes-based になりません。routes-based にするなら networking_mode = "ROUTES" を明示します。
実際に確かめました。
gcloud container clusters describe tf-adv-gke-ipmasq --zone=asia-northeast1-a \
--format="yaml(ipAllocationPolicy)"
clusterIpv4CidrBlock: 172.16.0.0/16
clusterSecondaryRangeName: gke-tf-adv-gke-ipmasq-pods-53263778
servicesSecondaryRangeName: gke-tf-adv-gke-ipmasq-services-53263778
useIpAliases: true
useIpAliases: true で VPC-native です。GKE がセカンダリレンジを作っています。Subnet 側にも出ます。
gcloud compute networks subnets list --filter="network~'ipmasq'" \
--format="table(name,ipCidrRange,secondaryIpRanges[].rangeName.list())"
NAME RANGE RANGE_NAME
tf-adv-gke-ipmasq-a-vpc-gke-subnet 10.10.0.0/28 gke-...-pods-53263778,gke-...-services-53263778
Pod CIDR 宛のルートは交換されていた #
peering_custom_routes = false のままです。それでも Project B は Pod CIDR 宛のルートを持っていました。
gcloud compute routes list --project=<Project B> --filter="destRange~'^172\.16\.'"
NAME DEST_RANGE NEXT_HOP_PEERING
peering-route-c9f8152e209b7b79 172.16.0.0/16 tf-adv-gke-ipmasq-b-vpc-peer-to-a
VPC-native では Pod IP がサブネットのセカンダリレンジから出ます。 このプライベート IPv4 のサブネットルートは、Peering が有効ならカスタムルートの交換設定に関わらず交換されます。
ただしルートがあることと通信が通ることは別です。ファイアウォールの許可も要ります。
「Project B は Pod CIDR 宛のルートを一切知らない」という当初の説明は誤りでした。
失敗の原因はファイアウォールだった #
では何が落としていたのか。宛先側のファイアウォールを見ます。
gcloud compute firewall-rules list --project=<Project B> --filter="network~'ipmasq'" \
--format="table(name,sourceRanges.list(),allowed[].map().firewall_rule().list())"
NAME SOURCE_RANGES ALLOW
tf-adv-gke-ipmasq-b-vpc-allow-http-from-a-nodes 10.10.0.0/28 tcp:80
許可しているのはノードのSubnet(10.10.0.0/28)だけで、Pod CIDR(172.16.0.0/16)が入っていません。
対照実験をしました。ip-masq-agent の DaemonSet は動いていますが、ConfigMap は未作成です。この状態では既定の非マスカレード先が使われ、宛先 10.20.0.10 は 10.0.0.0/8 に含まれるので送信元は Pod IP のままです。
ルートは一切変更していません。変えたのはファイアウォールの source_ranges だけです。
Pod IP = 172.16.0.13
ConfigMap ip-masq-agent = NotFound(SNAT なし)
source_ranges curl http://10.20.0.10/
------------------------------------------------------------
10.10.0.0/28 exit 28(タイムアウト)
10.10.0.0/28,172.16.0.0/16 http_code=200 ×3
10.10.0.0/28(戻す) exit 28 ×2
ファイアウォールだけで結果が反転しました。落としていたのはルートではありません。
なぜip-masq-agentで直るのか #
今回のクラスタには、ip-masq-agentのDaemonSetが既にインストールされていました。ConfigMapを何も作らなくても動作しています。
ただし常に入るわけではありません。 Standardクラスタでは条件があります。--disable-default-snat が付いておらず、かつ次のどちらかを満たす場合です。
- Dataplane V2 を使わず、NetworkPolicy が有効
- Pod の IP レンジが
10.0.0.0/8に収まらない
今回は Pod CIDR が 172.16.0.0/16 なので、後者に当たります。Autopilot では常に入ります。
既定では、宛先が RFC 1918 を含む予約範囲であれば送信元を Pod IP のまま送り出します。既定の非マスカレード先は 10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16 などです。
カスタムのnonMasqueradeCIDRsは既定のリストを置き換えます。 今回のConfigMapが並べるのは Pod・Services・GKE Subnet の3範囲だけです。
nonMasqueradeCIDRs:
- __POD_CIDR__
- __SERVICES_CIDR__
- __GKE_SUBNET_CIDR__
masqLinkLocal: true
つまり宛先VMのSubnetを含め、このリストに無い宛先はすべてSNATの対象になります。既定では非マスカレードだった他の予約範囲も同じです。masqLinkLocal: trueによってリンクローカル宛の扱いも変わります。
SNAT後の送信元はNode IPです。宛先側のファイアウォールが許可している 10.10.0.0/28 に入るので、通るようになります。
つまり ip-masq-agent は経路を直しているのではなく、送信元アドレスを許可済みの範囲に変えているだけです。
宛先側(Project B)の設定は一切変更していません。 これは変わりません。
今回の構成 #
flowchart TB
subgraph ProjectA["Project A"]
subgraph VPCA["VPC A"]
subgraph GkeSubnet["GKE Subnet 10.10.0.0/28"]
Pod["Pod(curlpod)
172.16.0.x(サブネットのセカンダリレンジ)"]
end
Bastion["踏み台"]
end
end
subgraph ProjectB["Project B(設定変更なし)"]
subgraph VPCB["VPC B"]
subgraph TargetSubnet["宛先VM Subnet 10.20.0.0/24"]
VM["宛先VM(nginx)"]
end
end
end
VPCA -.->|"VPC Peering
Subnetルートは交換される"| VPCB
Pod -->|"Step 1: 失敗
Step 2: Node IPへSNATして成功"| VM
Step 1と2でPodからの通信がどう変わるかを示します。
sequenceDiagram
participant Pod as Pod(curlpod、172.16.0.x)
participant Node as GKEノード(iptablesでSNAT)
participant VM as 宛先VM(10.20.0.10)
Note over Node: ip-masq-agentはConfigMapを読み、iptablesのルールを設定する
Note over Pod,VM: Step 1: ConfigMap適用前
Pod->>Node: curl http://10.20.0.10/(送信元: Pod IP)
Node--xVM: 宛先は既定のnonMasqueradeCIDRs(10.0.0.0/8)→SNATなし
Note over VM: ファイアウォールがPod CIDRを許可していない → タイムアウト
Note over Pod,VM: Step 2: ConfigMap適用後
Pod->>Node: curl http://10.20.0.10/(送信元: Pod IP)
Node->>VM: リストに無い宛先→送信元をNode IPにSNAT
VM-->>Node: 200 OK(送信元がNode IPなので許可条件を満たす)
Node-->>Pod: 200 OK
使用するTerraformコード #
設定と実行 #
cd Advanced-Examples/17-gke-routes-based-ip-masq
cp terraform.tfvars.example terraform.tfvars
project_id = "YOUR_PROJECT_A_ID"
target_project_id = "YOUR_PROJECT_B_ID"
iap_member = "user:YOUR_ACCOUNT"
terraform init
terraform fmt -check
terraform validate
terraform plan
terraform apply
2プロジェクトにまたがるため36リソースを作成します。
Apply complete! Resources: 36 added, 0 changed, 0 destroyed.
Peeringは双方向(A→B、B→A)で作成します。同時に作成しようとすると、GCP側の排他制御に引っかかりThere is a peering operation in progress on the local or peer network.で失敗することがあります。depends_onで明示的に順序をつけて回避しました。
Step 1: 失敗を確認する(再現) #
curlpodをデプロイし、宛先VMへcurlします。
gcloud compute scp k8s/curlpod.yaml \
"$(terraform output -raw bastion_name):/tmp/curlpod.yaml" \
--zone="$(terraform output -raw zone)" --tunnel-through-iap \
--project="$(terraform output -raw project_id)"
踏み台に Terraform も state もありません。値はローカルで展開してから渡します。
# ローカルで
CLUSTER=$(terraform output -raw cluster_name)
ZONE=$(terraform output -raw zone)
PROJECT=$(terraform output -raw project_id)
TARGET=$(terraform output -raw target_vm_ip)
# 踏み台の中で実行(上の値を埋めて)
gcloud container clusters get-credentials "$CLUSTER" --zone="$ZONE" --project="$PROJECT"
kubectl apply -f /tmp/curlpod.yaml
kubectl wait --for=condition=ready pod/curlpod --timeout=90s
kubectl exec curlpod -- curl -m 5 -v "http://$TARGET/"
* Trying 10.20.0.10:80...
* Connection timed out after 5003 milliseconds
curl: (28) Connection timed out after 5003 milliseconds
command terminated with exit code 28
宛先VM側でtcpdumpしても、Podからのパケットは拾えませんでした。
sudo timeout 15 tcpdump -ni any tcp port 80
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
11 packets captured
13 packets received by filter
0 packets dropped by kernel
キャプチャできたのはメタデータサーバー(169.254.169.254)宛の通信だけで、GKE側からのパケットは含まれていませんでした。
ただしこの出力は件数の要約です。1パケットずつの内容を載せていないので、読者が同じ判断をたどれません。
観測も timeout 15 の15秒間だけで、その外で届いていないことまでは言えません。確かめるなら -c を付けずに保存し、tcpdump -r で送信元ごとに数えます。
Step 2: ip-masq-agentで解決したことを確認する #
sed -e "s|__POD_CIDR__|$(terraform output -raw pod_cidr)|" \
-e "s|__SERVICES_CIDR__|172.17.0.0/20|" \
-e "s|__GKE_SUBNET_CIDR__|10.10.0.0/28|" \
k8s/ip-masq-config.yaml > /tmp/ip-masq-config.yaml
gcloud compute scp /tmp/ip-masq-config.yaml \
"$(terraform output -raw bastion_name):/tmp/ip-masq-config.yaml" \
--zone="$(terraform output -raw zone)" --tunnel-through-iap \
--project="$(terraform output -raw project_id)"
# 踏み台の中で実行
kubectl apply -f /tmp/ip-masq-config.yaml
kubectl -n kube-system rollout restart ds/ip-masq-agent
kubectl -n kube-system rollout status ds/ip-masq-agent --timeout=60s
同じcurlを再実行します。
# 踏み台の中で実行
kubectl exec curlpod -- curl -m 5 -v "http://$TARGET/"
* Trying 10.20.0.10:80...
* Connected to 10.20.0.10 (10.20.0.10) port 80
> GET / HTTP/1.1
> Host: 10.20.0.10
>
< HTTP/1.1 200 OK
< Server: nginx/1.22.1
<
hello from target-vm
* Connection #0 to host 10.20.0.10 left intact
成功しました。宛先VM側のtcpdumpでは、送信元IPが変わっていることも確認できます。
09:46:38.890551 ens4 In IP 10.10.0.3.58030 > 10.20.0.10.80: Flags [S], ...
送信元は10.10.0.3で、Podの実際のIP(172.16.0.13)ではありません。
10.10.0.3 は GKE Subnet(10.10.0.0/28)の範囲に入るので、Node IP と考えられます。
ただし実際のノードIPと突き合わせてはいません。確かめるなら kubectl get nodes -o wide の INTERNAL-IP と比べます。
この送信元は、宛先側のファイアウォールが許可している 10.10.0.0/28 に入ります。 だから通ります。
後片付け #
terraform destroy
Destroy complete! Resources: 36 destroyed.
2プロジェクトにまたがるGKE・VM・Cloud NATは利用中に料金が発生します。検証後は必ず両プロジェクトのリソースがdestroyされたことを確認します。
まとめ #
- provider 5.0.0 以降、新規クラスタは既定で VPC-native。
ip_allocation_policyを書かないだけでは routes-based にならず、networking_mode = "ROUTES"の明示が要る。実測でuseIpAliases: trueだった - VPC-native の Pod CIDR はサブネットのセカンダリレンジ。 このプライベート IPv4 のサブネットルートは
peering_custom_routes = falseでも交換され、Project B に入っていた。ただしルートがあることと通信が通ることは別 - 今回のクラスタでは ip-masq-agent が各ノードで動いていた。自動配置されるかは Pod IP レンジなどの構成条件による
- カスタムの
nonMasqueradeCIDRsは既定のリストを置き換える。列挙した3範囲以外がすべてSNATの対象になる - 失敗の原因は宛先側のファイアウォールだった。 許可が
10.10.0.0/28だけで Pod CIDR を含まない。ファイアウォールだけを変えた対照実験で結果が反転した - SNAT後の送信元はNode IPになり、既存の許可条件を満たす。宛先側の設定を変えずに済むのはそのため
- 「通信が失敗する」ことと「通信が復旧する」ことの両方を、実際のcurlの成功・失敗と、宛先VM側のtcpdumpで確認できた
- 観測が正しくても、原因の説明は別に確かめる必要がある。 今回は最初の説明が丸ごと違った
参考資料 #
- Google Cloud: Create routes-based clusters
- Google Cloud: IP masquerade agent
- Google Cloud: VPC Network Peering
- Terraform Registry: google_container_cluster
- Terraform Registry: google_compute_network_peering