Utilizar Azure Files con Private Endpoint como persistent volume en un AKS Multi-zona

Esta semana he tenido que configurar el siguiente escenario para uno de nuestros clientes: un AKS que utilice Azure Files para el montaje de volúmenes persistentes, donde la cuenta de almacenamiento solo es accesible a través de Private Link. Además este clúster tiene que ser multi-zona. En este artículo te comparto los pasos que he dado para montar este entorno.

Definir las variables y el grupo de recursos a utilizar

Antes de comenzar a crear los recursos, he definido un conjunto de variables y el grupo de recursos donde voy a añadir todos los componentes que necesito para este escenario.

#Variables
RESOURCE_GROUP="AKS-Demo"
AKS_NAME="ha-aks"
LOCATION="northeurope"
VNET="aks-vnet"
SUBNET_AKS="aks-subnet"
SUBNET_ENDPOINTS="endpoints"
PRIVATE_DNS_ZONE="private.file.core.windows.net"
PRIVATE_STORAGE_ACCOUNT="webapache"
RECORD_SET_NAME="web-apache-storage"
#Create Resource Group
az group create --name $RESOURCE_GROUP --location $LOCATION

Crear una Virtual Network

Tanto para mi nuevo clúster de AKS como para la cuenta de almacenamiento, a la que accederé de manera privada, necesito definir una red virtual como la siguiente:

#Create a VNET
az network vnet create --resource-group $RESOURCE_GROUP \
--name $VNET --address-prefixes 192.168.0.0/16 \
--subnet-name $SUBNET_AKS \
--subnet-prefixes 192.168.1.0/24
VNET_ID=$(az network vnet show --name $VNET --resource-group $RESOURCE_GROUP --query "id" --output tsv)
SUBNET_AKS_ID=$(az network vnet subnet show --name $SUBNET_AKS --resource-group $RESOURCE_GROUP --vnet-name $VNET --query "id" --output tsv)
#Create a subnet for the endpoints
az network vnet subnet create --resource-group $RESOURCE_GROUP \
--vnet-name $VNET --name $SUBNET_ENDPOINTS --address-prefixes 192.168.2.0/24 \
--disable-private-endpoint-network-policies true

Como ves, he recuperado además el ID de la VNET, así como el de la subnet donde irá el AKS. He creado también una segunda subnet a la que asociaré el endpoint privado de la cuenta de almacenamiento. El resultado debería de ser el siguiente:

aks-vnet con dos subnets

Crear un DNS privado

Para poder referirnos a nuestra cuenta de almacenamiento en la configuración que vamos a hacer para AKS creamos un DNS privado que vamos a asociar a la VNET que acabamos de crear.

#Create a private DNS zone
az network private-dns zone create --resource-group $RESOURCE_GROUP \
--name $PRIVATE_DNS_ZONE
#Create an association between the VNET and the private DNS zone
az network private-dns link vnet create \
--name "link_between_dns_and_vnet" \
--resource-group $RESOURCE_GROUP \
--zone-name $PRIVATE_DNS_ZONE \
--virtual-network $VNET \
--registration-enabled false

Con ello conseguimos que lo que esté dentro de dicha VNET sepa resolver los nombres que demos de alta como registros en este resolvedor de nombres privado. Si todo ha ido bien, en el Private Zone DNS deberías de ver la conexión con aks-vnet:

aks-vnet en los Virtual Network links de la private zone DNS

Crear una cuenta de almacenamiento y un endpoint privado para ella

Ahora que ya tienes la red virtual donde va a estar el AKS y la cuenta de almacenamiento, así como el DNS privado que resolverá los nombres, lo siguiente que voy a crear es la cuenta de almacenamiento:

# Create an Azure Storage only accesible via private link
# So you need:
# 1. Create a storage account
az storage account create -g $RESOURCE_GROUP \
--name $PRIVATE_STORAGE_ACCOUNT \
--sku Standard_ZRS \
--default-action Deny
STORAGE_ACCOUNT_ID=$(az storage account show --name $PRIVATE_STORAGE_ACCOUNT --resource-group $RESOURCE_GROUP --query "id" --output tsv)

