GKE Dataplane V2 and Network Policies in Practice

In this tutorial, we are going to play with the Google Kubernetes Engine Dataplane V2 and check how we can use it along with Kubernetes Network Policies to limit traffic to Pods and to obtain real-time visibility on cluster network activity.

Dataplane V2 is a recent feature in GKE, with GA starting on version 1.20.6-gke.700 as of May 10, 2021. It uses Cilium to process network packets in-kernel using Kubernetes-specific metadata without relying on the kube-proxy and iptables for service routing, resulting in performance improvements. Dataplane V2 brings some exciting features for cluster operations and security, such as:

  • Built-in Network Policies enforcement without the need of Calico and;
  • Real-time visibility, enabling cluster networking troubleshooting, auditing, and alerting.

See the Dataplane V2 documentation for more details.

Prerequisites

To follow this tutorial, you need a Google Cloud project created with billing enabled.

Also, make sure you have the most recent version of the gcloud CLI installed. See this doc for installation instructions. If you already have gcloud installed, you can ensure it’s up to date by running gcloud components update.

Note that this tutorial uses billable components of Google Cloud. Make sure you execute the clean up steps after you finish.

Create a GKE Cluster

Let’s get started by creating a GKE cluster with Dataplane V2 enabled.

Let’s set some defaults:

gcp_project=your-project
cluster_name=your-cluster
cluster_region=us-east1

gcloud config set project $gcp_project

And create the cluster:

gcloud container clusters create $cluster_name \
    --enable-dataplane-v2 \
    --enable-ip-alias \
    --release-channel regular \
    --region $cluster_region

The current default version in the regular channel already enables the use of the Dataplane V2. If you want to know which versions are available in the release channels, you can execute:

# optional command to check versions in the channels, no need to run
gcloud container get-server-config --format "yaml(channels)" --region $cluster_region

After the cluster is created your kube-config will be already pointing to the newly created cluster. If you need to reconnect to it, you can use the following command:

# optional command to re-configure the kube-config, no need to run
gcloud container clusters get-credentials $cluster_name --region $cluster_region

Deploy a Demo Application

To check the Dataplane V2 feature, we want to use a non-trivial scenario. For this, we will deploy a cloud-native microservices demo application commonly used to demonstrate Kubernetes/GKE features.

Clone the GitHub repository:

git clone https://github.com/GoogleCloudPlatform/microservices-demo

Apply the Kubernetes manifests:

kubectl apply -f microservices-demo/release/kubernetes-manifests.yaml

Check that the pods are running:

kubectl get po
NAME                                     READY   STATUS    RESTARTS   AGE
adservice-5844cffbd4-k5nnq               1/1     Running   0          65s
cartservice-fdc659ddc-lf8ph              1/1     Running   0          66s
checkoutservice-64db75877d-stqwv         1/1     Running   0          68s
currencyservice-9b7cdb45b-qsgzb          1/1     Running   0          66s
emailservice-64d98b6f9d-s528z            1/1     Running   0          68s
frontend-76ff9556-nsb6w                  1/1     Running   0          67s
loadgenerator-589648f87f-nnfzt           1/1     Running   0          66s
paymentservice-65bdf6757d-rqq8x          1/1     Running   0          67s
productcatalogservice-5cd47f8cc8-d2x8d   1/1     Running   0          67s
recommendationservice-b75687c5b-nz6rt    1/1     Running   0          68s
redis-cart-74594bd569-wcvlz              1/1     Running   0          65s
shippingservice-778554994-8l2pr          1/1     Running   0          66s

Check the external IP of the LoadBalancer service:

kubectl get svc frontend-external
NAME                TYPE           CLUSTER-IP    EXTERNAL-IP      PORT(S)        AGE
frontend-external   LoadBalancer   10.60.3.181   34.139.243.117   80:30595/TCP   2m1s

Optional: Play around

Optionally, access the application from the browser using the external IP and add some products to the cart.

Undesired Network Access

