Ravindra BagaleCourses & study guides मराठी

Chapter 65: Kubernetes Domain Names and HTTPS: Ingress, DNS, ExternalDNS, cert-manager and Gateway API

65.3 ExternalDNS with Route 53: DNS records that follow your Ingress

Why: the problem

In 65.2 we created DNS records by hand. That works for one site, but Razorpay runs hundreds of services across several clusters, and every new Ingress, every recreated load balancer and every deleted app means a DNS change. Humans forget. A forgotten record is either a broken site (record points at a deleted load balancer) or a security hole (a dangling record that someone else can take over by creating a resource with that address). ExternalDNS is a small controller that runs inside the cluster. It watches Ingress, Service and Gateway API route objects, reads their host names and the load balancer address in their status, and creates matching records in your DNS provider: Route 53, Cloudflare, Google Cloud DNS, Azure DNS and many more. When you delete the Ingress, it deletes the record. It never touches records it did not create, because it writes an ownership TXT record next to each one, stamped with a txt-owner-id you choose, such as the cluster name. Safety settings matter. --domain-filter limits ExternalDNS to one zone, so a typo cannot create records in your main company domain. --policy=upsert-only lets it create and update but never delete, which is a common first step in production; --policy=sync also deletes, which keeps the zone clean. --registry=txt with a unique --txt-owner-id lets two clusters share one zone without fighting. On AWS, ExternalDNS needs IAM permission to change records in that one hosted zone, given through IRSA (IAM roles for service accounts) or EKS Pod Identity, never through access keys in a Secret. In this lesson we see ExternalDNS work with no cloud account by using its in-memory provider on kind, then write the real Route 53 setup for EKS: a scoped IAM policy, the service account annotation and the Deployment flags, and finally see records appear and disappear in Route 53 as Ingress objects come and go.

What: every term in plain English

  • ExternalDNS — Controller that syncs DNS records from Kubernetes objects to a DNS provider.
  • Source — The object types ExternalDNS reads, such as ingress, service and gateway-httproute.
  • Provider — The DNS service it writes to, such as aws for Route 53.
  • Hosted zone — A Route 53 container for one domain's records, with a zone ID.
  • --domain-filter — Restricts changes to names under one domain.
  • --policy — sync creates, updates and deletes; upsert-only never deletes.
  • TXT registry — Ownership records that mark which records this ExternalDNS instance manages.
  • txt-owner-id — Unique name per cluster written into the ownership records.
  • IRSA — IAM roles for service accounts: a pod gets AWS permissions through a signed service account token.
  • Dangling record — A DNS record still pointing at a deleted resource; a takeover risk.
Run ExternalDNS with the in-memory provider on kind. Create an Ingress with a host. Delete the Ingress. Write the Route 53 IAM policy for one zone. Create the IRSA service account. Deploy ExternalDNS for aws. Create an Ingress on EKS.
ExternalDNS keeps Route 53 in sync with Ingress

How: step by step

  1. Run ExternalDNS with the in-memory provider on kind. Logs show planned records.
  2. Create an Ingress with a host. ExternalDNS plans an A record.
  3. Delete the Ingress. Record is removed with sync policy.
  4. Write the Route 53 IAM policy for one zone. Least privilege.
  5. Create the IRSA service account. Role attached.
  6. Deploy ExternalDNS for aws. Pod Running.
  7. Create an Ingress on EKS. Record appears in Route 53.
  8. Check TXT ownership records. Owner id visible.

Example 1: See ExternalDNS work on kind with the in-memory provider

Goal. See ExternalDNS work on kind with the in-memory provider.

external-dns-inmemory.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services", "endpoints", "pods", "nodes"]
    verbs: ["get", "watch", "list"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: external-dns}
subjects:
  - {kind: ServiceAccount, name: external-dns, namespace: kube-system}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: kube-system
spec:
  replicas: 1
  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.19.0
          args:
            - --source=ingress
            - --provider=inmemory
            - --inmemory-zone=example.com
            - --domain-filter=example.com
            - --policy=sync
            - --registry=txt
            - --txt-owner-id=ch65-kind
            - --interval=20s

