Integrating Kubernetes External-DNS with BIND for Automatic DNS Management

Integrating Kubernetes External-DNS with BIND for Automatic DNS Management

External-DNS is a Kubernetes controller that automatically creates and manages DNS records based on Kubernetes resources like Services and Ingresses. When combined with BIND using the RFC2136 (dynamic DNS) provider, you get fully automated DNS management for your Kubernetes workloads without relying on cloud DNS providers.

How External-DNS Works

External-DNS watches Kubernetes resources (Services, Ingresses, Gateway API resources) and synchronizes DNS records with an external DNS provider. For BIND, it uses the RFC2136 protocol - the same dynamic DNS update mechanism we covered in the previous post.

The workflow:

  1. You deploy a Service or Ingress with DNS annotations
  2. External-DNS detects the resource and its annotations
  3. External-DNS sends DNS UPDATE requests to your BIND server
  4. BIND creates or updates the DNS records
  5. When resources are deleted, External-DNS removes the corresponding records

Prerequisites

BIND Configuration

Your BIND server needs to accept dynamic updates. Generate a TSIG key for External-DNS:

tsig-keygen -a hmac-sha256 external-dns-key > /etc/named/keys/external-dns.key

Configure BIND to accept updates:

// /etc/named.conf

include "/etc/named/keys/external-dns.key";

zone "k8s.example.com" {
    type primary;
    file "/var/named/dynamic/k8s.example.com.zone";
    update-policy {
        grant external-dns-key zonesub ANY;
    };
};

// If managing reverse DNS
zone "1.168.192.in-addr.arpa" {
    type primary;
    file "/var/named/dynamic/192.168.1.rev";
    update-policy {
        grant external-dns-key zonesub PTR;
    };
};

Create the initial zone file:

; /var/named/dynamic/k8s.example.com.zone
$TTL 300
@   IN  SOA ns1.example.com. admin.example.com. (
        2024010101  ; Serial
        3600        ; Refresh
        1800        ; Retry
        604800      ; Expire
        300 )       ; Minimum TTL

    IN  NS  ns1.example.com.

Deploying External-DNS

Create TSIG Secret

First, create a Kubernetes secret containing the TSIG key:

# Extract the secret from the key file
TSIG_SECRET=$(grep secret /etc/named/keys/external-dns.key | cut -d '"' -f2)

kubectl create namespace external-dns

kubectl create secret generic external-dns-tsig \
  --namespace external-dns \
  --from-literal=tsig-secret="$TSIG_SECRET"

Or as a YAML manifest:

apiVersion: v1
kind: Secret
metadata:
  name: external-dns-tsig
  namespace: external-dns
type: Opaque
stringData:
  tsig-secret: "your-base64-tsig-secret-here"

RBAC Configuration

apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  namespace: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services", "endpoints", "pods"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["extensions", "networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "watch", "list"]
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["list", "watch"]
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["gateways", "httproutes", "tlsroutes", "tcproutes", "udproutes"]
    verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
  - kind: ServiceAccount
    name: external-dns
    namespace: external-dns

External-DNS Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: external-dns
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
        - name: external-dns
          image: registry.k8s.io/external-dns/external-dns:v0.14.0
          args:
            - --source=service
            - --source=ingress
            - --provider=rfc2136
            - --rfc2136-host=192.168.1.10
            - --rfc2136-port=53
            - --rfc2136-zone=k8s.example.com
            - --rfc2136-tsig-keyname=external-dns-key
            - --rfc2136-tsig-secret-alg=hmac-sha256
            - --rfc2136-tsig-axfr
            - --domain-filter=k8s.example.com
            - --policy=sync
            - --registry=txt
            - --txt-owner-id=k8s-cluster
            - --interval=30s
            - --log-level=info
          env:
            - name: RFC2136_TSIG_SECRET
              valueFrom:
                secretKeyRef:
                  name: external-dns-tsig
                  key: tsig-secret

Configuration Options

Key Arguments Explained

