Exposing AKS services to a hub-and-spoke network¶
How workloads on the cluster were reached from outside it: internal and public load balancers, the Kafka listener model that decides whether a client can actually talk to a broker, a private DNS zone for the internal load balancers, a bastion TCP forwarder for clients with no route in, and a small App Service used to test reachability from an application spoke.
Internal and public load balancers¶
Services of type: LoadBalancer get an Azure load balancer frontend. Annotations decide whether
it is internal and where its address comes from. The cluster's identity needs Network
Contributor on any subnet named here (see the aks module).
apiVersion: v1
kind: Service
metadata:
name: nginx-internal-lb
namespace: default
annotations:
# Private frontend instead of a public IP
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
# Take the frontend address from this subnet rather than the node subnet
service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "snet-aks-internal-lb"
# Optional: pin a static address from that subnet (10.40.85.0/24)
# service.beta.kubernetes.io/azure-load-balancer-ipv4: "10.40.85.10"
spec:
type: LoadBalancer
selector:
app: nginx
ports:
- port: 80
targetPort: 80
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: nginx-external-lb
namespace: default
annotations:
# Public frontend: omit the internal annotation. Name the public IP resource,
# and choose the resource group it is created in (defaults to the node RG).
service.beta.kubernetes.io/azure-pip-name: "pip-nginx-external"
service.beta.kubernetes.io/azure-load-balancer-resource-group: "rg-aks-nonprod"
# Optional: reuse an existing public IP instead
# service.beta.kubernetes.io/azure-load-balancer-ipv4: "<public-ip-address>"
spec:
type: LoadBalancer
selector:
app: nginx
ports:
- port: 80
targetPort: 80
protocol: TCP
Without a subnet annotation, internal frontends land in the node subnet
An internal load balancer that does not name a subnet takes its frontend address from the
node subnet. That is where the Kafka internal frontends ended up (10.40.84.x), even though
the VNet had a dedicated snet-aks-internal-lb. Either annotate every internal service or
drop the dedicated subnet; half and half confuses every allocation table.
Kafka on the cluster¶
Kafka ran as a KRaft-mode StatefulSet (no ZooKeeper) from a Kustomize base, with one overlay per
environment namespace (dev-kafka, sdev-kafka, test-kafka). Each overlay only set the
namespace and patched the controller quorum voter address to match it:
namespace: dev-kafka
resources:
- ../../base
- namespace.yaml
patches:
- target:
kind: StatefulSet
name: kafka
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/4/value
value: "1@kafka-0.kafka-headless.dev-kafka.svc.cluster.local:9094"
The base exposed three services:
| Service | Type | Ports | Purpose |
|---|---|---|---|
kafka-headless |
ClusterIP: None |
9092 broker, 9093 client TLS, 9094 controller | Stable pod DNS for in-cluster clients and the controller quorum |
kafka-external |
LoadBalancer, public |
9092, 9093 | Public bootstrap, allowlisted by source range, published in DNS by external-dns |
kafka-internal |
LoadBalancer |
9092 → 9095, 9093 → 9096 | The broker's internal listeners for clients in the peered network |
apiVersion: v1
kind: Service
metadata:
name: kafka-external
namespace: dev-kafka
annotations:
external-dns.alpha.kubernetes.io/hostname: kafka.dev.example.com
spec:
type: LoadBalancer
# Preserve the client source address so loadBalancerSourceRanges can see it
externalTrafficPolicy: Local
loadBalancerSourceRanges:
- 203.0.113.10/32 # office
- 198.51.100.11/32 # engineer
- 198.51.100.12/32 # engineer
- 203.0.113.25/32 # hub firewall egress
- 203.0.113.50/32 # this cluster's own egress
selector:
app: kafka
ports:
- name: broker
port: 9092
targetPort: 9092
- name: controller-tls
port: 9093
targetPort: 9093
An internal service needs the internal annotation on AKS
type: LoadBalancer with no azure-load-balancer-internal annotation gets a public
frontend on AKS. A service called kafka-internal is only internal if the annotation says
so, and it has no loadBalancerSourceRanges to fall back on. Check with
kubectl get svc -n dev-kafka kafka-internal -o wide: an internal frontend shows a private
EXTERNAL-IP.
Advertised listeners decide whether a client works¶
A Kafka client does two things: it connects to a bootstrap address, then it reconnects to
whatever addresses the broker returns in its advertised.listeners metadata. External access
breaks when the broker advertises addresses the client cannot resolve or route to, typically
internal *.svc.cluster.local names. The symptom is a TCP connection that succeeds followed by
a protocol that fails:
# TCP reaches the load balancer
nc -vznt kafka.dev.example.com 9092
# Connection ... succeeded!
# But the Kafka protocol fails
kcat -b kafka.dev.example.com:9092 -L
# % ERROR: Failed to acquire metadata: Local: Broker transport failure
The fix is a listener per path, each advertising a name that is reachable from where its
clients sit: an EXTERNAL listener advertising kafka.dev.example.com on 9092/9093 for the
public load balancer, and internal listeners (9095/9096 behind kafka-internal) for the peered
network.
Diagnosis checklist¶
# 1. Is the external listener present and advertising a public host?
kubectl exec -n dev-kafka kafka-0 -- \
grep -E '^listeners=|^advertised.listeners=' /mnt/shared/config/server.properties
# 2. Does the load balancer have an address and live endpoints?
kubectl get svc kafka-external -n dev-kafka
kubectl get endpoints kafka-external -n dev-kafka
# 3. Does the public name resolve to that address? (external-dns)
dig +short kafka.dev.example.com
# 4. Is your source address in loadBalancerSourceRanges?
kubectl get svc kafka-external -n dev-kafka -o jsonpath='{.spec.loadBalancerSourceRanges}'
# 5. End-to-end metadata probe
kcat -b kafka.dev.example.com:9092 -L
| Observation | Likely cause |
|---|---|
TCP connects, metadata fails, debug shows *.svc.cluster.local |
The external entry in advertised.listeners is missing or wrong |
| Connection refused or timeout at TCP | Source address not in loadBalancerSourceRanges, or the load balancer has no address |
| TLS handshake errors on 9093 | Plaintext client pointed at the TLS port, or the other way round |
| Works in-cluster, fails externally | The client is resolving the internal advertised name; bootstrap through the public host instead |
Private DNS for the internal load balancers¶
Clients in the application spokes reached the internal frontends by name through a private DNS
zone, example.internal, linked to the spokes that needed it (not to the AKS VNet, whose pods
use cluster DNS). Auto-registration was off; the records were plain A records.
resource "azurerm_private_dns_zone" "this" {
name = var.zone_name
resource_group_name = var.resource_group_name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "this" {
for_each = var.vnet_links # link name => VNet ID
name = each.key
resource_group_name = var.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.this.name
virtual_network_id = each.value
registration_enabled = var.registration_enabled
tags = var.tags
}
resource "azurerm_private_dns_a_record" "kafka" {
for_each = var.kafka_internal_records # record name => IP
name = each.key
zone_name = azurerm_private_dns_zone.this.name
resource_group_name = var.resource_group_name
ttl = var.ttl
records = [each.value]
tags = var.tags
}
inputs = {
zone_name = "example.internal"
vnet_links = {
"link-app-dev" = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-spokes/providers/Microsoft.Network/virtualNetworks/vnet-app-dev"
"link-app-sdev" = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-spokes/providers/Microsoft.Network/virtualNetworks/vnet-app-sdev"
"link-app-tst" = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-spokes/providers/Microsoft.Network/virtualNetworks/vnet-app-tst"
}
registration_enabled = false
kafka_internal_records = {
"kafka.dev" = "10.40.84.7"
"kafka.sdev" = "10.40.84.4"
"kafka.tst" = "10.40.84.6"
}
ttl = 3600
}
Take link names as input
The first version of the module derived each link's name from the VNet ID
(link-${basename(vnet_id)}). The links had been created by hand with different names, and
a link cannot be renamed in place, so adopting them into state meant changing the module to
take the names explicitly. Taking names as input costs nothing and makes imports possible.
Bastion TCP forwarder¶
Clients without a routable, allowlisted source address (see Problem 2) went through a bastion VM in the landing zone that forwarded one port per environment to that environment's internal Kafka frontend:
| Service | Bastion port | Target |
|---|---|---|
kafka-dev-proxy |
9092 | 10.224.0.120:9092 |
kafka-test-proxy |
9093 | 10.224.0.121:9092 |
kafka-sdev-proxy |
9094 | 10.224.0.122:9092 |
The targets are addresses from the period when the cluster ran on the AKS-managed default
network (10.224.0.0/16). The forwarder was plain socat under systemd, installed with
az vm run-command so nobody needed an SSH session on the bastion:
az vm run-command invoke \
-n vm-bastion \
-g rg-devops \
--command-id RunShellScript \
--scripts \
"sudo apt update && sudo apt install -y socat" \
"sudo tee /etc/systemd/system/kafka-dev-proxy.service <<EOF
[Unit]
Description=Kafka Dev Port Forward
After=network.target
[Service]
Type=simple
User=nobody
ExecStart=/usr/bin/socat TCP-LISTEN:9092,fork,reuseaddr TCP:10.224.0.120:9092
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF" \
"sudo systemctl daemon-reload" \
"sudo systemctl enable --now kafka-dev-proxy.service"
The test and sdev units were identical apart from the listen port and target. Each unit runs as
User=nobody with Restart=always and a five-second back-off, so a broker restart or a bastion
reboot brings the proxy back on its own.
# Status of the proxies
az vm run-command invoke -n vm-bastion -g rg-devops --command-id RunShellScript \
--scripts "sudo systemctl status kafka-dev-proxy.service kafka-test-proxy.service kafka-sdev-proxy.service --no-pager"
# From a client that can reach the bastion
nc -vznt <BASTION_IP> 9092
Not managed by anything
The units existed only on the VM. Neither Terraform nor GitOps knew about them, so a rebuilt bastion lost them silently, and nothing flagged them when the cluster went away.
TCP only: listeners still matter
socat forwards raw TCP; it does not rewrite Kafka's advertised listeners. A client that
bootstraps through the bastion is then redirected to whatever the broker advertises, and that
address must be reachable from the client too.
Connection tester in an application spoke¶
To prove what an application in a spoke could actually reach, a small Linux App Service (Python,
gunicorn, a B2 plan) ran with regional VNet integration into a subnet of the application spoke
delegated to Microsoft.Web/serverFarms. Three settings are what make such a tester behave like
a real workload in the spoke:
virtual_network_subnet_idset to the delegated spoke subnet, andvnet_route_all_enabled = true, so all outbound traffic, not just RFC 1918, took the spoke's routes.WEBSITE_DNS_SERVER = 168.63.129.16, so it resolved names through Azure DNS and therefore through the private zones linked to the spoke.ip_restrictionentries allowing only the office and engineers' addresses to the app itself.