ExternalDNS integration with Azure DNS using workload identity
- Introduction
- Prerequisites
- Create Azure DNS zone, identity and grant permissions
- Install ExternalDNS on you cluster
- Adding ingress rules
- Manually managing the rules
- Troubleshooting problems
- Conclusion
Introduction
Managing DNS records manually is error-prone and just tedious. When deploying internet-facing apps to your Kubernetes cluster, setting domain names on ingress is a must. Why not take advantage of already defined ingress rules and enable your cluster to update the designated DNS zone using the workload identity mechanism and ExternalDNS controller?
In this guide, I’ll demonstrate the integration on Azure Kubernetes Service (AKS) with nginx ingress, Helm chart + Helmfile, Azure DNS, and workload identities. However, you can set up ExternalDNS not only on AKS but also on any Kubernetes cluster with various supported DNS providers.
Prerequisites
While ExternalDNS supports many authentication options, I strongly recommend using workload identities to authenticate from the cluster to Azure DNS resources. If you’re not using workload identities in your cluster yet, or you are using the deprecated AAD pod identity mechanism, follow this article.
This approach ties down permissions only to ExternalDNS pods, reducing the possibility of leaked secrets and potential serious problems if you lose control over your DNS zone. Additionally, ensure you have ingress installed on your cluster.
Create Azure DNS zone, identity and grant permissions
Before proceeding to install ExternalDNS on your cluster, set up Azure DNS zone, create a managed identity, and grant this identity the DNS Zone Contributor
role to allow updating DNS records. This can be achieved in various ways, such as using Terraform, Bicep, or Azure CLI. Alternatively, this can be done from the portal. However, I challenge you to maintain all your configurations within the git repository.
Terraform
The following Terraform snippets outlines the necessary resources to create and manage Azure DNS zones and identities. and assigns the necessary roles and permissions.
Variables file
variable "project_name" {
type = string
description = "Project name"
}
variable "resources_suffix" {
type = string
default = "common"
description = "suffix that will be added to created resources"
}
variable "environment" {
type = string
description = "Name of the environment"
}
variable "primary_location" {
type = object({
name = optional(string, "westeurope")
shortcode = optional(string, "weu")
})
description = "The Azure location where resources will be created"
}
variable "dns_zone_name" {
type = string
description = "dns zone name like example.com"
}
variable "oidc" {
type = object({
audience = optional(list(string), ["api://AzureADTokenExchange"])
issuer_url = string
kubernetes_namespace = optional(string, "external-dns")
kubernetes_serviceaccount_name = optional(string, "external-dns")
kubernetes_cluster_name = string
})
description = "Configure OIDC federation settings to establish a trusted token mechanism between the Kubernetes cluster and external systems."
}
Create DNS zone
resource "azurecaf_name" "this" {
resource_types = [
"azurerm_resource_group"
]
name = var.primaryLocation.shortcode
prefixes = [var.project_name, substr(var.environment, 0, 3)]
suffixes = [var.resources_suffix]
clean_input = true
}
resource "azurerm_resource_group" "this" {
name = format("%s-%02s", azurecaf_name.this.results["azurerm_resource_group"], 1)
location = var.primary_location.name
}
resource "azurerm_dns_zone" "this" {
name = var.zone_name
resource_group_name = azurerm_resource_group.this.name
}
Create managed identity for ExternalDNS service account
resource "azurecaf_name" "externaldns" {
resource_types = [
"azurerm_user_assigned_identity"
]
name = var.primaryLocation.shortcode
prefixes = [var.project_name, substr(var.environment, 0, 3)]
suffixes = [var.resources_suffix, "externaldns-01"]
clean_input = true
}
resource "azurerm_user_assigned_identity" "this" {
name = azurecaf_name.externaldns.results["azurerm_user_assigned_identity"]
location = azurerm_resource_group.this.location
resource_group_name = azurerm_resource_group.this.name
}
resource "azurerm_federated_identity_credential" "this" {
name = "${var.kubernetes_cluster.name}-ServiceAccount-${var.oidc.kubernetes_namespace}-${var.oidc.kubernetes_serviceaccount_name}"
resource_group_name = azurerm_resource_group.this.name
audience = var.oidc.audience
issuer = var.oidc.issuer_url
parent_id = azurerm_user_assigned_identity.this.id
subject = "system:serviceaccount:${var.oidc.kubernetes_namespace}:${var.oidc.kubernetes_serviceaccount_name}"
}
Grant permissions
resource "azurerm_role_assignment" "dns_zone_contributor" {
principal_id = azurerm_user_assigned_identity.this.principal_id
role_definition_name = "DNS Zone Contributor"
scope = azurerm_dns_zone.this.id
skip_service_principal_aad_check = true
}
Bicep
Sample template - resource group context
Parameters
param zoneName string
param aksClusterName string
param env string
param issuerUrl string
param managedIdentityName string
param externalDnsNamespace string = 'external-dns'
param externalDnsServiceAccount string = 'external-dns'
Create DNS zone
resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' = {
name: zoneName
location: 'global'
}
Create managed identity for ExternalDNS service account
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: managedIdentityName
location: resourceGroup().location
}
resource federatedCredentials 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31' = {
name: '${aksClusterName}-serviceaccount-${externalDnsNamespace}:${externalDnsServiceAccount}'
properties: {
audiences: [
'api://AzureADTokenExchange'
]
issuer: issuerUrl
subject: 'system:serviceaccount:${externalDnsNamespace}:${externalDnsServiceAccount}'
}
parent: managedIdentity
}
Grant permissions
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2023-04-01' = {
name: '${managedIdentityName}-dnsZoneContributor'
properties: {
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'befefa01-2a29-4197-83a8-272ff33ce314')
principalType: 'ServicePrincipal'
principalId: managedIdentity.properties.principalId
}
}
Azure CLI
Line breaks are for PowerShell if you are using different shell, replace ` with \
Set variables
$rgName = "dns-rg"
$location = "westeurope"
$identityName = "externaldns-identity"
$dnsZoneName="dev.cloudchronicles.blog"
$aksClusterName = "aks-cluster"
$aksRgName = "aks-rg"
Create DNS zone
az group create --name $rgName `
--location $location
az network dns zone create --resource-group $rgName `
--name $dnsZoneName
Create managed identity
az identity create --resource-group $rgName `
--name $identityName
$identityClientId = az identity show --resource-group $rgName `
--name $identityName `
--query "clientId" `
--output tsv
Grant DNS Zone Contributor to identity
$dnsId = az network dns zone show --name $dnsZoneName `
--resource-group $rgName `
--query "id" `
--output tsv
$dnsRgId = az group show --name $rgName `
--query "id" `
--output tsv
az role assignment create --role "DNS Zone Contributor" `
--assignee $identityClientId `
--scope $dnsId
Create federation between service account and identity
$oidcIssuerUrl = az aks show --name $aksClusterName `
--resource-group $aksRgName `
--query "oidcIssuerProfile.issuerUrl" `
--output tsv
az identity federated-credential create --name $identityName `
--identity-name $identityName `
--resource-group $rgName `
--issuer $oidcIssuerUrl `
--subject "system:serviceaccount:external-dns:external-dns"
Install ExternalDNS on you cluster
Two Helm charts are provided for your convenience, one published by Bitnami and another by Kubernetes Sigs. My personal preference is the Bitnami chart due to its popularity, though the choice ultimately depends on your specific requirements.
Setting resource limits for ExternalDNS pods is optional, but highly recommended for optimal performance and resource management on your cluster. The suggested values can serve as a helpful starting point based on my experience.
Although most of the configuration is generic, there are specific entries tailored for an Azure DNS scenario, particularly with workload identity:
Specify the DNS provider for creating DNS records.
provider: azure
Configure Azure DNS, ensuring that tenantId
, subscriptionId
, and resourceGroup
name point to the correct values for the DNS zone resource location.
azure:
useWorkloadIdentityExtension: true
tenantId: < azure tenant id >
subscriptionId: < subscription id with dns zone resource >
subscriptionId: < resource group name with dns zone resource >
Configure pod labels and service account annotations for workload identity.
podLabels:
azure.workload.identity/use: "true"
serviceAccount:
annotations:
azure.workload.identity/client-id: < the clientId of managed identity with DNS zone permissions >
Azure DNS does not support *
in the middle of DNS entries like AWS Route53 or GCP DNS. ExternalDNS, by default, attempts to create a-*
entries for wildcard ingress rules, resulting in errors.
time="2022-08-01T13:10:44Z" level=info msg="Updating TXT record named 'a-*.example' to '\"heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/xxx/yyy\"' for Azure DNS zone 'example.com'."
time="2022-08-01T13:10:44Z" level=error msg="Failed to update TXT record named 'a-*.example' to '\"heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/xxx/yyy\"' for DNS zone 'example.com': dns.RecordSetsClient#CreateOrUpdate: Failure responding to request: StatusCode=400 -- Original Error: autorest/azure: Service returned an error. Status=400 Code=\"BadRequest\" Message=\"The domain name 'a-*.example.example.com' is invalid. The provided record set relative name 'a-*.example' is invalid.\""
To address this, override *
with a specific value, such as wildcard
, using the txt-wildcard-replacement
argument during deployment.
extraArgs:
txt-wildcard-replacement: "wildcard"
Helm
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install external-dns bitnami/external-dns `
--version 6.32.0 `
--namespace=external-dns `
--set provider=azure `
--set policy=sync `
--set azure.useWorkloadIdentityExtension=true `
--set azure.resourceGroup=rg-name-with-dns-zone-resource ` # the resource group name where DNS zone is located
--set azure.subscriptionId=c32d5ab5-478d-47ud-bc61-230d5511a0f0 `
--set azure.tenantId=ald2e401-0911-47f6-8e20-13819f4bd107 `
--set extraArgs.txt-wildcard-replacement=wildcard `
--set serviceAccount.annotations."azure\.workload\.identity/client-id"=7d878f79-e2c7-41ee-a592-57ac74f14096 `
--set podLabels."azure\.workload\.identity/use"=true `
--set txtOwnerId=external-dns `
--set logLevel=info `
--set domainFilters[0]=dev.cloudchronicles.blog `
--set domainFilters[1]=dev.next.domain ` # if you want to add more than one domain
--set resources.requests.cpu=10m `
--set resources.requests.memory=32Mi `
--set resources.limits.cpu=50m `
--set resources.limits.memory=64Mi
Helmfile
To avoid redundancy and potential errors in configuration, I use two values files: one for general settings shared across all environments values.yaml
, and another for environment-specific configurations values.env.yaml
. However, you can merge these files into a single values.yaml
and eliminate environment-specific files if desired.
helmfile.yaml
environments:
dev:
prd:
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
releases:
- name: external-dns
namespace: external-dns
chart: bitnami/external-dns
version: 6.32.0
values:
- values.yaml
- values.{{ .Environment.Name }}.yaml
values.yaml
provider: azure
policy: sync
azure:
useWorkloadIdentityExtension: true
tenantId: < azure tenant id >
txtOwnerId: external-dns
extraArgs:
txt-wildcard-replacement: "wildcard"
podLabels:
azure.workload.identity/use: "true"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 50m
memory: 64Mi
values.dev.yaml
azure:
resourceGroup: < resource group name with dns zone resource >
subscriptionId: < subscription id with dns zone resource >
logLevel: info
domainFilters:
- dev.cloudchronicles.blog
- dev.next.domain #if you want to add more than one
serviceAccount:
annotations:
azure.workload.identity/client-id: < the clientId of managed identity with DNS zone permissions >
To apply above helmfile configuration, run:
helmfile --environment dev --file "helmfile.yaml" sync
Check status
There is no GUI for ExternalDNS. If you don’t believe helm/helmfile status that deployment was successful, then you can run following command:
kubectl get pods -n external-dns -l app.kubernetes.io/name=external-dns
# adjust namespace name and/or pod label name if you selected another name or namespace
NAME READY STATUS RESTARTS AGE
external-dns-c764ddc4-j8lhb 1/1 Running 0 8d
…or if you are using AKS, then you can check it in portal.
Adding ingress rules
ExternalDNS will now monitor all ingress rules and try to create records for rules matching domains provided in domainFilters
. With the policy: sync
, if you modify or delete an ingress rule, it will update accordingly. If you want to change this behavior, for example, to only modify a record if there is a replacement value, set the ExternalDNS policy to upsert
.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontend
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/instance: frontend
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- "dev.cloudchronicles.blog"
- "*.dev.cloudchronicles.blog"
secretName: wildcard-dev-cloudchronicles-blog
rules:
- host: "dev.cloudchronicles.blog"
http:
paths:
- path: /
pathType: ImplementationSpecific
backend:
service:
name: frontend
port:
number: 80
Above configuration is enough for ExternalDNS. Ingress domain name matches domain specified in configuration, so controller will create new records for the domain.
As you can see, ExternalDNS created TXT records for each A record. Thanks to that, it knows which records are under its control.
TXT "heritage=external-dns,external-dns/owner=external-dns,external-dns/resource=ingress/frontend-namespace/frontend"
Manually managing the rules
ExternalDNS creates TXT records to know which records it can manage. If you want to manually modify a record created by ExternalDNS, simply remove the associated TXT record and update existing record.
You can also create a new record that won’t be overridden by ExternalDNS, even if someone creates an ingress rule for such name.
If you delete an existing A record created by ExternalDNS, the controller will recreate it within a few seconds.
Troubleshooting problems
In case you encounter any issues with updating your DNS records, check the logs, which are very descriptive.
kubectl logs -n external-dns -l app.kubernetes.io/name=external-dns
# adjust namespace name and/or pod label name if you selected another name or namespace
It’s also worth mentioning that ExternalDNS has a large community, so you should quickly find answers to your problems on Stack Overflow or receive replies to your issues in the GitHub repository..
Conclusion
Using ExternalDNS is a great option for those who want to simplify DNS management. No more complex scripts and orchestrating changes between the DNS provider and the Kubernetes cluster.