What each line does

  • RBAC — ExternalDNS only reads Kubernetes objects, so get, watch and list are enough.
  • --source=ingress — It reads hosts from Ingress rules and the address from Ingress status.
  • --provider=inmemory — A fake zone in memory, perfect to learn the behaviour with no cloud account.
  • --txt-owner-id — Marks records as owned by this cluster.
  • --interval=20s — How often it compares desired and actual records.

Green output

$ kubectl apply -f external-dns-inmemory.yaml
deployment.apps/external-dns created
$ kubectl get ingress storefront -n shop
NAME         CLASS   HOSTS                                   ADDRESS
storefront   nginx   shop.example.com,www.shop.example.com   localhost
$ kubectl logs -n kube-system deploy/external-dns --tail=4
level=info msg="CREATE: shop.example.com 0 IN CNAME localhost []"
level=info msg="CREATE: www.shop.example.com 0 IN CNAME localhost []"
level=info msg="CREATE: a-shop.example.com 0 IN TXT \"heritage=external-dns,external-dns/owner=ch65-kind\""
$ kubectl delete ingress storefront -n shop
$ kubectl logs -n kube-system deploy/external-dns --tail=2
level=info msg="DELETE: shop.example.com 0 IN CNAME localhost []"
level=info msg="DELETE: www.shop.example.com 0 IN CNAME localhost []"

Expected result. ExternalDNS read the Ingress hosts, created a record per host plus an ownership TXT record, and removed them when the Ingress was deleted. Because the address is a name (localhost), it chose CNAME; on AWS with an alias-capable target it writes alias A records.

Example 2: Give ExternalDNS least-privilege Route 53 access with IRSA

Goal. Give ExternalDNS least-privilege Route 53 access with IRSA.

external-dns-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["route53:ChangeResourceRecordSets"],
      "Resource": ["arn:aws:route53:::hostedzone/Z0123456789EXAMPLE"]
    },
    {
      "Effect": "Allow",
      "Action": ["route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ListTagsForResources"],
      "Resource": ["*"]
    }
  ]
}

irsa.sh

aws iam create-policy --policy-name ExternalDNSShopZone --policy-document file://external-dns-policy.json
eksctl create iamserviceaccount \
  --cluster shop-prod --namespace kube-system --name external-dns \
  --attach-policy-arn arn:aws:iam::111122223333:policy/ExternalDNSShopZone \
  --role-name shop-prod-external-dns --approve
kubectl get sa external-dns -n kube-system -o jsonpath='{.metadata.annotations}'

What each line does

  • One zone ARN — Change rights only on the shop zone, not every zone in the account.
  • List actions on * — Route 53 list calls cannot be scoped to one zone, and they are read-only.
  • 111122223333 — AWS's documentation placeholder account ID; use yours.
  • eksctl create iamserviceaccount — Creates the IAM role with a trust policy for this one service account and annotates it.
  • No access keys — The pod gets short-lived credentials from its service account token.

Green output

$ bash irsa.sh
{
    "Policy": {
        "PolicyName": "ExternalDNSShopZone",
        "Arn": "arn:aws:iam::111122223333:policy/ExternalDNSShopZone"
    }
}
created serviceaccount "kube-system/external-dns"
{"eks.amazonaws.com/role-arn":"arn:aws:iam::111122223333:role/shop-prod-external-dns"}

Expected result. The service account now carries a role ARN. Any pod using it gets temporary credentials that can change only the shop zone, which is how Razorpay-style platform teams keep DNS automation from touching the main company domain.

Example 3: Run ExternalDNS for Route 53 and watch records follow an Ingress

Goal. Run ExternalDNS for Route 53 and watch records follow an Ingress.

external-dns-aws-args.yaml

args:
  - --source=ingress
  - --source=service
  - --provider=aws
  - --aws-zone-type=public
  - --domain-filter=shop.example.com
  - --policy=upsert-only
  - --registry=txt
  - --txt-owner-id=shop-prod
  - --txt-prefix=edns-
  - --interval=1m

check.sh

kubectl rollout status deploy/external-dns -n kube-system
kubectl logs -n kube-system deploy/external-dns --tail=5
aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLE \
  --query "ResourceRecordSets[?contains(Name,'shop')].[Name,Type,AliasTarget.DNSName]" --output table

