Calico から GKE Dataplane V2 への移行を考える

Google Cloud の GKE (Google Kubernetes Engine) では、Calico と Dataplane V2 という 2 つのネットワーク制御の仕組みが提供されています。
どちらも Kubernetes NetworkPolicy を利用できるようになっていますが、Calico は iptables を利用してルーティングを制御する一方、Dataplane V2 は Cilium をベースとして eBPF によってルーティングを制御します。
今回は、Calico から GKE Dataplane V2 への移行を考えるにあたって、NetworkPolicy 関連で検証したことの一部をご紹介します。

GKE Dataplane V2 とは

GKE Dataplane V2 (以降、Dataplane V2) は Cilium をベースとした GKE のネットワーク制御の仕組みで、2020年8月に発表されました。※1
kube-proxy や iptables に依存しなくなることでネットワーク制御のパフォーマンス向上が期待できるほか、ネットワークポリシーロギング の機能によって NetworkPolicy の適用結果 (Allow / Deny) をログに出力する機能を備えるなど、従来の Calico CNI に基づくネットワーク制御では得られなかった複数のメリットがあります。
現時点では、Calico を用いた既存のネットワーク制御の仕組みを完全に置き換えるものではない(並行して提供されている)ため、Standard クラスタであればユーザがどちらかを選択して利用することになります(Autopilot では Dataplane V2 が採用されており、これを変更することはできません)。
なお、現時点では既存の GKE Standard クラスタを Calico から Dataplane V2 に移行する手段は提供されていないため、移行する場合は新規にクラスタを作成する必要があります。

Dataplane V2 は Cilium をベースとしているものの、あくまで Google 独自の実装であり、Cilium と完全な互換性があるものではありません。
例えば、Cilium では Kubernetes NetworkPolicy のほかに、拡張された NetworkPolicy である CiliumNetworkPolicy や CiliumClusterwideNetworkPolicy を併用することができますが、現在の GKE (1.21.5-gke.1300 以降) ではこれらの Cilium CRD はサポートされていません。※2

機能によっては Dataplane V2 側に代替実装が用意されている場合もあります。
例えば、L7 の FQDN に基づく NetworkPolicy を定義したい場合、Cilium であれば CiliumNetworkPolicy がその役割を担っていますが、Dataplane V2 の場合は FQDNNetworkPolicy が同様の目的を果たす手段として用意されています(FQDNNetworkPolicy については以前検証したブログ記事がありますので、ご興味があれば こちら も併せてどうぞ)。
なお、両者は YAML の書き方が異なり、機能にも差分があるため、同じものとして扱うことはできません。

※1 余談も余談ですが、Google Cloud には GKE Dataplane V2 と名前の似た Cloud Interconnect Dataplane V2 というものがありますが、これらは単純に第二世代という意味であり、実装に強い関連性を持っているわけではありません。例えば、Cloud Interconnect Dataplane V2 は Cilium や eBPF の技術とは無関係(のはず)です。
※2 "GKE バージョン 1.21.5-gke.1300 以降、GKE Dataplane V2 は CiliumNetworkPolicy または CiliumClusterwideNetworkPolicy CRD API をサポートしていません。" (Dataplane V2 の 制限事項 より)

今回の検証ポイントと背景

Calico から Dataplane V2 に安全に移行するためには、様々な観点で検証が必要になると考えられます。
今回は、Calico と Dataplane V2 の Kubernetes NetworkPolicy に関する機能差分に着目し、Dataplane V2 では無効となる構成を実際に Apply してみた場合の挙動を調べてみました。

GKE のこちらの 公式ドキュメント によると、Dataplane V2 では、NetworkPolicy を定義する際に以下のような制約があることがわかります。
これらの制約に該当する構成は、「無効」になると記載されています。

  • ipBlock.cidr フィールドに Pod または Service の IP アドレスを使用することはできない(代わりに、ラベルを指定して対象のワークロードを特定する必要がある)
  • 空の ports.port フィールドを指定することはできない(ポートを指定する際は必ずプロトコルも指定する必要がある)

Calico からの移行という前提に立つと、大量に存在する NetworkPolicy の中に上記制約に該当するものが混在しており、NetworkPolicy マニフェストの単純な移行を試みて無効な構成を Apply しようとしてしまう、といった可能性は考えられそうです。
上記制約の個々は決して深刻なものではありませんが、これが全てとは限らず、今後 Calico の発展や Dataplane V2 としての実装の都合上などにより他にも Calico との違いが生まれてくる可能性が考えられます。
そうした場合に、構成が「無効」になる、とは具体的にはどういった挙動を示すのでしょうか。
「無効」な構成の NetworkPolicy マニフェストを Apply すると Dataplane V2 はどうなるのか、そもそも Apply できるのか、といった挙動を確認してみようというのが今回の主旨です。