En este caso es importante señalar que he puesto como –default-action a Deny, lo cual significa que nadie tiene acceso desde fuera por defecto.

Ahora lo siguiente que vamos a hacer es crear un private endpoint:

# 2. Create a private endpoint for the storage account
az network private-endpoint create --resource-group $RESOURCE_GROUP \
--name $PRIVATE_STORAGE_ACCOUNT \
--vnet-name $VNET \
--subnet $SUBNET_ENDPOINTS \
--location $LOCATION \
--connection-name $PRIVATE_STORAGE_ACCOUNT \
--group-id file \
--private-connection-resource-id $STORAGE_ACCOUNT_ID 

Esto lo que hace es crear una NIC asociada a esta cuenta de almacenamiento:

NIC para la Azure Storage account, creada a partir del private endpoint

lo que le permitirá tener una IP dentro de la subred que hemos indicado, correspondiente a la VNET que creamos anteriormente:

Ahora la cuenta de almacenamiento ya tiene una IP asociada en tu VNET

Recuperamos los valores de la NIC, para poder obtener la IP asociada dentro de la red desde el script, y damos de alta un nuevo registro en nuestro DNS privado para la cuenta de almacenamiento:

# 3. Get the ID of the azure storage NIC
STORAGE_NIC_ID=$(az network private-endpoint show --name $PRIVATE_STORAGE_ACCOUNT -g $RESOURCE_GROUP --query 'networkInterfaces[0].id' -o tsv)
# 4. Get the IP of the azure storage NIC
STORAGE_ACCOUNT_PRIVATE_IP=$(az resource show --ids $STORAGE_NIC_ID --query 'properties.ipConfigurations[0].properties.privateIPAddress' --output tsv)
# 5. Setup DNS with the new private endpoint
# The record set name to the private IP of the storage account
az network private-dns record-set a add-record \
--record-set-name $RECORD_SET_NAME \
--resource-group $RESOURCE_GROUP \
--zone-name $PRIVATE_DNS_ZONE \
--ipv4-address $STORAGE_ACCOUNT_PRIVATE_IP

El resultado debería de ser el siguiente:

Nuevo registro llamado web-apache-storage en el DNS privado

Crear el clúster de Kubernetes

Ahora que ya tenemos todas las piezas anteriores, lo último que nos queda, en cuanto a infraestructura, es el clúster de Kubernetes. Para ello podemos lanzar los siguientes comandos:

#Create a Service Principal
az ad sp create-for-rbac -n $AKS_NAME --role Contributor \
--scopes $STORAGE_ACCOUNT_ID $VNET_ID > auth.json
appId=$(jq -r ".appId" auth.json)
password=$(jq -r ".password" auth.json)
#Create AKS cluster
az aks create \
    --resource-group $RESOURCE_GROUP \
    --name $AKS_NAME \
    --generate-ssh-keys \
    --load-balancer-sku standard \
    --node-count 3 \
    --zones 1 2 3 \
    --vnet-subnet-id $SUBNET_AKS_ID \
    --service-principal $appId \
    --client-secret $password
    
# Get AKS credentials
az aks get-credentials --resource-group $RESOURCE_GROUP --name $AKS_NAME

Es importante que el Service Principal tenga permisos tanto sobre la VNET como sobre la cuenta de almacenamiento, para poder gestionar las acciones que se vayan solicitando. Este clúster, como comentamos al principio del artículo tiene habilitada la multi-zona, que simplemente es añadir la opción –zones y las zonas en las que quieres que se desplieguen los nodos (en este caso en la 1, 2 y 3). Una vez que termine podrás recuperar las credenciales de tu clúster y comenzar con las pruebas.

Comprobar que es un clúster multi-zona