What each line does

  • --provider=aws — Writes to Route 53 using the IRSA credentials.
  • --domain-filter=shop.example.com — Even with rights to the zone, it ignores names outside this suffix.
  • --policy=upsert-only — Start safe: create and update only. Switch to sync after you trust it.
  • --txt-prefix=edns- — Puts ownership TXT records under edns-name, so they never clash with a CNAME on the same name.
  • --source=service — LoadBalancer Services with the external-dns.alpha.kubernetes.io/hostname annotation also get records.

Green output

$ bash check.sh
deployment "external-dns" successfully rolled out
level=info msg="Applying provider record filter for domains: [shop.example.com. .shop.example.com.]"
level=info msg="Desired change: CREATE shop.example.com A [Id: /hostedzone/Z0123456789EXAMPLE]"
level=info msg="Desired change: CREATE edns-a-shop.example.com TXT [Id: /hostedzone/Z0123456789EXAMPLE]"
level=info msg="2 record(s) in zone shop.example.com. [Id: /hostedzone/Z0123456789EXAMPLE] were successfully updated"
-------------------------------------------------------------------------------------------------
|                                    ListResourceRecordSets                                     |
+----------------------------+-----+------------------------------------------------------------+
|  shop.example.com.         |  A  |  k8s-ingressn-ingressn-1a2b3c4d5e-0123456789.elb.ap-south-1.amazonaws.com. |
|  edns-a-shop.example.com.  | TXT |  None                                                      |
+----------------------------+-----+------------------------------------------------------------+

Expected result. Within one interval an alias A record pointing at the controller's NLB appeared in Route 53, with a TXT ownership record beside it. No ticket, no console click, and the record will follow the load balancer if it is ever recreated.

Every command, step by step (kind lab with kubectl)

  1. In-memory

    kubectl apply -f external-dns-inmemory.yaml
    

    What it does: Fake zone.

    deployment.apps/external-dns created
    
  2. Logs

    kubectl logs -n kube-system deploy/external-dns --tail=4
    

    What it does: Planned records.

    CREATE: shop.example.com 0 IN CNAME localhost
    
  3. Delete

    kubectl delete ingress storefront -n shop
    

    What it does: Sync removes.

    DELETE: shop.example.com
    
  4. Restore

    kubectl apply -f hosts.yaml
    

    What it does: Back.

    ingress.networking.k8s.io/storefront created
    
  5. Policy

    aws iam create-policy --policy-name ExternalDNSShopZone --policy-document file://external-dns-policy.json
    

    What it does: Scoped.

    "PolicyName": "ExternalDNSShopZone"
    
  6. IRSA

    eksctl create iamserviceaccount --cluster shop-prod --namespace kube-system --name external-dns ...
    

    What it does: Role.

    created serviceaccount "kube-system/external-dns"
    
  7. Annotation

    kubectl get sa external-dns -n kube-system -o yaml
    

    What it does: Role ARN.

    eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/shop-prod-external-dns
    
  8. Rollout

    kubectl rollout status deploy/external-dns -n kube-system
    

    What it does: Running.

    successfully rolled out
    
  9. Records

    aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLE
    

    What it does: Alias A.

    shop.example.com.  A
    
  10. Lookup

    dig +short shop.example.com
    

    What it does: Load balancer IPs.

    13.235.10.21
    

When it is red 1

AccessDenied: not authorized to perform: route53:ChangeResourceRecordSets

Cause. Policy scoped to a different zone ID or IRSA not attached.

Fix, step by step

  1. Check the zone ID in the policy.
  2. kubectl get sa external-dns -o yaml for the role annotation.

When it is red 2

No records created and logs say 'All records are already up to date'

Cause. Ingress has no address in status yet, or the host is outside --domain-filter.

Fix, step by step

  1. kubectl get ingress; wait for ADDRESS.
  2. Check the filter.

When it is red 3

Skipping record because it is not owned by this instance

Cause. A record exists with another owner id or was made by hand.

Fix, step by step

  1. Delete the manual record once, or use a different name.

When it is red 4