※ ちなみに、上記の制約は Cilium の 公式ドキュメント でも Cilium の Kubernetes NetworkPolicy における Known Issue として紹介されているため、Dataplane V2 独自の制約というわけではなさそうです

実機検証

それでは実際に GKE Standard のクラスタを用意して検証してみましょう。
今回は以下のとおり、Dataplane V2 を有効化した GKE 1.27.3-gke.100 のクラスタを用意しました。

$ gcloud container clusters describe sample-cluster
addonsConfig:
  dnsCacheConfig:
    enabled: true
  networkPolicyConfig:
    disabled: true
  ...
autopilot: {}
clusterIpv4Cidr: 10.32.0.0/14
currentMasterVersion: 1.27.3-gke.100
currentNodeCount: 3
currentNodeVersion: 1.27.3-gke.100
ipAllocationPolicy:
  clusterIpv4Cidr: 10.32.0.0/14
  clusterIpv4CidrBlock: 10.32.0.0/14
  clusterSecondaryRangeName: gke-sample-cluster-pods-099d0671
  servicesIpv4Cidr: 10.36.0.0/20
  servicesIpv4CidrBlock: 10.36.0.0/20
  servicesSecondaryRangeName: gke-sample-cluster-services-099d0671
  stackType: IPV4
  useIpAliases: true
  ...
location: us-central1
name: sample-cluster
network: default
networkConfig:
  datapathProvider: ADVANCED_DATAPATH
  defaultSnatStatus: {}
  network: projects/****************/global/networks/default
  serviceExternalIpsConfig: {}
  subnetwork: projects/****************/regions/us-central1/subnetworks/default
  ...
nodeConfig:
  machineType: e2-medium
  ...  

Dataplane V2 という表記はありませんが、networkConfig.datapathProvider が ADVANCED_DATAPATH になっていることから、このクラスタで Dataplane V2 が有効化されていることがわかります(Calico CNI を利用する場合は networkConfig.datapathProvider が LEGACY_DATAPATH , networkPolicy.provider が CALICO になります)。
また、Dataplane V2 の場合は networkPolicy.provider のフィールドがそもそも存在しないようですが、これは Dataplane V2 の場合デフォルトで NetworkPolicy が有効化されている(Disable できない)ためと考えられます。

次に、NetworkPolicy の動作確認のため、適当な Pod を 3 つほどクラスタに展開します。

$ kubectl run pod1 --image nginx                                                                                                                                                                
pod/pod1 created
$ kubectl run pod2 --image nginx                                                                                                                                                                
pod/pod2 created
$ kubectl run pod3 --image nginx                                                                                                                                                                
pod/pod3 created
$ kubectl get pod -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP           NODE                                                  NOMINATED NODE   READINESS GATES
pod1   1/1     Running   0          16s   10.32.2.8    gke-sample-cluster-default-pool-c40d772e-l3kh   <none>           <none>
pod2   1/1     Running   0          9s    10.32.2.9    gke-sample-cluster-default-pool-c40d772e-l3kh   <none>           <none>
pod3   1/1     Running   0          5s    10.32.2.10   gke-sample-cluster-default-pool-c40d772e-l3kh   <none>           <none>

検証用の Kubernetes NetworkPolicy のマニフェストは以下を用意しました。
Dataplane V2 では「無効」な構成とされている、ipBlock.cidr フィールドに Pod の IP アドレスを使用したポリシとなっています。

今回は動作検証が目的であるためあえてこのようなポリシを作成していますが、一般的にはクラスタ内の Pod に対して ipBlock でポリシを作成するのはアンチパターンです。
Pod の IP アドレスは動的に付与され、稼働中に再スケジュールなどで変動する可能性があります。

$ cat testnetpol.yaml 
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - ipBlock:
        cidr: 10.32.2.8/32
  egress:
  - {}

この無効なマニフェストクラスタに Apply してみるとどうなるでしょうか。

$ kubectl apply -f testnetpol.yaml 
apiVersion: networking.k8s.io/v1
networkpolicy.networking.k8s.io/test-network-policy created

$ kubectl describe networkpolicy test-network-policy 
Name:         test-network-policy
Namespace:    default
Created on:   2023-09-23 14:20:57 +0000 UTC
Labels:       <none>
Annotations:  <none>
Spec:
  PodSelector:     <none> (Allowing the specific traffic to all pods in this namespace)
  Allowing ingress traffic:
    To Port: <any> (traffic allowed to all ports)
    From:
      IPBlock:
        CIDR: 10.32.2.8/32
        Except: 
  Not affecting egress traffic
  Policy Types: Ingress

Apply することができました。
無効といえど、Apply の時点でバリデーションが行われるということではなさそうです。