Lo primero que me gustaría comprobar es que efectivamente el clúster se ha desplegado en las zonas que le he indicado. Para ello es tan sencillo como lanzar el siguiente comando:

#See the zone for the nodes
kubectl get nodes -o custom-columns=NAME:'{.metadata.name}',REGION:'{.metadata.labels.topology\.kubernetes\.io/region}',ZONE:'{metadata.labels.topology\.kubernetes\.io/zone}'

En mi ejemplo, que está desplegado en North Europe veré una salida como la siguiente:

Recuperar los nodos y sus zonas

Instalar el driver CSI para Azure Files

Ahora, para poder configurar Azure Files con private link es necesario usar el driver Azure Files CSI (Container Storage Interface). Para ello puedes hacerlo con el siguiente comando:

# Install CSI Driver for Azure Files
curl -skSL https://raw.githubusercontent.com/kubernetes-sigs/azurefile-csi-driver/v1.5.0/deploy/install-driver.sh | bash -s v1.5.0 --

Este creará diferentes recursos dentro de tu clúster que ayudarán a saber cómo gestionar la configuración que daremos de alta como una nueva Storage Class.

Nota: si quieres crear todo lo creado hasta ahora de manera dinámica, lee este otro artículo donde el driver CSI hace todo esto por ti.

Storage Class para volúmenes persistentes con Private Link

Tal y como indica la documentación oficial, y siguiendo con las variables de nuestro ejemplo, lo primero que voy a crear es una Storage Class que me permita interactuar con la cuenta de almacenamiento que hemos configurado a través de su enlace privado:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: azurefile-csi
provisioner: file.csi.azure.com
allowVolumeExpansion: true
parameters:
  resourceGroup: AKS-Demo
  storageAccount: webapache
  server:  web-apache-storage.private.file.core.windows.net
  shareName: content
reclaimPolicy: Delete
volumeBindingMode: Immediate
mountOptions:
  - dir_mode=0777
  - file_mode=0777
  - uid=0
  - gid=0
  - mfsymlinks
  - cache=strict  
  - nosharesock  
  - actimeo=30

Como ves, en ella a parte de especificar como provisioner file.csi.azure.com utilizo como parámetros el grupo de recursos donde está mi cuenta de almacenamiento, el nombre de la cuenta y el nombre completo que registré en mi zona DNS privada.

Persistent Volume Claim usando la nueva Storage Class

En este ejemplo, vamos a generar de forma dinámica los recursos que hacen falta y es por ello que voy a hacer uso de un PersistentVolumeClaim como el siguiente, que utiliza la storage class anterior:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: apache-content
spec:
  resources:
    requests:
      storage: 50Gi
  volumeMode: Filesystem
  storageClassName: azurefile-csi
  accessModes:
    - ReadWriteMany

Aplicación de ejemplo

Ya hemos llegado al final y es que solo queda probar que todo esto que «te he hecho configurar» 🙂 funciona correctamente. Para ello he creado una página en PHP muy sencilla que simplemente muestra el nombre del pod y el nodo que está sirviendo esta página:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Apache Web Server Information</title>
</head>
<body>
    <h1>Apache Web Server Information</h1>
    <p>
        <ul>
            <li><strong>Pod name</strong>: <?php echo gethostname(); ?></li>
            <li><strong>Node name</strong>: <?php echo getenv('NODE_NAME') ?></li>
        </ul>    
</body>
</html>

Para probarlo, primero voy a crear un despliegue con tres réplicas que utilice un Apache, con PHP configurado, que utilice el PersistentVolumeClaim anterior, que a su vez hace uso de la StorageClass que se entiende con la cuenta de almacenamiento por Private Link:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: apache-web-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: apache
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
          image: php:7.2-apache
          ports:
            - containerPort: 80
          volumeMounts:
            - mountPath: /var/www/html
              name: html
      volumes:
        - name: html
          persistentVolumeClaim:
            claimName: apache-content

