↓メインコンテンツへスキップ
  1. Blogs/

Peering越しのPod→VM通信が落ちる原因を、ルートだと思い込んでいた話

4 分
Terraform GoogleCloud GKE VPC
0222-nnn
著者
0222-nnn
猫が好き
目次
Terraform-GoogleCloud - この記事は連載の一部です
パート 30: この記事

概要
#

複数の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で確認できた
  • 観測が正しくても、原因の説明は別に確かめる必要がある。 今回は最初の説明が丸ごと違った

参考資料
#

次回
#

GKEのスタンドアロンNEGを消したとき、LBのバックエンドがどうなるかを確認します。

Terraform-GoogleCloud - この記事は連載の一部です
パート 30: この記事

関連記事

TerraformでGKE Dataplane V2を有効化し、NetworkPolicyでPod間通信を遮断してみた
2 分
Terraform GoogleCloud GKE NetworkPolicy
TerraformでGKEのノードあたり最大Pod数を小さくしすぎるとどうなるか試してみた
2 分
Terraform GoogleCloud GKE
TerraformでGKEのPodをInternal HTTP LB(NEG)でポート別に振り分けてみた
3 分
Terraform GoogleCloud GKE LoadBalancing
TerraformでGKEのコントロールプレーンをプライベート+パブリックの両方から接続してみた
2 分
Terraform GoogleCloud GKE
TerraformでGKEプライベートクラスタからMemorystore for Redis Clusterへ接続してみた
3 分
Terraform GoogleCloud GKE Redis
TerraformでGKEプライベートクラスタからMemorystore for Redisへ接続してみた
2 分
Terraform GoogleCloud GKE Redis