Azure Application Gateway con WAF y wildcard + Nginx Controller para AKS

Actualizado 👍 11/04/2023

Hace ya unas cuantas semanas atrás, mis compañeros Carlos Mendible, David Sancho y una servidora estuvimos dándole vueltas a una necesidad de un cliente que requería lo siguiente: la idea era utilizar Azure Application Gateway, por su característica de WAF, como Ingress de Kubernetes. El problema es que a día de hoy, al poner este en modo WAF solo soporta hasta 40 listeners (a día de hoy, en 2023, soporta 100) y en este escenario consideraban que se les quedaría pequeño en un futuro cercano. Es por ello que se presentó la siguiente alternativa: usar Application Gateway con la posibilidad de usar wildcard para el listener, de tal manera que pudiéramos tener más de un listener en uno, y que un Ingress Controller, como por ejemplo Nginx, tuviera las reglas necesarias para saber a dónde tenía que mandar la petición.

En este artículo te comparto la prueba de concepto que se desarrolló, por si te fuera de utilidad.

Configuración del clúster

Lo primero que necesito es un clúster de Kubernetes sobre el que montar esta prueba. Para ello puedo utilizar los siguientes comandos, para crear el grupo de recursos y este:

# Variables
RESOURCE_GROUP="waf-and-nginx-ingress-controller"
LOCATION="northeurope"
AKS_NAME="aks-with-nginx-ingress"
VNET_NAME="aks-vnet"
AKS_SUBNET="aks-subnet"
# Create a resource group
az group create -n $RESOURCE_GROUP -l $LOCATION
# Create a VNET and aks subnet
az network vnet create -g $RESOURCE_GROUP -n $VNET_NAME \
--address-prefixes 10.0.0.0/8 \
--subnet-name $AKS_SUBNET \
--subnet-prefix 10.10.0.0/16
# Create a user identity for the AKS
az identity create --name $AKS_NAME-identity --resource-group $RESOURCE_GROUP
# Get managed identity ID
IDENTITY_RESOURCE_ID=$(az identity show --name $AKS_NAME-identity --resource-group $RESOURCE_GROUP --query id -o tsv)
IDENTITY_CLIENT_ID=$(az identity show --name $AKS_NAME-identity --resource-group $RESOURCE_GROUP --query clientId -o tsv)
# Get VNET id
VNET_ID=$(az network vnet show -g $RESOURCE_GROUP -n $VNET_NAME --query id -o tsv)
SUBNET_ID=$(az network vnet subnet show -g $RESOURCE_GROUP -n $AKS_SUBNET --vnet-name $VNET_NAME --query id -o tsv)
#Give AKS permissions to the VNET
az role assignment create --assignee $IDENTITY_CLIENT_ID --scope $VNET_ID --role "Network Contributor"
# Create an AKS
az aks create \
--name $AKS_NAME \
--resource-group $RESOURCE_GROUP \
--node-vm-size Standard_B4ms \
--vnet-subnet-id $SUBNET_ID \
--generate-ssh-keys \
--enable-managed-identity \
--assign-identity $IDENTITY_RESOURCE_ID
# Get credentials
az aks get-credentials -n $AKS_NAME -g $RESOURCE_GROUP

Ahora ya tenemos un clúster limpio donde montar el escenario descrito al inicio.

Instalar Nginx Controller

Lo siguiente que vamos a hacer es desplegar un Ingress con Nginx que será el encargado de enrutar a los servicios correctos cuando llegue una petición desde mi WAF. Para esta prueba vamos a instalar la configuración básica que aparece en la documentación:

# Install nginx as ingress controller
NAMESPACE=ingress-basic
INTERNAL_LOAD_BALANCER_IP_FOR_NGINX_INGRESS="10.10.0.50"
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
# https://learn.microsoft.com/en-us/azure/aks/internal-lb
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --create-namespace \
  --namespace $NAMESPACE \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-health-probe-request-path"=/healthz \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-internal"=true \
  --set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-ipv4"=$INTERNAL_LOAD_BALANCER_IP_FOR_NGINX_INGRESS