This microservice application has quite a few components and, among them, we can see that the CartService communicates with the Cache (implemented by Redis) to store the product items in the user’s shopping cart. See the architecutre diagram for details on how the components are related to each other.

List the Kubernetes services in the cluster to identify the Redis service:

kubectl get svc
NAME                    TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)        AGE
adservice               ClusterIP      10.60.5.28     <none>           9555/TCP       6m
cartservice             ClusterIP      10.60.6.160    <none>           7070/TCP       6m
checkoutservice         ClusterIP      10.60.9.50     <none>           5050/TCP       6m
currencyservice         ClusterIP      10.60.9.235    <none>           7000/TCP       6m
emailservice            ClusterIP      10.60.8.126    <none>           5000/TCP       6m
frontend                ClusterIP      10.60.1.70     <none>           80/TCP         6m
frontend-external       LoadBalancer   10.60.3.181    34.139.243.117   80:30595/TCP   6m
kubernetes              ClusterIP      10.60.0.1      <none>           443/TCP        17m
paymentservice          ClusterIP      10.60.12.233   <none>           50051/TCP      6m
productcatalogservice   ClusterIP      10.60.1.237    <none>           3550/TCP       6m
recommendationservice   ClusterIP      10.60.6.61     <none>           8080/TCP       6m
redis-cart              ClusterIP      10.60.1.152    <none>           6379/TCP       6m
shippingservice         ClusterIP      10.60.4.213    <none>           50051/TCP      6m

You can see the redis-cart ClusterIP service configured on port 6379. You can verify that this service is fronting the redis-cart Pod by describing and checking the selector: app=redis-cart:

kubectl describe svc redis-cart
Name:              redis-cart
Namespace:         default
Labels:            <none>
Annotations:       cloud.google.com/neg: {"ingress":true}
Selector:          app=redis-cart
Type:              ClusterIP
IP Families:       <none>
IP:                10.60.1.152
IPs:               10.60.1.152
Port:              redis  6379/TCP
TargetPort:        6379/TCP
Endpoints:         10.56.8.4:6379
Session Affinity:  None
Events:            <none>

And confirm by checking the redis-cart deployment:

kubectl get deploy redis-cart -o json | jq .spec.template.metadata.labels
{
  "app": "redis-cart"
}

Note that since describing a deployment is much more verbose than describing a service, we opted to use the json output piping to jq.

Let’s play the role of a malicious actor that got access to a container in our cluster. For this, we will launch a container using the redis image and check connectivity to the redis-cart Pod.

kubectl run rogue-1 --image=redis --restart=Never --rm -it -- /bin/bash

From the container’s prompt run:

root@rogue-1:/data# redis-cli -h redis-cart ping
PONG

With this, we can confirm connectivity to the Redis database. A malicious actor can use different commands to access data or disrupt the service. Also, keep in mind that a malicious actor can explore this behavior across all application components. We are using the Redis database just as an example.

Kubernetes Network Policies

To improve the security posture, the first thing that we’ll do is create a NetworkPolicy that blocks this kind of undesired access, restricting ingress traffic to the redis-cart Pod only from the CartService Pods.

kubectl apply -f- << EOF
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
  name: redis-cart-allow-from-cartservice
spec:
  policyTypes:
  - Ingress
  podSelector:
    matchLabels:
      app: redis-cart
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: cartservice
EOF

The network policy is self-explanatory and you can see that we are using label selectors to match the Pods where we want the policy to be applied to and the pods where we want to enable ingress from.

Launch the rogue container again and try to execute the ping command and notice that it hangs:

kubectl run rogue-1 --image=redis --restart=Never --rm -it -- /bin/bash
root@rogue-1:/data# redis-cli -h redis-cart ping

Use CTRL+C to stop.

This feature is already helping to improve our security posture. However, depending on the level of access a malicious actor has gotten, they could notice that a network policy is in play and try a few tricks to bypass it.

Optional: Play around again

Access the application from the browser and add some products to the cart and check that the CartService -> Cache connectivity still works as expected.