Two clusters keep overwriting the same record

Cause. Both use the same txt-owner-id.

Fix, step by step

  1. Give each cluster a unique --txt-owner-id.

When it is red 5

Records never deleted

Cause. Policy is upsert-only.

Fix, step by step

  1. Expected; switch to --policy=sync when ready.

Practice tasks

Easy Task 1: Deploy

Why: In-memory.

What to do: Apply external-dns-inmemory.yaml.

How:
  1. kind cluster.
Solution
kubectl get deploy -n kube-system external-dns

Easy Task 2: Create

Why: Watch.

What to do: Create an Ingress and read the logs.

How:
  1. CREATE lines.
Solution
kubectl logs -n kube-system deploy/external-dns

Easy Task 3: Delete

Why: Watch.

What to do: Delete the Ingress and read the logs.

How:
  1. sync policy.
Solution
kubectl logs -n kube-system deploy/external-dns --tail=3

Medium Task 4: Policy

Why: Scope.

What to do: Write a policy for one zone.

How:
  1. ChangeResourceRecordSets.
Solution
cat external-dns-policy.json

Medium Task 5: IRSA

Why: Attach.

What to do: Create the service account with eksctl.

How:
  1. Role ARN.
Solution
kubectl get sa external-dns -n kube-system -o yaml

Medium Task 6: Args

Why: Safe.

What to do: Write aws args with upsert-only.

How:
  1. domain-filter.
Solution
kubectl get deploy external-dns -n kube-system -o yaml

Hard Task 7: Two clusters

Why: Share.

What to do: Explain two owner ids in one zone.

How:
  1. txt-owner-id.
Solution
aws route53 list-resource-record-sets --hosted-zone-id Z0123456789EXAMPLE

Hard Task 8: Razorpay

Why: Main task.

What to do: In-memory demo plus AWS setup.

How:
  1. No access keys.
Solution
kubectl logs -n kube-system deploy/external-dns

Homework: try at home

For Razorpay's checkout team, run ExternalDNS with the in-memory provider on kind for pay.example.com, show create and delete in logs, then write the Route 53 IAM policy scoped to one zone, the IRSA command and the ExternalDNS args with upsert-only and a unique owner id.

Recap

  • ExternalDNS turns Ingress and Service host names into DNS records automatically.
  • TXT ownership records and txt-owner-id keep it away from records it did not make.
  • domain-filter and upsert-only are the safe starting settings.
  • On EKS give it a scoped IAM role through IRSA or Pod Identity, never keys.
  • Deleting an Ingress removes its record with sync policy, avoiding dangling records.

FAQ

Q. Can ExternalDNS work with Cloudflare instead of Route 53?

Yes. Set --provider=cloudflare and give it a scoped API token from a Kubernetes Secret created outside Git, or from a secrets manager.

Q. Does it support Gateway API?

Yes, with sources such as gateway-httproute; it reads hostnames from routes and addresses from the Gateway.

Q. How do I set TTL?

Add the annotation external-dns.alpha.kubernetes.io/ttl: "60" on the Ingress or Service.

Q. Is EKS Pod Identity better than IRSA?

Both give short-lived credentials. Pod Identity is simpler to set up on new EKS clusters; IRSA works everywhere including self-managed clusters.

Interview questions

  1. What problem does ExternalDNS solve?

    Model answer: Keeping DNS in sync with cluster resources automatically, preventing broken and dangling records.

  2. How do two clusters share one hosted zone safely?

    Model answer: Each runs ExternalDNS with a different txt-owner-id and often a different domain-filter.

  3. What IAM rights does it need?

    Model answer: ChangeResourceRecordSets on the target zone and read-only list calls.

  4. Why start with upsert-only?

    Model answer: So a mistake cannot delete production records while you gain confidence.

Quick quiz

Q1. What does ExternalDNS read?

Hosts and addresses from Ingress, Service and route objects.

Q2. How does it avoid touching other records?

TXT ownership records with txt-owner-id.

Q3. Which policy never deletes?

upsert-only.

Q4. How does it get AWS rights on EKS?

IRSA or Pod Identity.

Q5. What limits it to one domain?

--domain-filter.

Samajla ka?