kubectl get svc -n ingress-basic -w

Durante la instalación con Helm le he pasado unas anotaciones para el health probe, para indicar que el balanceador que cree debe ser interno y cuál es la IP interna que quiero que utilice.

Una vez que finalice, podemos comprobar que el balanceador tiene la IP correcta:

kubectl get svc -n ingress-basic

y ya podemos poner algunas aplicaciones de ejemplo.

Clientes de ejemplo y health probe

Ahora vamos a simular que tenemos varios despliegues en nuestro clúster, que corresponden a diferentes clientes y entornos para estos. Este es un ejemplo de un entorno de dev para client1:

apiVersion: v1
kind: Namespace
metadata:
  name: client1-dev
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-client1-dev-whoami
  namespace: client1-dev
spec:
  selector:
    matchLabels:
      app: webapp-client1-dev-whoami
  replicas: 3
  template:
    metadata:
      labels:
        app: webapp-client1-dev-whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: svc-client1-dev-whoami
  namespace: client1-dev
spec:
  selector:
    app: webapp-client1-dev-whoami
  ports:
    - port: 7070
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webapp-client1-dev-ingress
  namespace: client1-dev
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
    - host: client1.dev.azuredemo.es
      http:
        paths:          
          - path: /
            pathType: Prefix
            backend:
              service:
                name: svc-client1-dev-whoami
                port:
                  number: 7070

Como ves, creo un conjunto de recursos con estos nombres:

  • Namespace: client1-dev
  • Deployment: webapp-client1-dev-whoami
  • Service: svc-client1-dev-whoami
  • Ingress: webapp-client1-dev-ingress

Me he preocupado de que todos los recursos que pertenecen al mismo entorno tenga el identificador del cliente al que corresponde para que, a la hora de validar la prueba, supiera que estaba bien. Hay otra carpeta en el repositorio de GitHub que simula hasta tres clientes más, siguiendo la misma estructura con el nombre de los clientes.

Clientes de prueba

Puedes desplegar estos utilizando el siguiente comando:

# Create client environments
kubectl apply -f client-environments/.

Por otro lado, además de los clientes, he creado un despliegue en un namespace diferente para las pruebas de disponibilidad que necesita Application Gateway, para saber que un backend está respondiendo correctamente:

apiVersion: v1
kind: Namespace
metadata:
  name: probe
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-probe-whoami
  namespace: probe
spec:
  selector:
    matchLabels:
      app: webapp-probe-whoami
  replicas: 3
  template:
    metadata:
      labels:
        app: webapp-probe-whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: svc-probe-whoami
  namespace: probe
spec:
  selector:
    app: webapp-probe-whoami
  ports:
    - port: 7070
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webapp-probe-ingress
  namespace: probe
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - http:
      paths:
      - path: /check
        pathType: Prefix
        backend:
          service:
            name: svc-probe-whoami
            port:
              number: 7070

Para desplegarlo, puedes utilizar este comando:

# Create probe test
kubectl apply -f health-probe/app-gw-probe.yaml

Si quisieras comprobar que este test funciona correctamente puedes hacerlo a través de estos comandos:

kubectl run -it --rm aks-ingress-test --image=mcr.microsoft.com/aks/fundamental/base-ubuntu:v0.0.11 --namespace ingress-basic
apt-get update && apt-get install -y curl
curl -L http://10.10.0.50/check
exit

Application Gateway

El siguiente paso es crear un Application Gateway en modo WAF dentro de una subnet de la red donde se encuentra este clúster de Kubernetes, por simplicidad en la PoC. El mismo se conectará de manera interna con el ingress controller al que le pasará las peticiones con el hostname con el que le han llegado. Para todo esto puedes utilizar los siguientes comandos:

# Create a WAF policy
GENERAL_WAF_POLICY="general-waf-policies"
az network application-gateway waf-policy create \
--name $GENERAL_WAF_POLICY \
--resource-group $RESOURCE_GROUP \
--type OWASP \
--version 3.2
# Create an App Gw with WAF enabled
az network application-gateway create \
-n $APPGW_NAME \
-g $RESOURCE_GROUP \
--sku WAF_v2 \
--public-ip-address $APPGW_PUBLIC_IP_NAME \
--vnet-name $VNET_NAME \
--subnet $APPGW_SUBNET \
--capacity 1 \
--priority 1 \
--waf-policy $GENERAL_WAF_POLICY
# Update default backend pool with the load balancer for nginx ingress
az network application-gateway address-pool update \
--gateway-name $APPGW_NAME \
--resource-group $RESOURCE_GROUP \
--name appGatewayBackendPool \
--servers $INTERNAL_LOAD_BALANCER_IP_FOR_NGINX_INGRESS
# Now update the default listener, using wildcard, that points to the Nginx Controller Ip and routes to the proper web app
# https://learn.microsoft.com/en-us/azure/application-gateway/multiple-site-overview#wildcard-host-names-in-listener-preview (It's not in preview anymore)
# https://azure.microsoft.com/en-us/updates/general-availability-wildcard-listener-on-application-gateways/
az network application-gateway http-listener update \
  --name appGatewayHttpListener \
  --frontend-ip appGatewayFrontendIP \
  --frontend-port appGatewayFrontendPort \
  --resource-group $RESOURCE_GROUP \
  --gateway-name $APPGW_NAME \
  --host-names "*.$YOUR_DOMAIN"
#Update default rule that glues the listener and the backend pool
az network application-gateway rule update \
--gateway-name $APPGW_NAME \
--name "rule1" \
--resource-group $RESOURCE_GROUP \
--http-listener "appGatewayHttpListener" \
--rule-type "Basic" \
--address-pool "appGatewayBackendPool" \
--priority 1
#Create health probe
az network application-gateway probe create \
--gateway-name $APPGW_NAME \
--resource-group $RESOURCE_GROUP \
--name "probe-azuredemo-es" \
--host $INTERNAL_LOAD_BALANCER_IP_FOR_NGINX_INGRESS \
--path "/check" \
--protocol "Http"
#Add HTTP settings a custom probe
az network application-gateway http-settings update \
--gateway-name $APPGW_NAME \
--name "appGatewayBackendHttpSettings" \
--resource-group $RESOURCE_GROUP \
--probe "probe-azuredemo-es"
#See all ingress configured
kubectl get ingress --all-namespaces
# Check domain name
nslookup $YOUR_DOMAIN
dig $YOUR_DOMAIN

En mi ejemplo, voy a usar el dominio azuredemo.es al que configuro con un wildcard, de tal forma que pueda usar subdominios con el mismo listener.

Nota: esta funcionalidad todavía está en Preview, por lo que más allá de la prueba de concepto todavía no puede ser usada en un entorno productivo. Ya está en GA 🥳

Configuración del dominio

Para que todo esto funcione, además es necesario configurar el DNS de tu dominio para que acepte los subdominios que he utilizado para este ejercicio:

La IP que aparece para el registro A es la IP pública de mi Application Gateway.

Cómo probar que funciona

Si ahora compruebo todos los recursos de tipo Ingress que me he generado:

kubectl get ingress --all-namespaces

Debería de ver algo como lo siguiente:

recursos de tipo Ingess creados para enrutar las peticiones

Las pruebas que me he generado, para ver que efectivamente todo esto funciona, han sido utilizando la extensión para Visual Studio Code llamada REST Client y las he dejado en este archivo, el cual contiene lo siguiente:

###
GET http://client1.dev.azuredemo.es HTTP/1.1
###
GET http://client2.qa.azuredemo.es HTTP/1.1
###
GET http://client3.prep.azuredemo.es HTTP/1.1
###
GET http://client4.prep.azuredemo.es HTTP/1.1
###
GET http://client5.qa.azuredemo.es HTTP/1.1

El resultado sería algo como esto:

Resultado de la PoC

La última petición debería de fallar porque no hay ningún entorno para ese cliente (así confirmamos que funciona realmente cuando debe).

El código del ejemplo lo tienes en mi GitHub.

¡Saludos!