We don’t want a malicious user silently playing around in our cluster. So, besides preventing undesired access, we also want to log this activity. This log allows us to audit and alert on suspicious events.

Network Policy Logging

When you create a cluster with Dataplane V2 enabled, a default NetworkLogging object is created. We need to change this object to configure the network policy logging behavior.

Let’s see how the default NetworkLogging object looks like initially:

kubectl get nl default -o yaml
apiVersion: networking.gke.io/v1alpha1
kind: NetworkLogging
metadata:
  name: default
spec:
  cluster:
    allow:
      delegate: false
      log: false
    deny:
      delegate: false
      log: false

Use this yaml to change the default network logging configuration and enable logging for denied connections:

kubectl apply -f- << EOF
kind: NetworkLogging
apiVersion: networking.gke.io/v1alpha1
metadata:
  name: default
spec:
  cluster:
    allow:
      log: false
      delegate: false
    deny:
      log: true
      delegate: false
EOF

(You should expect to see a warning after applying this change, it’s ok.)

Check the Network Policy Logging documentation for a complete description of the NetworkLogging specification and configuration options.

Now we can check the Google Cloud Logging to verify the log events related to the network policy activity.

Execute the Redis ping command again:

kubectl run rogue-1 --image=redis --restart=Never --rm -it -- /bin/bash
root@rogue-1:/data# redis-cli -h redis-cart ping

And check the logs:

# replace <your-cluster-name> and <your-project-name>
gcloud logging read --project $gcp_project 'resource.type="k8s_node" resource.labels.location=us-east1 resource.labels.cluster_name=<your-cluster-name> logName="projects/<your-project-name>/logs/policy-action" jsonPayload.dest.pod_name=~"redis-cart" jsonPayload.disposition="deny"'

You will see the log entries coming through with details about the events. Check the documentation for the Log Format definition.

A note on Google Cloud Logging

You are able to check the logs with the previous command because Cloud Logging is enabled by default in new clusters, and network policy logs are automatically shipped to Cloud Logging. See more about how to configure Cloud Operations for GKE.

Using Cloud Logging you can set up a Log-based metric and create an alerting policy upon suspicious activity.

Keep in mind that network policy logs generated on each cluster are available at /var/log/network/policy_action.log* and can be shipped to your preferred log aggregator.

Optional: Delegating Network Policy Logging Configuration

Instead of enabling logging for all denied connections as we did with our previous NetworkLogging configuration, you can delegate it by using the annotation policy.network.gke.io/enable-deny-logging: "true".

Update the NetworkLogging object:

kubectl apply -f- << EOF
kind: NetworkLogging
apiVersion: networking.gke.io/v1alpha1
metadata:
  name: default
spec:
  cluster:
    allow:
      log: false
      delegate: false
    deny:
      log: true
      delegate: true
EOF

Execute the Redis ping command again:

kubectl run rogue-1 --image=redis --restart=Never --rm -it -- /bin/bash
root@rogue-1:/data# redis-cli -h redis-cart ping

Check the logs and observe that there are no new entries:

# replace <your-cluster-name> and <your-project-name>
gcloud logging read --project $gcp_project 'resource.type="k8s_node" resource.labels.location=us-east1 resource.labels.cluster_name=<your-cluster-name> logName="projects/<your-project-name>/logs/policy-action" jsonPayload.dest.pod_name=~"redis-cart" jsonPayload.disposition="deny"'

Annotate the namespace where the redis-cart (Pod where the connection was denied) is running with the following annotation:

kubectl annotate namespaces default policy.network.gke.io/enable-deny-logging="true"

Execute the test again and observe that logs will show up.

Allowed connections delegation

Note that to delegate logging for allowed connections, the configuration is different. We have to add the annotation policy.network.gke.io/enable-logging: "true" to the NetworkPolicy instead of the namespace.

Clean up

kubectl delete -f microservices-demo/release/kubernetes-manifests.yaml
gcloud container clusters delete $cluster_name --region $cluster_region