Sources:

  • --source=service - Watch Kubernetes Services
  • --source=ingress - Watch Ingress resources
  • --source=gateway-httproute - Watch Gateway API HTTPRoutes

Provider Settings:

  • --provider=rfc2136 - Use RFC2136 dynamic DNS
  • --rfc2136-host - BIND server address
  • --rfc2136-port - DNS port (default 53)
  • --rfc2136-zone - Zone to manage
  • --rfc2136-tsig-keyname - TSIG key name (must match BIND config)
  • --rfc2136-tsig-secret-alg - Algorithm (hmac-sha256)
  • --rfc2136-tsig-axfr - Allow zone transfers to read existing records

Filtering:

  • --domain-filter - Only manage records in this domain
  • --namespace - Only watch resources in specific namespace
  • --annotation-filter - Only manage resources with specific annotations

Policies:

  • --policy=sync - Full sync: create, update, delete records
  • --policy=upsert-only - Only create/update, never delete
  • --policy=create-only - Only create new records

Registry:

  • --registry=txt - Use TXT records to track ownership
  • --txt-owner-id - Unique identifier for this External-DNS instance

Multiple Zones

To manage multiple zones, deploy multiple External-DNS instances or use comma-separated zones:

args:
  - --rfc2136-zone=k8s.example.com
  - --rfc2136-zone=apps.example.com
  - --domain-filter=k8s.example.com
  - --domain-filter=apps.example.com

Creating DNS Records

Via Services

Use the external-dns.alpha.kubernetes.io/hostname annotation:

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: my-app

For multiple hostnames:

metadata:
  annotations:
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com,app.k8s.example.com

Set custom TTL:

metadata:
  annotations:
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
    external-dns.alpha.kubernetes.io/ttl: "60"

Via Ingress

External-DNS automatically uses Ingress hostnames:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: myapp.k8s.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app
                port:
                  number: 80

The Ingress controller must populate the status.loadBalancer.ingress field for External-DNS to determine the target IP.

Via Gateway API

With Gateway API, External-DNS reads hostnames from HTTPRoute resources:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app-route
spec:
  parentRefs:
    - name: my-gateway
  hostnames:
    - myapp.k8s.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: my-app
          port: 80

Record Types

A Records

Created automatically for LoadBalancer Services with external IPs:

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
spec:
  type: LoadBalancer
  loadBalancerIP: 192.168.1.100  # Optional: request specific IP
  ports:
    - port: 80
  selector:
    app: my-app

CNAME Records

For services behind cloud load balancers that return hostnames:

metadata:
  annotations:
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com
    external-dns.alpha.kubernetes.io/target: elb.amazonaws.com

TXT Records

External-DNS creates TXT records to track ownership:

myapp.k8s.example.com.  300  IN  A    192.168.1.100
myapp.k8s.example.com.  300  IN  TXT  "heritage=external-dns,external-dns/owner=k8s-cluster,external-dns/resource=service/default/my-app"

This prevents External-DNS from accidentally modifying records it didn't create.

Advanced Configuration

Multiple Clusters

When multiple clusters share a zone, use different owner IDs:

# Cluster 1
args:
  - --txt-owner-id=cluster-prod
  - --txt-prefix=_edns-prod-

# Cluster 2
args:
  - --txt-owner-id=cluster-staging
  - --txt-prefix=_edns-staging-

Filtering by Annotation

Only manage resources with specific annotations:

args:
  - --annotation-filter=external-dns.alpha.kubernetes.io/enabled=true

Then annotate resources explicitly:

metadata:
  annotations:
    external-dns.alpha.kubernetes.io/enabled: "true"
    external-dns.alpha.kubernetes.io/hostname: myapp.k8s.example.com

Namespace Filtering

Limit External-DNS to specific namespaces:

args:
  - --namespace=production
  - --namespace=staging

Internal vs External Records

Run two External-DNS instances for split-horizon DNS:

# Internal DNS (for internal services)
- --source=service
- --rfc2136-host=internal-dns.example.com
- --rfc2136-zone=internal.k8s.example.com
- --annotation-filter=external-dns.alpha.kubernetes.io/internal=true

