Integrar Azure DNS y Azure Private DNS con Kubernetes con ExternalDNS

No nos gusta trabajar con IPs. De hecho, debería de estar prohibido ⛔️ tener que acoplar una IP a una aplicación o servicio como puntero a otro servicio, y más en un mundo donde ahora todo es escalable y automatizable y las IPs pueden variar con mayor facilidad. En Azure tenemos dos servicios para gestionar nuestro dominios, ya sean internos, con Azure Private DNS, o externos, con Azure DNS. En este artículo te cuento cómo puedes hacer que tu clúster actualice estos dos servicios de forma transparente para ti a través del proyecto ExternalDNS.

Configurar las variables

Para este artículo voy a utilizar las siguientes variables:

RESOURCE_GROUP="aks-external-dns"
AKS_NAME="aks-cluster"
LOCATION="northeurope"
AKS_SUBNET_NAME="aks-subnet"
PUBLIC_DNS_ZONE_NAME="tour-of-heroes.es"
PRIVATE_DNS_ZONE_NAME="tour-of-heroes.internal"
INGRESS_NAMESPACE=ingress-controller
VM_SUBNET_NAME="vm-subnet"
VM_NAME="jumpboxvm"
HIGLIGHT='\033[0;33m'
NC='\033[0m' # No Color
echo -e "${HIGLIGHT}Variables set!${NC}"

Como ves, en este ejemplo voy a jugar con dos dominios: uno público, tour-of-heroes.es, y otro privado, tour-of-heroes.internal. Empecemos con el público 😊

Crear el grupo de recursos y una zona de Azure DNS

Para que veas cómo funciona ExternalDNS, lo primero que necesitas es un grupo de recursos y una zona de Azure DNS, que será la que gestione tu dominio público, en mi caso tour-of-heroes.es:

echo -e "${HIGLIGHT}Create the resource group...${NC}"
az group create --name $RESOURCE_GROUP --location $LOCATION
RESOURCE_GROUP_ID=$(az group show -n $RESOURCE_GROUP --query id -o tsv)
echo -e "${HIGLIGHT}Create the DNS zone ${PUBLIC_DNS_ZONE_NAME}...${NC}"
DNS_ZONE_ID=$(az network dns zone create -g $RESOURCE_GROUP -n $PUBLIC_DNS_ZONE_NAME --query id -o tsv)
echo -e "${HIGLIGHT}There are the nameserver you should point your domain:${NC}"
az network dns zone show -g $RESOURCE_GROUP -n $PUBLIC_DNS_ZONE_NAME --query nameServers -o tsv

El último comando te devolverá los name servers que deberás de configurar en tu proveedor del dominio que elijas para las pruebas. La propagación puede tardar hasta una hora en ocurrir, por lo que aquí un poco de paciencia 😙

Crear un clúster de Kubernetes

Para este ejemplo, voy a utilizar un clúster de AKS donde desplegaré ExternalDNS y las demos que te mostrarán cómo funciona todo esto:

echo -e "${HIGLIGHT}Create the cluster...${NC}"
time az aks create \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--name $AKS_NAME \
--node-vm-size Standard_B4ms \
--node-count 1
az aks get-credentials -g $RESOURCE_GROUP -n $AKS_NAME --overwrite-existing

He utilizado la configuración básica, ya que para este ejemplo no necesitas nada más.

Desplegar External DNS para Azure DNS

Ahora que ya tienes una zona de Azure DNS y un clúster que la gestione, el siguiente paso es desplegar ExternalDNS con la configuración para dominios públicos:

echo -e "${HIGLIGHT}Fetching the kubelet identity...${NC}"
KUBELET_IDENTITY_CLIENT_ID=$(az aks show --resource-group $RESOURCE_GROUP --name $AKS_NAME --query "identityProfile.kubeletidentity.objectId" --output tsv)
echo -e "${HIGLIGHT}Assign the 'DNS Zone Contributor' role...${NC}"
az role assignment create --role "DNS Zone Contributor" --assignee $KUBELET_IDENTITY_CLIENT_ID --scope $DNS_ZONE_ID
echo -e "${HIGLIGHT}Create a configuration file for the identity...${NC}"
cat <<EOF > azure.json
{
    "tenantId": "$(az account show --query tenantId -o tsv)",
    "subscriptionId": "$(az account show --query id -o tsv)",
    "resourceGroup": "$RESOURCE_GROUP",
    "useManagedIdentityExtension": true
}
EOF
echo -e "${HIGLIGHT}Create the secret for the identity...${NC}"
kubectl create secret generic azure-config-file --namespace "default" --from-file azure.json
echo -e "${HIGLIGHT}Deploy external-dns...${NC}"
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services","endpoints","pods", "nodes"]
    verbs: ["get","watch","list"]
  - apiGroups: ["extensions","networking.k8s.io"]
    resources: ["ingresses"]
    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: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