次に、この NetworkPolicy がどのように動作するのかを見てみましょう。
今回のポリシは、Ingress を pod1 の IP アドレス(10.32.2.8/32)からのみ許可し、Egress に関しては関与しない(すべて許可)、という設定になっています。
従って、pod1 から他の Pod への通信は(このポリシが正常に機能すれば)許可されるはずです。

$ kubectl exec -it pod1 -- sh

# curl 10.32.2.10
(タイムアウト)  

# exit 
command terminated with exit code 130

$ kubectl delete -f testnetpol.yaml 
networkpolicy.networking.k8s.io "test-network-policy" deleted

$ kubectl exec -it pod1 -- sh

# curl 10.32.2.10
...(略)...
Welcome to nginx!
...(略)...

結果、pod1 から pod3 (10.32.2.10) への curl は失敗となりました。
該当の NetworkPolicy を取り除くと、当然ながら pod1 から他の Pod へのリクエストは正常に到達可能になりました。

Dataplane V2 の場合は、ネットワークポリシーロギングを利用できるため、ログエントリからも動作の様子を確認してみましょう。
このクラスタの NetworkLogging の設定は以下のとおりです。
予め Allow / Deny のどちらの場合でも NetworkPolicy の動作に関するログが Cloud Logging に出力されるよう設定しています。

$ kubectl describe networklogging
Name:         default
Namespace:    
Labels:       addonmanager.kubernetes.io/mode=EnsureExists
Annotations:  components.gke.io/component-name: advanceddatapath
              components.gke.io/component-version: 27.6.0
              components.gke.io/layer: addon
API Version:  networking.gke.io/v1alpha1
Kind:         NetworkLogging
Metadata:
  Creation Timestamp:  2023-09-22T15:19:29Z
  Generation:          2
  Resource Version:    778820
  UID:                 7df685fc-397d-4f43-9d81-ff88d6ad7863
Spec:
  Cluster:
    Allow:
      Delegate:  false
      Log:       true
    Deny:
      Delegate:  false
      Log:       true
Events:          <none>

出力されたログエントリを Cloud Logging から確認してみます。
上記の curl コマンドに対して、6 件のログエントリが生成されていました。

今回の場合は、定義したポリシが pod1 と pod3 の両方を対象とするため、pod1 から pod3 への curl リクエストは pod1 からの Egress と pod3 への Ingress の 2 つのタイミングで評価されていることがわかります。

1 件目のエントリでは、pod1 からの Egress トラフィックが許可されていることがわかります。

{
  "insertId": "dhickie3cds9bxp7",
  "jsonPayload": {
    "dest": {
      "pod_namespace": "default",
      "namespace": "default",
      "pod_name": "pod3"
    },
    "disposition": "allow",
    "node_name": "gke-sample-cluster-default-pool-c40d772e-l3kh",
    "count": 1,
    "src": {
      "namespace": "default",
      "pod_namespace": "default",
      "pod_name": "pod1"
    },
    "connection": {
      "dest_ip": "10.32.2.10",
      "protocol": "tcp",
      "src_port": 48512,
      "dest_port": 80,
      "direction": "egress",
      "src_ip": "10.32.2.8"
    },
    "policies": [
      {
        "kind": "NetworkPolicy",
        "name": "test-network-policy",
        "namespace": "default"
      }
    ]
  },
  "resource": {
    "type": "k8s_node",
    "labels": {
      "location": "us-central1",
      "project_id": "****************",
      "cluster_name": "sample-cluster",
      "node_name": "gke-sample-cluster-default-pool-c40d772e-l3kh"
    }
  },
  "timestamp": "2023-09-23T14:21:51.384375643Z",
  "logName": "projects/****************/logs/policy-action",
  "receiveTimestamp": "2023-09-23T14:21:54.247088734Z"
}

2 件目のエントリでは、pod3 への Ingress トラフィックが拒否されていることが確認できます。
3 件目以降のエントリは curl の再試行によるもので、2 件目とログの内容は同じです。

{
  "insertId": "akf4ygd85sv6au09",
  "jsonPayload": {
    "disposition": "deny",
    "node_name": "gke-sample-cluster-default-pool-c40d772e-l3kh",
    "count": 3,
    "src": {
      "pod_name": "pod1",
      "pod_namespace": "default",
      "namespace": "default"
    },
    "dest": {
      "pod_name": "pod3",
      "namespace": "default",
      "pod_namespace": "default"
    },
    "connection": {
      "direction": "ingress",
      "protocol": "tcp",
      "dest_ip": "10.32.2.10",
      "src_port": 48512,
      "src_ip": "10.32.2.8",
      "dest_port": 80
    }
  },
  "resource": {
    "type": "k8s_node",
    "labels": {
      "location": "us-central1",
      "project_id": "****************",
      "node_name": "gke-sample-cluster-default-pool-c40d772e-l3kh",
      "cluster_name": "sample-cluster"
    }
  },
  "timestamp": "2023-09-23T14:21:51.384394882Z",
  "logName": "projects/****************/logs/policy-action",
  "receiveTimestamp": "2023-09-23T14:21:59.247442915Z"
}

