Google Distributed Cloud (GDC) air-gapped provides a built-in managed Layer 4 (L4) load balancer, but many enterprise applications require advanced Layer 7 (L7) capabilities, such as host-based routing, centralized TLS management, and complex traffic splitting. Historically, this is accomplished using the Ingress API, which is now considered feature-frozen in the Kubernetes community.
This reference architecture provides a self-managed Layer 7 load balancing solution. By deploying the popular HAProxy open source controller on a GDC standard cluster, customers can seamlessly route L7 traffic to hybrid environments. This architecture uses TLS Termination (HTTPRoute) to route traffic based on Server Name Indication (SNI) to both built-in containerized pods and applications hosted on external Virtual Machines.
Architecture

The key components of the solution include:
- Client: An entity initiating HTTPS requests to interact with the applications.
- GDC standard cluster: GDC provides a built-in way to create Kubernetes Vanilla Clusters. In this solution, the cluster will host the L7 LB and its controllers, along with the workloads and healess service for external VMs
- GDC L4 Load Balancer: The built-in L4 load balancer serving as the entry point, distributing TCP/443 traffic directly to the Kubernetes Pods running the controllers.
- Ingress Controllers: HAProxy operators running in the standard cluster.
They monitor the
Ingressresources and dynamically update the underlying proxies. HAProxy Ingress Controller will be used in the following implementation - Ingress: Standardized Kubernetes resources defining the physical listening port (443) and the SNI-based host routing rules with TLS Termination.
- Containerized Workload (Pods): A standard Kubernetes Deployment exposed
internally with a regular Kubernetes
Service. - VM-based Workload (External): A workload hosted on an external VM on the
project network, exposed to the proxy using a headless Kubernetes
Serviceand a custom endpoint containing the VM's direct IP. - Harbor Registry: A private container registry used to store and serve the proxy and application images in the air-gapped environment.
In the standard cluster, you create three namespaces:
The
load-balancernamespace hosts the HAProxy Ingress Controller and the HAProxy load balancer workload:
The
hello-appnamespace hosts theDeployment, aService, and anIngressfor the demo container workload:
The
vm-appnamespace hosts a headless service that exposes the external VM IP, anEndpointSlicethat points at the external IP, and anIngress:
Before you begin
Before deploying this solution, ensure you have the following prerequisites in place:
- Software needed: helm, docker, kubectl
CLI login and local setup: Download gdcloud CLI from the GDC console and set up your environment locally:
export USER_NAME="USER_NAME" export PROJECT_ID="PROJECT_ID" export ZONE="ZONE" export ORG_NAME="ORG_NAME" export GDC_URL="GDC_URL" gdcloud components install gdcloud-k8s-auth-plugin gdcloud config set core/organization_console_url \ https://console.$ORG_NAME.$ZONE.$GDC_URL gdcloud config set core/zone $ZONE gdcloud config set core/project ${PROJECT_ID} gdcloud auth login # use --login-config-cert option in case of TLS errorProject Setup: Create a project in your GDC air-gapped environment to hold the resources:
gdcloud projects create $PROJECT_IDIAM Roles: Grant your user the Cluster Admin and Standard Cluster Admin roles to manage Kubernetes resources, and the Harbor Instance Admin role to push images:
# Grant standard cluster and cluster admin roles gdcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member="user:${USER_NAME}" \ --role=cluster-admin gdcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member="user:${USER_NAME}" \ --role=standard-cluster-admin # Grant Harbor instance admin role gdcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member="user:${USER_NAME}" \ --role=harbor-instance-admin
Create a standard cluster
This section guides you through the process of setting up a standard Kubernetes cluster within your GDC air-gapped environment. A standard cluster provides a flexible and robust foundation for deploying various workloads, including the HAProxy Ingress Controller, and your custom applications. The following steps will ensure your cluster is properly configured and accessible for subsequent deployments.
Identify the available virtual machine image types by running:
gdcloud compute machine-types listSelect an appropriate machine type for your cluster worker nodes. For this tutorial, a machine type with at least 4 vCPUs is recommended.
export MACHINE_TYPE="MACHINE_TYPE"Get the management API server kubeconfig and set an alias:
export CLUSTER_NAME="CLUSTER_NAME" KUBECONFIG=kubeconfig-admin.yaml gdcloud clusters \ get-credentials ${ORG_NAME}-admin alias km="kubectl --kubeconfig kubeconfig-admin.yaml"Create a standard cluster with two worker nodes:
km create -f - <<EOF apiVersion: cluster.gdc.goog/v1 kind: Cluster metadata: name: ${CLUSTER_NAME} namespace: ${PROJECT_ID} spec: nodePools: - machineTypeName: ${MACHINE_TYPE} nodeCount: 2 name: ${CLUSTER_NAME}-node-pool EOFFor more details on available options, refer to the documentation.
Standard cluster creation can take up to 60 minutes to complete. To check the status, use the following command:
km get clusters/${CLUSTER_NAME} \ -n ${PROJECT_ID} \ --watchAfter the cluster is ready, the output should show a state of Running, like this:
NAME STATE K8S VERSION my-cluster Running 1.30.12-gke.300After the cluster is ready, retrieve its credentials:
KUBECONFIG=kubeconfig-${CLUSTER_NAME}.yaml gdcloud clusters \ get-credentials ${CLUSTER_NAME} \ --standard \ --project ${PROJECT_ID} \ --zone ${ZONE}Create an alias to keep
kubectlcommands more concise in the rest of this guide. This alias will be used to interact with the standard cluster:alias kk="kubectl --kubeconfig kubeconfig-${CLUSTER_NAME}.yaml"Create namespaces for the controller, the "hello-app" demo containerized app, and the VM-based demo app:
kk create namespace load-balancer kk create namespace hello-app kk create namespace vm-app
Create and integrate Harbor Registry
Harbor is a container image registry with built-in support in GDC air-gapped. This section guides you through the steps to integrate a Harbor Registry with your standard cluster, including configuring credentials and secrets to enable secure pulling and pushing of images.
- Create a Harbor instance in your project.
- Create a Harbor project in your Harbor instance.
Set environment variables:
export HARBOR_INSTANCE_NAME="HARBOR_INSTANCE_NAME" export HARBOR_INSTANCE_URL="HARBOR_INSTANCE_URL" export HARBOR_PROJECT="HARBOR_PROJECT" export IMAGE_PULL_SECRET_NAME="harbor-secret"Sign in to the Harbor instance using a robot account:
docker --config=./docker login ${HARBOR_INSTANCE_URL}Create the secrets in the standard cluster:
kk create secret docker-registry ${IMAGE_PULL_SECRET_NAME} \ --from-file=.dockerconfigjson=./docker/config.json \ -n load-balancer kk create secret docker-registry ${IMAGE_PULL_SECRET_NAME} \ --from-file=.dockerconfigjson=./docker/config.json \ -n hello-app
Deploy demo containerized app
This section details the deployment of a demo containerized application
(hello-app) within your GDC air-gapped Kubernetes
cluster. You will create the necessary Kubernetes Deployment and Service
resources to run the hello-app and expose it internally within the cluster,
preparing it for access using the L7 load balancer.
Upload a sample image for the demo containerized app into Harbor:
docker pull gcr.io/google-samples/hello-app:1.0 \ --platform linux/amd64 docker tag gcr.io/google-samples/hello-app:1.0 \ ${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/hello-app:1.0 docker --config=./docker push \ ${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/hello-app:1.0Deploy the following manifest in the standard cluster:
cat << EOF > hello-app.yaml apiVersion: apps/v1 kind: Deployment metadata: name: hello-app namespace: hello-app spec: replicas: 2 selector: matchLabels: app: hello-app template: metadata: labels: app: hello-app spec: containers: - name: hello-server image: ${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/hello-app:1.0 ports: - containerPort: 8080 imagePullSecrets: - name: ${IMAGE_PULL_SECRET_NAME} --- apiVersion: v1 kind: Service metadata: name: hello-app namespace: hello-app spec: type: ClusterIP selector: app: hello-app ports: - protocol: TCP port: 80 targetPort: 8080 EOF kk apply -f hello-app.yaml
Then verify that the deployment and the service are there
kk get svc,deploy -n hello-app
Deploy demo app in a VM
This section details the deployment of a demo application within a virtual machine (VM) outside your Kubernetes cluster. By setting up an HTTP server on a VM, you'll simulate an external application that the load balancer can expose, demonstrating its capability to manage traffic to resources both inside and outside the cluster.
First, create a VM for the demo app:
- Open the GDC console in your web browser.
- Select the same project as where you created your standard Kubernetes cluster.
- Open the menu then click Virtual machines.
- Click Create Instance.
- Give the VM the name
vm-workload. A 2 vCPU image is enough for the example. - For the boot disk image, select an Ubuntu 22.04 distribution, which comes with Python pre-installed.
- Click Create.
- Wait a few minutes until the VM becomes ready.
- Establish an SSH connection to VM:
- In the GDC console, click the VM.
- Click Connect with SSH.
After you're connected to the SSH console, run the following:
mkdir ~/simple-server
cd ~/simple-server
echo 'Welcome to my VM!' > index.html
python3 -m http.server --bind 0.0.0.0 8080 &
To route traffic to a VM, create a headless Service (without selectors). This
will be manually mapped to the VM's internal IP address using an EndpointSlice
resource.
kk apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
name: vm-app-svc
namespace: vm-app
spec:
ports:
- protocol: TCP
port: 443
targetPort: 443
EOF
Get the IP address of vm-workload VM by running
gdcloud compute instances list --project ${PROJECT_ID} \
| grep workload-vm | awk '{print $3}'
The output will be the IP address of the VM that will be needed to set up the EndpointSlice resource.
Create the resource EndpointSlice that will connect to the VM-app
selector-less Service and address the IP of the VM to which the traffic should
be routed.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: vm-app-endpoints
namespace: vm-app
labels:
kubernetes.io/service-name: vm-app-svc
addressType: IPv4
ports:
- port: 8080
endpoints:
- addresses:
- "VM_IP"
conditions:
ready: true
Create self-signed certificates
This section guides you through the process of creating TLS certificates and Kubernetes secrets to secure communication for your container-based and VM-based applications. This guide uses self-signed certificates for convenience, but in production environments, you must use production-grade certificates, as described in Optional: Use production-ready certificates. Choose arbitrary sample domain names for these apps. By establishing secure connections, you ensure data integrity and confidentiality for clients accessing your application through the HAProxy Ingress Controller.
For the containerized app, we create a self-signed certificate and save it as a secret on the load-balancer namespace. This will be used for the TLS when requesting k8s-app.example.com
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tls-containerized.key \
-out tls-containerized.crt \
-subj "/CN=k8s-app.example.com" \
-days 365
kk create secret tls tls-containerized \
--namespace load-balancer \
--key tls-containerized.key \
--cert tls-containerized.crt
kk create secret tls tls-containerized \
--namespace hello-app \
--key tls-containerized.key \
--cert tls-containerized.crt
For the VM app, a similar self-signed certificate is issued and saved. This will be used for the TLS when requesting vm-app.example.com
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tls-vm.key \
-out tls-vm.crt \
-subj "/CN=vm-app.example.com" \
-days 365
kk create secret tls tls-vm \
--namespace load-balancer \
--key tls-vm.key \
--cert tls-vm.crt
kk create secret tls tls-vm \
--namespace vm-app \
--key tls-vm.key \
--cert tls-vm.crt
Deploy HAProxy
Install HAProxy Ingress controller and L4 LB
export HAPROXY_VERSION=3.1.14
# pull the HAProxy Ingress Controller image and push it to Harbor
docker pull haproxytech/kubernetes-ingress:${HAPROXY_VERSION} \
--platform linux/amd64
docker tag haproxytech/kubernetes-ingress:${HAPROXY_VERSION} \
${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/haproxy-ingress:${HAPROXY_VERSION}
docker --config=./docker push \
${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/haproxy-ingress:${HAPROXY_VERSION}
# Get Helm repo
helm repo add haproxytech https://haproxytech.github.io/helm-charts
helm repo update
# Install the Ingress Controller with helm
helm upgrade --install haproxy-kubernetes-ingress \
haproxytech/kubernetes-ingress \
--kubeconfig kubeconfig-${CLUSTER_NAME}.yaml \
--namespace load-balancer \
--set controller.image.repository=${HARBOR_INSTANCE_URL}/${HARBOR_PROJECT}/haproxy-ingress \
--set controller.image.tag=${HAPROXY_VERSION} \
--set controller.existingImagePullSecret=${IMAGE_PULL_SECRET_NAME} \
--set controller.service.type=LoadBalancer \
--set-json \
controller.service.annotations='{"networking.gke.io/load-balancer-type": "internal"}'
The HAProxy Ingress Controller gains a unique virtual IP address for client access using a
LoadBalancer type service. This service sets up a fully-managed Layer 4 load
balancer. For this guide's simplicity, an internal load balancer is created by
setting the load-balancer-type annotation to internal. Omitting this
annotation would result in an external load balancer. The Kubernetes deployment
securely pulls images from Harbor using the provided secret
(${IMAGE_PULL_SECRET_NAME}), which contains the Harbor robot account's
credentials.
Validate HAProxy Ingress controller installation
Check that the HAProxy Ingress Controller's pods are running and ready:
kk get pods -n load-balancer
The output should look like the following:
NAME READY STATUS RESTARTS AGE
haproxy-kubernetes-ingress-78dc9c8676-f8fcb 1/1 Running 0 35s
haproxy-kubernetes-ingress-78dc9c8676-lfnr2 1/1 Running 0 65s
haproxy-kubernetes-ingress-crdjob-3-tgj2h 0/1 Completed 0 65s
Check that the HAProxy Ingress Controller's service is created and configured:
kk get services -n load-balancer
The output looks similar to the following:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
haproxy-kubernetes-ingress LoadBalancer 10.252.27.46 10.252.4.17 80:32023/TCP,443:31103/TCP,443:31103/UDP,1024:30146/TCP,6060:30718/TCP 10m
Define Ingress resources for the demo apps
Create the Ingress resource that will connect the HAProxy to the containerized app Service
cat << EOF > hello-app-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-app-ingress
namespace: hello-app
annotations:
haproxy.org/ssl-redirect: "true"
haproxy.org/ssl-redirect-port: "443"
haproxy.org/ssl-redirect-code: "308"
spec:
ingressClassName: haproxy
tls:
- hosts:
- "k8s-app.example.com"
secretName: tls-containerized
rules:
- host: "k8s-app.example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-app
port:
number: 80
EOF
kk apply -f hello-app-ingress.yaml
Create the Ingress resource that will connect to the VM-app selector-less Service and address the IP of the VM to which the traffic should be routed.
cat << EOF > vm-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vm-app-ingress
namespace: vm-app
annotations:
haproxy.org/ssl-redirect: "true"
haproxy.org/ssl-redirect-port: "443"
haproxy.org/ssl-redirect-code: "308"
spec:
ingressClassName: haproxy
tls:
- hosts:
- "vm-app.example.com"
secretName: tls-vm
rules:
- host: "vm-app.example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vm-app-svc
port:
number: 443
EOF
kk apply -f vm-ingress.yaml
Retrieve load balancer IP address
Run the command to get the IP address of the load balancer.
kk get services/haproxy-kubernetes-ingress \
-n load-balancer \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
This will be needed when verifying the access to the apps. This will be referred
to as LOAD_BALANCER_IP.
Create a client VM
Follow the steps to create a client VM:
- Open the GDC console in your web browser.
- Open the menu and click Virtual machines.
- Click Create Instance.
- Create a VM named
client, select a small machine type and select either Rocky Linux or Ubuntu, which come withcurlpreinstalled. - Click Create.
- Wait a few minutes until the VM becomes ready.
- After the VM is ready, establish an SSH connection with the VM:
- In the GDC console, click the VM.
- Click Connect with SSH.
Verify access and routing
To test the routing, execute curl commands from your client VM. You can
connect to both applications using their defined hostnames with the load
balancer's IP address.
By passing the --resolve flag in curl, you can force the domain names to
resolve to your GDC air-gapped L4 Load Balancer's IP.
Note that we pass the -k flag to trust the self-signed certificates.
Test the Kubernetes containerized app:
curl -k --resolve k8s-app.example.com:443:$LOAD_BALANCER_IP https://k8s-app.example.com -v
Test the external VM app:
curl -k --resolve vm-app.example.com:443:$LOAD_BALANCER_IP https://vm-app.example.com -v
If configured correctly, the Ingress controller will seamlessly act as TLS terminator and pass-through the traffic to the destination.
Optional: Use production-ready certificates
This section covers how to leverage the GDC air-gapped CA Service to create a private Root Certificate Authority (CA), issue signed certificates for your workloads, and securely update your GDC air-gapped standard cluster and client VMs.
This section outlines how to use the GDC air-gapped CA
Service to create a
private Root Certificate Authority (CA) and issue valid certificates for your
applications. By installing this Root CA on your client VM, you can verify that
TLS termination works seamlessly with trusted certificates, without needing to
bypass SSL warnings (for example, using curl -k).
Grant necessary permissions and get credentials
To manage the CA Service and issue certificates, your user needs the appropriate IAM roles in the project.
Grant the
certificate-authority-service-adminandcertificate-requesterroles:gdcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member=user:${USER_NAME} \ --role=certificate-authority-service-admin gdcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member=user:${USER_NAME} \ --role=certificate-requesterGet the credentials of the management API server:
gdcloud clusters get-credentials ${ORG_NAME}-admin
Create the root CA
You will create a Certificate Authority in the management API server within your project namespace.
Apply the
CertificateAuthorityresource:km apply -f - <<EOF apiVersion: pki.security.gdc.goog/v1 kind: CertificateAuthority metadata: name: my-root-ca namespace: ${PROJECT_ID} spec: caProfile: commonName: "My Root CA" duration: 87600h # 10 years keyAlgorithm: RSA_2048 maxChainLength: 1 caType: ROOT keyLocation: HSM rotationPolicy: cronTime: 0 0 1 1 * EOFkm -n ${PROJECT_ID} get \ certificateauthority.pki.security.gdc.goog/my-root-ca -ojson \ | jq -r ' .status.conditions[] | select( .type as $id | "Ready" | index($id)) .status'
Issue and deploy certificates
After the CA is ready, you will request certificates for both the containerized app and the VM-based app. These requests happen in the management API server, and the resulting keys must be moved to your standard cluster.
Create requests for both domains:
km apply -f - <<EOF
apiVersion: pki.security.gdc.goog/v1
kind: CertificateRequest
metadata:
name: tls-containerized-req
namespace: ${PROJECT_ID}
spec:
certificateAuthorityRef:
name: my-root-ca
namespace: ${PROJECT_ID}
certificateConfig:
subjectConfig:
commonName: "k8s-app.example.com"
dnsNames:
- "k8s-app.example.com"
signedCertificateSecret: tls-containerized-signed
---
apiVersion: pki.security.gdc.goog/v1
kind: CertificateRequest
metadata:
name: tls-vm-req
namespace: ${PROJECT_ID}
spec:
certificateAuthorityRef:
name: my-root-ca
namespace: ${PROJECT_ID}
certificateConfig:
subjectConfig:
commonName: "vm-app.example.com"
dnsNames:
- "vm-app.example.com"
signedCertificateSecret: tls-vm-signed
EOF
Wait a few moments for the certificates to be issued. You can verify they are ready when the Ready condition is True:
km get certificaterequests -n ${PROJECT_ID}
Update the standard cluster
If you followed the previous sections of this guide, you have self-signed secrets in your standard cluster. You must delete them before creating the new, signed versions:
kk delete secret tls-containerized -n load-balancer
kk delete secret tls-vm -n load-balancer
kk delete secret tls-containerized -n hello-app
kk delete secret tls-vm -n vm-app
Now, extract the signed certificates from the management API server and create the new secrets in the standard cluster.
km get secret -n ${PROJECT_ID} tls-containerized-signed \
-o jsonpath='{.data.tls\.crt}' \
| base64 -d > tls-containerized.crt
km get secret -n ${PROJECT_ID} tls-containerized-signed \
-o jsonpath='{.data.tls\.key}' \
| base64 -d > tls-containerized.key
kk create secret tls tls-containerized \
--namespace load-balancer \
--key tls-containerized.key \
--cert tls-containerized.crt
kk create secret tls tls-containerized \
--namespace hello-app \
--key tls-containerized.key \
--cert tls-containerized.crt
km get secret -n ${PROJECT_ID} tls-vm-signed \
-o jsonpath='{.data.tls\.crt}' \
| base64 -d > tls-vm.crt
km get secret -n ${PROJECT_ID} tls-vm-signed \
-o jsonpath='{.data.tls\.key}' \
| base64 -d > tls-vm.key
kk create secret tls tls-vm \
--namespace load-balancer \
--key tls-vm.key \
--cert tls-vm.crt
kk create secret tls tls-vm \
--namespace vm-app \
--key tls-vm.key \
--cert tls-vm.crt
The new secrets will be obtained automatically and refreshed by load balancers.
Configure client trust
To verify the setup, you need to tell your client VM to trust your new root CA.
Extract the root CA certificate to a file:
km get secret -n ${PROJECT_ID} my-root-ca-secret \
-o jsonpath='{.data.tls\.crt}' \
| base64 -d > my-root-ca.crt
Transfer the certificate to your client VM. (You can copy the content of my-root-ca.crt and paste it into a file on the client VM).
On the client VM, update the trust store.
If client VM is Ubuntu:
sudo cp my-root-ca.crt /usr/local/share/ca-certificates/
sudo chmod 644 /usr/local/share/ca-certificates/my-root-ca.crt
sudo update-ca-certificates
If client VM is Rocky Linux:
sudo cp my-root-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
Verify access
You can now access your applications using curl without the -k flag. The
connection will be fully trusted.
Test the k8s containerized App:
curl -v --resolve k8s-app.example.com:443:LOAD_BALANDER_IP https://k8s-app.example.com
Test the VM App:
curl -v --resolve vm.example.com:443:LOAD_BALANDER_IP https://vm-app.example.com
If successful, you will see the application output immediately without any SSL certificate warnings.