spec:
  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.13.4
          args:
            - --source=service
            - --source=ingress
            # - --domain-filter=$PUBLIC_DNS_ZONE_NAME # (optional)
            - --provider=azure
            - --azure-resource-group=$RESOURCE_GROUP # (optional) 
            - --publish-internal-services
          volumeMounts:
            - name: azure-config-file
              mountPath: /etc/kubernetes
              readOnly: true
      volumes:
        - name: azure-config-file
          secret:
            secretName: azure-config-file
EOF
kubectl get all

En este ejemplo lo primero que hago es recuperar la identidad de kubelet para poder asignarle el rol DNS Zone Contributor, para que el clúster pueda modificar el recurso de Azure DNS con los servicios y los ingress que se vayan desplegando. Además, ExternalDNS necesita de un secreto con ciertos valores como la suscripción, el tenant, el grupo de recursos y el tipo de autenticación que puede utilizar para poder llamar a la API de ARM, que es a través de la cuál va a hacer las diferentes operaciones. En este caso, como estoy utilizando un clúster de AKS puedo hacer uso de useManagedIdentityExtension, aunque también podría utilizar un service principal (y Azure AD Pod Identity, y esperemos que pronto Workload identity). Con todo ello, ya puedes desplegar una service account, el rol asociada a la misma y el despliegue donde se especifican los valores necesarios para esta configuración con Azure DNS. En el caso de este, debemos utilizar como provider azure y podemos restringir si se quiere a un único dominio y grupo de recursos donde estén los DNS zone que quieras que gestione.

Una vez desplegado ya puedes jugar con algunas demos.

Desplegar demos de ejemplo para ExternalDNS y Azure DNS

Para probar este entorno voy a hacer uso de dos ejemplos: El primero de ellos, el que viene con la documentación de ExternalDNS, pero con la instalación de NGINX controller incluida 😙, donde despliego el ingress controller y un nginx de ejemplo:

echo -e "${HIGLIGHT}Deploy nginx ingress controller using Helm...${NC}"
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --create-namespace \
  --namespace $INGRESS_NAMESPACE \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-health-probe-request-path"=/healthz
  
echo -e "${HIGLIGHT}Deploy nginx demo...${NC}"
kubectl apply -f external-dns/manifests/nginx.yaml

En este caso el despliegue de Nginx como controller es exactamente igual que lo harías siempre y el ejemplo de Ingress sería como el siguiente:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - image: nginx
          name: nginx
          ports:
          - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-svc
spec:
  ports:
    - port: 80
      protocol: TCP
      targetPort: 80
  selector:
    app: nginx
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx
spec:
  ingressClassName: nginx
  rules:
    - host: nginx.tour-of-heroes.es
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-svc
                port:
                  number: 80

Como ves, en el caso del Ingress hago un uso totalmente normal y es ExternalDNS quien va a monitorizarlos y va a fijarse en la propiedad host de estos. En este caso, va a intentar añadir el registro nginx en la zona tour-of-heroes.es, creada como Azure DNS. Puedes comprobarlo en los logs del despliegue:

External DNS agrega un registro a Azure DNS apuntando a la IP pública del Ingress Controller

En el caso de que el registro que haya que crear provenga de un Ingress Controller se apuntará el mismo a la IP del controller.

Es por ello que quise hacer otra demo más, donde se utilice directamente un servicio, como en el caso de mi demo de Tour of heroes. Para este ejemplo modifiqué los servicios de la API:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: tour-of-heroes-api
  name: tour-of-heroes-api
  annotations:
    external-dns.alpha.kubernetes.io/hostname: api.tour-of-heroes.es
spec:
  type: LoadBalancer
  ports:
  - name: web
    port: 80
    targetPort: 5000
  selector:
    app: tour-of-heroes-api

y de la web:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: tour-of-heroes-web
  name: tour-of-heroes-web
  annotations:
    external-dns.alpha.kubernetes.io/hostname: web.tour-of-heroes.es
spec:
  type: LoadBalancer
  ports:
  - name: web
    port: 80
    targetPort: 80
  selector:
    app: tour-of-heroes-web

Para que esto funcione correctamente solo he tenido que añadir la anotación external-dns.alpha.kubernetes.io/hostname con el subdominio que espero que tenga dentro de mi dominio. Al cabo de unos instantes external DNS mapeará estos directamente con la IP de estos servicios.