1 件目と 2 件目のエントリを見比べると、1 件目は評価した NetworkPolicy のリソース名 (test-network-policy) がログエントリに含まれているのに対し、2 件目には含まれていませんでした。

考察・検証から得られた気付き

Dataplane V2 における無効な NetworkPolicy がどのような影響を与えるのか、上記の検証を通じて得られたいくつかの気付きを共有します。

まず、無効なマニフェストであってもクラスタに Apply することは可能なようです。
無効なポリシの対象となったトラフィックタイムアウトし、明示的なエラーは返却されません。
加えて、Calico CNI を利用したクラスタで利用している NetworkPolicy を単純移行しようとすると無効なポリシも含めて Apply には成功してしまいます。
そのため、場合によっては無効なポリシが悪影響を及ぼしていることに気付くのが遅れるといった可能性がありそうです。

今回は折角 Dataplane V2 を利用しているため、ネットワークポリシーロギングからも無効なポリシに関する挙動を確認しました。
ログエントリの内容にそれを判別できる特徴があるのであれば、NetworkPolicy が期待した動作をしない場合に無効な構成を Apply してしまっている可能性に気付けるきっかけにできます。
検証では、pod1 からの Egress の許可には評価した NetworkPolicy のリソース名が含まれ、pod3 への Ingress の拒否にはリソース名が含まれていなかったことから、ネットワークポリシが無効な設定の場合はログエントリにリソース名が表示されないのでは(無効なポリシなので、そもそも Ingress を正しく評価できる NetworkPolicy がいない → policies フィールドが空になる)と想定しました。
しかし、以下のように有効な構成と思われる AllDeny のポリシを Apply した場合であっても、Deny ログには policies フィールドが含まれていない(拒否の理由となった NetworkPolicy のリソース名はログエントリからわからない)ため、どうやら単純に拒否 (Deny) のログエントリは「拒否の理由が、正しく評価できる NetworkPolicy であるか、無効なポリシが Apply されているためかに関わらず、policies フィールドが含まれない」ようです。
そのため、ネットワークポリシーロギングのエントリから直接的に無効なポリシの存在に気付くのは難しそうですね。

$ cat alldeny.yaml 
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
  
$ kubectl apply -f alldeny.yaml 
networkpolicy.networking.k8s.io/default-deny-all created

$ kubectl get netpol
NAME               POD-SELECTOR   AGE
default-deny-all   <none>         44m

$ kubectl exec pod1 -it -- sh

# curl 10.32.2.10
(タイムアウト)

# exit
command terminated with exit code 130

$ gcloud logging read 'insertId=o0qc79kobivrxvmm'
---
insertId: o0qc79kobivrxvmm
jsonPayload:
  connection:
    dest_ip: 10.32.2.10
    dest_port: 80
    direction: egress
    protocol: tcp
    src_ip: 10.32.2.8
    src_port: 42996
  count: 3
  dest:
    namespace: default
    pod_name: pod3
    pod_namespace: default
  disposition: deny
  node_name: gke-sample-cluster-default-pool-c40d772e-l3kh
  src:
    namespace: default
    pod_name: pod1
    pod_namespace: default
logName: projects/****************/logs/policy-action
receiveTimestamp: '2023-09-25T02:23:59.248034900Z'
resource:
  labels:
    cluster_name: sample-cluster
    location: us-central1
    node_name: gke-sample-cluster-default-pool-c40d772e-l3kh
    project_id: ****************
  type: k8s_node
timestamp: '2023-09-25T02:23:50.903492410Z'

以上より、Calico から Dataplane V2 への移行というシナリオにおいて、既存の NetworkPolicy をひとまず投入してから挙動やログに基づいて構成を修正してゆく、というアプローチはちょっと難しそうです。
移行にあたって無効なポリシを Apply してしまうリスクに対しては、Dataplane V2 や Cilium の Known Issue ・制限事項などに該当するマニフェストがないかなど、事前の精査にウェイトを置くほうが良さそうですね。

まとめ

Calico CNI から GKE Dataplane V2 への移行というシチュエーションをもとに、Dataplane V2 において無効な NetworkPolicy がどのような影響を与えるのかについて、実機検証を交えて調べました。
パフォーマンスや可観測性など、既存ワークロードの移行においては色々と検討すべき事項があるかと思いますが、そのひとつの観点として記事の情報が何かのお役に立てば幸いです。