# External DNS (for public services)
- --source=ingress
- --rfc2136-host=external-dns.example.com
- --rfc2136-zone=k8s.example.com
- --annotation-filter=external-dns.alpha.kubernetes.io/internal!=true

Helm Installation

For production deployments, use the Helm chart:

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update

helm install external-dns external-dns/external-dns \
  --namespace external-dns \
  --create-namespace \
  --set provider=rfc2136 \
  --set rfc2136.host=192.168.1.10 \
  --set rfc2136.port=53 \
  --set rfc2136.zone=k8s.example.com \
  --set rfc2136.tsigKeyname=external-dns-key \
  --set rfc2136.tsigSecretAlg=hmac-sha256 \
  --set rfc2136.tsigAxfr=true \
  --set domainFilters[0]=k8s.example.com \
  --set policy=sync \
  --set txtOwnerId=k8s-cluster \
  --set extraEnv[0].name=RFC2136_TSIG_SECRET \
  --set-string extraEnv[0].valueFrom.secretKeyRef.name=external-dns-tsig \
  --set-string extraEnv[0].valueFrom.secretKeyRef.key=tsig-secret

Troubleshooting

Check External-DNS Logs

kubectl logs -n external-dns deployment/external-dns -f

Look for:

  • All records are already up to date - Normal operation
  • Updating record - Changes being made
  • TSIG error - Authentication issues
  • REFUSED - BIND rejecting updates

Verify BIND Received Updates

Enable update logging in BIND:

logging {
    channel update_log {
        file "/var/log/named/update.log" versions 5 size 10m;
        severity debug;
        print-time yes;
    };
    category update { update_log; };
    category update-security { update_log; };
};

Test Connectivity

From the External-DNS pod:

kubectl exec -n external-dns deployment/external-dns -- nslookup k8s.example.com 192.168.1.10

Manual DNS Test

Test dynamic updates manually:

nsupdate -v << EOF
server 192.168.1.10
key external-dns-key <secret>
zone k8s.example.com
update add test.k8s.example.com 300 A 192.168.1.99
send
EOF

Common Issues

Records not created:

  • Check --domain-filter matches your hostnames
  • Verify Service has status.loadBalancer.ingress populated
  • Check annotation spelling

TSIG authentication failed:

  • Verify key name matches exactly
  • Check algorithm matches (hmac-sha256)
  • Ensure secret is correctly encoded in the Kubernetes secret

Records deleted unexpectedly:

  • Check --policy setting (use upsert-only to prevent deletions)
  • Verify TXT ownership records exist
  • Check --txt-owner-id matches

Security Considerations

Limit Zone Access

Restrict External-DNS to specific subdomains:

update-policy {
    grant external-dns-key subdomain k8s.example.com ANY;
};

Network Policies

Restrict External-DNS network access:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: external-dns
  namespace: external-dns
spec:
  podSelector:
    matchLabels:
      app: external-dns
  policyTypes:
    - Egress
  egress:
    # Allow DNS to BIND server
    - to:
        - ipBlock:
            cidr: 192.168.1.10/32
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Allow API server access
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: TCP
          port: 443

Conclusion

External-DNS with BIND provides cloud-agnostic, automated DNS management for Kubernetes. By leveraging RFC2136 dynamic updates, you maintain full control over your DNS infrastructure while enjoying the convenience of automatic record management.

The next post will cover Response Policy Zones (RPZ) for implementing DNS-based security filtering and blocking.

Read more

HAProxy Monitoring with Prometheus: Complete Observability Guide

HAProxy Monitoring with Prometheus: Complete Observability Guide

Monitoring HAProxy is essential for maintaining reliable load balancing infrastructure. Prometheus provides powerful metrics collection, alerting capabilities, and seamless Grafana integration for visualizing HAProxy performance and health. Why Prometheus for HAProxy? Prometheus offers: * Pull-based metrics - Prometheus scrapes HAProxy metrics endpoints * Time-series database - Store historical data for trend analysis

By Patrick de Ruiter