Si te fijas, lo que hago es montar sobre la ruta que el Apache sirve los archivos un File Share de nuestra cuenta de almacenamiento, por lo que si todo va bien cuando se generen mis pods, se cree la carpeta content, todo lo que suelte ahí estará siendo servido por las tres réplicas.

Por último, creo un recurso de tipo Service que me facilite una IP pública para probar el escenario:

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 80

Si ejecuto todos estos archivos:

k apply -f k8s/

Deberá crearse la StorageClass, el PersistentVolumeClaim, el Service, y el Deployment (y su ReplicaSet) con tres Pods:

Recursos desplegados para apache-web-server correctamente

Como puedes ver, los pods se están ejecutando correctamente, la IP pública ya está asignada (52.155.89.205) y si compruebo los persistent volume claims y persistent volumes verás que tenemos uno creado, que además está montado en todas las réplicas, usando la clase que definimos:

Persistent volume y persistent volume claim para un Azure Storage con Private Link

Si ahora accedemos a la IP pública verás que no ves nada 🙂

apache-web-server todavía no sirve nada

Y esto es porque todavía no hemos subido el archivo index.php a la carpeta compartida que se ha creado como resultado de nuestra petición de volumen, a través del PersistentVolumeClaim. Para subir dicho archivo puedes hacer uso de este comando:

STORAGE_KEY=$(az storage account keys list -g $RESOURCE_GROUP -n $PRIVATE_STORAGE_ACCOUNT --query '[0].value' -o tsv)
# Upload file to azure files share
az storage file upload --source apache-content/index.php \
--account-name $PRIVATE_STORAGE_ACCOUNT \
--account-key $STORAGE_KEY \
--share-name content

Sin embargo, date cuenta de que al ser una cuenta de almacenamiento que solo permite el acceso privado a través de la VNET por defecto no es posible hacer esta subida desde tu local:

De hecho, si intentas acceder desde el portal al nuevo File Share verás el siguiente mensaje de error:

No se puede acceder al contenido de una Azure Storage con –default-action Deny

Para poder hacer la subida de este archivo para esa prueba necesitas habilitar en el Firewall de la cuenta de almacenamiento tu IP pública desde la que estás accediendo:

Añadir tu IP pública al firewall de la cuenta de almacenamiento

Una vez hecho esto podrás lanzar de nuevo el comando anterior sin problemas.

Subir archivos una vez la IP pública se añade en el firewall

A través de la IP pública del despliegue de apache-web-server podrás ver el resultado de todos los pods que sirven el contenido a través del File Share de la cuenta de almacenamiento a la que se está accediendo a través de private link.

apache-web-server usando Azure Storage a través de private link para servir index

Por otro lado, si quisieras probar los pods forzando el nodo, y por lo tanto la zona, puedes usar este otro ejemplo:

apiVersion: v1
kind: Pod
metadata:
  name: northeurope-1
  labels:
    name: northeurope-1
spec:
  containers:
   - name: apache
     env:
     - name: NODE_NAME
       valueFrom:
        fieldRef:
          fieldPath: spec.nodeName
     image: php:7.2-apache
     ports:
     - containerPort: 80
     volumeMounts:
      - mountPath: /var/www/html
        name: html
  volumes:
  - name: html
    persistentVolumeClaim:
      claimName: apache-content
  nodeName: aks-nodepool1-25530853-vmss000000
---
apiVersion: v1
kind: Service
metadata:
  name: web-in-northeurope-1
spec:
  type: LoadBalancer
  selector:
    name: northeurope-1
  ports:
  - port: 80
    targetPort: 80

Solo debes cambiar el valor nodeName por el nombre del nodo donde quieres desplegar el pod.

El código del ejemplo lo tienes en mi GitHub. También te dejo el artículo de mi compañero Carlos Mendible, que ha hecho un ejercicio parecido ¡además con Bicep y Terraform!

¡Saludos!