ExternalDNS mapea la IP de los servicios de tipo LoadBalancer con los hostname de la anotación

Sin embargo, esta configuración solo es válida cuando estamos pensando en dominios públicos que queremos gestionar de forma automatizada. Si quisiéramos hacer lo mismo para DNS privados fíjate en lo que viene a continuación 👇

Configurar External DNS para Azure Private DNS

Para probar este otro escenario, debemos crear otro tipo de recurso llamado Azure Private DNS. Este podremos asociarlo a nuestras redes privadas y que los servicios asociados a estas puedan consultar este servicio para resolver nombre internos de nuestra arquitectura. Para probarlo voy a crear uno donde gestione el dominio tour-of-heroes.internal:

echo -e "${HIGLIGHT}Create private DNS zone $PRIVATE_DNS_ZONE_NAME...${NC}"
PRIVATE_DNS_ZONE_ID=$(az network private-dns zone create -g $RESOURCE_GROUP -n $PRIVATE_DNS_ZONE_NAME --query id -o tsv)
echo -e "${HIGLIGHT}Assign the 'DNS Zone Contributor' role...${NC}"
az role assignment create --role "Reader" --assignee $KUBELET_IDENTITY_CLIENT_ID --scope $RESOURCE_GROUP_ID
az role assignment create --role "Private DNS Zone Contributor" --assignee $KUBELET_IDENTITY_CLIENT_ID --scope $PRIVATE_DNS_ZONE_ID

Al igual que con Azure DNS, en este caso también necesito dar permisos sobre el nuevo recurso a la identidad de kubelet para que ExternalDNS pueda gestionarlo.

Desplegar ExternalDNS con la configuración para Azure Private DNS

El mismo despliegue de External DNS no puede configurar tanto internos como externos, porque el proveedor que necesita en cada configuración es diferente. Es por ello que he creado un nuevo despliegue llamado external-dns-private que queda como el siguiente:

echo -e "${HIGLIGHT}Deploy external-dns...${NC}"
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns-private
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
  - apiGroups: [""]
    resources: ["services","endpoints","pods", "nodes"]
    verbs: ["get","watch","list"]
  - apiGroups: ["extensions","networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get","watch","list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns-viewer-private
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
  - kind: ServiceAccount
    name: external-dns-private
    namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns-private
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns-private
  template:
    metadata:
      labels:
        app: external-dns-private
    spec:
      serviceAccountName: external-dns-private
      containers:
        - name: external-dns-private
          image: registry.k8s.io/external-dns/external-dns:v0.13.4
          args:
            - --source=service
            - --source=ingress
            # - --domain-filter=$PUBLIC_DNS_ZONE_NAME # (optional)
            - --provider=azure-private-dns
            - --azure-resource-group=$RESOURCE_GROUP # (optional) 
            - --txt-prefix=externaldns-
            - --publish-internal-services
          volumeMounts:
            - name: azure-config-file
              mountPath: /etc/kubernetes
              readOnly: true
      volumes:
        - name: azure-config-file
          secret:
            secretName: azure-config-file
EOF
kubectl get all

La principal diferencia aquí está en el despliegue, donde he cambiado azure por azure-private-dns en la propiedad provider. Al resto le he puesto el sufijo -private, y estoy reaprovechando incluso el archivo de configuración para la demo (lo ideal en un entorno productivo es que cada uno tuviera el suyo, para que no fueran dependientes).

Desplegar la demo para ExternalDNS y Azure Private DNS

Para comprobar que funciona correctamente, he desplegado de nuevo mi Tour of heroes en un nuevo namespace llamado internal:

echo -e "${HIGLIGHT}Deploy tour-of-heroes demo...${NC}"
kubectl create ns internal
kubectl apply -f external-dns/manifests/tour-of-heroes-internal --recursive -n internal
echo -e "${HIGLIGHT}Wait a few seconds...${NC}"
sleep 10
echo -e "${HIGLIGHT}Check logs external-dns...${NC}"
kubectl logs -n default -l app=external-dns-private
echo -e "${HIGLIGHT}Check DNS records...${NC}"
az network private-dns record-set list --resource-group $RESOURCE_GROUP --zone-name $PRIVATE_DNS_ZONE_NAME -o table

En este caso el servicio de la API cambia de la siguiente manera:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: tour-of-heroes-api
  name: tour-of-heroes-api
  annotations:
    service.beta.kubernetes.io/azure-load-balancer-internal: "true"
    external-dns.alpha.kubernetes.io/hostname: api.tour-of-heroes.internal
spec:
  type: LoadBalancer
  ports:
    - name: web
      port: 80
      targetPort: 5000
  selector:
    app: tour-of-heroes-api

y lo mismo ocurre con la web:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: tour-of-heroes-web
  name: tour-of-heroes-web
  annotations:
    service.beta.kubernetes.io/azure-load-balancer-internal: "true"
    external-dns.alpha.kubernetes.io/hostname: web.tour-of-heroes.internal
spec:
  type: LoadBalancer
  ports:
    - name: web
      port: 80
      targetPort: 80
  selector:
    app: tour-of-heroes-web

En ambos he especificado que el servicio de tipo LoadBalancer sea interno, a través de la anotación service.beta.kubernetes.io/azure-load-balancer-internal, para que me asigne un balanceador de Azure a cada uno de ellos con un IP dentro de la subnet, y he modificado el hostname al que hace alusión a mi dominio .internal. Puedes desplegar el ejemplo completo con estos comandos:

echo -e "${HIGLIGHT}Deploy tour-of-heroes demo...${NC}"
kubectl create ns internal
kubectl apply -f external-dns/manifests/tour-of-heroes-internal --recursive -n internal
echo -e "${HIGLIGHT}Wait a few seconds...${NC}"
sleep 10
echo -e "${HIGLIGHT}Check logs external-dns...${NC}"
kubectl logs -n default -l app=external-dns-private
echo -e "${HIGLIGHT}Check DNS records...${NC}"
az network private-dns record-set list --resource-group $RESOURCE_GROUP --zone-name $PRIVATE_DNS_ZONE_NAME -o table

Al igual que en el caso anterior, ExternalDNS se percatará de que hay dos nuevos servicios que puede configurar según los permisos y los recursos que tiene y, por otro lado, descartará aquellos que se corresponden con Azure DNS, ya que detecta que no hay ningún Azure Private DNS con ese dominio configurado:

External DNS descarta los hostnames que no corresponden con Azure Private DNS y mapea las IPs privadas de los balanceadores con los hostname

Para probar este escenario, he asociado este Azure Private DNS a la red que creó el clúster de AKS, he creado una nueva subnet en la red y he agregado una máquina virtual que hace un nslookup y curl a los dos subdominios ya configurados en mi recurso:

# Get the vnet name from AKS resource group
AKS_RESOURCE_GROUP=$(az aks show --resource-group $RESOURCE_GROUP --name $AKS_NAME --query nodeResourceGroup -o tsv)
# Get the vnet name from AKS resource group
VNET_NAME=$(az network vnet list --resource-group $AKS_RESOURCE_GROUP --query "[0].name" -o tsv)
VNET_ID=$(az network vnet list --resource-group $AKS_RESOURCE_GROUP --query "[0].id" -o tsv)
# Connect the private DNS zone with the AKS cluster vnet
az network private-dns link vnet create \
--resource-group $RESOURCE_GROUP \
--zone-name $PRIVATE_DNS_ZONE_NAME \
--name $PRIVATE_DNS_ZONE_NAME \
--virtual-network $VNET_ID \
--registration-enabled false
# Create a subnet for the vm
VM_SUBNET_ID=$(az network vnet subnet create \
  --resource-group $AKS_RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --name $VM_SUBNET_NAME \
  --address-prefixes 10.225.0.0/16 \
  --query id -o tsv)
RANDOM_PASSWORD=$(openssl rand -base64 32)
# Create a vm
az vm create \
--resource-group $RESOURCE_GROUP \
--name $VM_NAME \
--image UbuntuLTS \
--size Standard_B4ms \
--admin-username azureuser \
--admin-password $RANDOM_PASSWORD \
--subnet $VM_SUBNET_ID
# Get the public ip of the vm
VM_IP=$(az vm list-ip-addresses --resource-group $RESOURCE_GROUP --name $VM_NAME --query '[0].virtualMachine.network.publicIpAddresses[0].ipAddress' -o tsv)
# Connect to the vm
echo "This is the password for the VM: $RANDOM_PASSWORD"
ssh azureuser@$VM_IP
# Install curl
sudo apt-get update && apt-get install curl -y
# Call web.tour-of-heroes.internal
nslookup web.tour-of-heroes.internal
nslookup api.tour-of-heroes.internal
curl http://web.tour-of-heroes.internal
curl http://api.tour-of-heroes.internal/api/hero

El ejemplo completo lo tienes en mi GitHub.

¡Saludos!