AWS, EKS, gestione domini e TLS con Ingress

di Andrea Zani, in terraform,

In un post precedente avevo trattato l'uso dei domini e dei certificati SSL in AWS. In un post più antico avevo scritto alcune informazioni riguardante l'uso di Ingress e Kubernetes. E arrivato il momento di unire il tutto?

Avviare Kubernetes in AWS

Per avere un cluster Kubernetes avviato in AWS ci sono tre possibili metodi:

  • Aws Console dal browser
  • Da terminale con il comando eksctl
  • Terraform

Tralascio il primo metodo solo per gusto personale perché lo trovo scomodo e troppo spesso mi ha portato ad errori.

Con il secondo metodo, comando eksctl, si può creare un Cluster per Kubernetes in AWS con questo comando da terminale:

eksctl create cluster \
--name my-cluster \
--version 1.22 \
--region eu-south-1  \
--nodegroup-name linux-nodes-for-k8s \
--node-type t3.small \
--nodes 1

E per cancellarlo:

eksctl delete cluster --name my-cluster

Tempo medio per la creazione? (Tempo misurato con il comando time in Bash):

real    15m15.657s
user    0m0.000s
sys     0m0.000s

Per la cancellazione:

real    8m35.827s
user    0m0.015s
sys     0m0.031s

La comodità con questo comando è che si avrà già la configurazione attiva sul computer da cui è lanciata e si potrà iniziare a lavorare con Kubernetes immediatamente:

kubectl get nodes
...

Terraform

Ormai ho scritto parecchi post a riguardo ed ormai è il mio metodo preferito per la creazione di un cluster EKS in AWS. Anche per questo post ho creato un repository in Github con il codice sorgente, e nella directory apposita si può trovare i vari file che creeranno tutte le risorse necessarie.

I passaggi, per chi già conosce Terraform sono i soliti:

terraform init
terraform plan
terraform apply -auto-approve

Ovviamente prima di lanciare questi comandi si dev'essere certi di avere configurato l'accesso ad AWS correttamente sulla propria macchina con il comando:

aws configure

Tempi di creazione nel mio caso:

real    10m49.006s
user    0m0.047s
sys     0m0.031s

Per cancellare il cluster con il comando terraform destroy -auto-approve:

real    9m16.021s
user    0m0.000s
sys     0m0.046s

Come spiegato nel post precedente, per poter poi utilizzare il cluster Kubernetes creato in AWS con questo metodo si può usare questo comando:

aws eks --region eu-south-1 update-kubeconfig --name K8sDemo

Che mi permette di eseguire comandi come kubectl...

Domini e certificati

Qualsiasi scelta si è fatta per la creazione del cluster per Kubernetes, ora si deve controllare di avere un dominio e un certificato SSL. Le varie operazioni per come configurare questi due risorse in AWS ne ho scritto fin troppo nel post precedente, quindi non aggiungerò nulla, anche perché tutte le operazioni sono semplicissime. Per proseguire è necessario solo il nome del dominio - nel mio caso example.com - che, ricordo, è ovviamente fittizio (ne sto usando un altro sempre .com), che il certificato SSL abbia lo stesso nome del dominio e che sia creato nella Region dove è stato avviato il cluster di Kubernetes - nel mio caso eu-south-1 (Milan).

Applicazioni web di esempio

Per questo esempio creerò due Pod in due Namespace, per semplicità a e b. Utilizzando ancora Terraform:

resource "kubernetes_namespace" "namespace-apache" { 
  metadata { 
    name = var.namespace_apache # <- a
  }
}

resource "kubernetes_namespace" "namespace-nginx" { 
  metadata { 
    name = var.namespace_nginx # <- b
  }
}

Il primo contenente Nginx e la sua home page di default:

resource "kubernetes_deployment" "nginx-1" { 
  metadata { 
    name = "nginx-1" 
    namespace = kubernetes_namespace.namespace-nginx.metadata.0.name
  } 

  spec { 
    replicas = 1 
    selector { 
      match_labels = { 
        app = "nginx-1" 
      } 
    }
    template { 
      metadata { 
        labels = { 
          app = "nginx-1" 
        } 
      } 
      spec {
        container { 
          image = "nginx:1.14.2" 
          name  = "nginx-1" 
        } 
      } 
    } 
  } 
} 

resource "kubernetes_service" "nginx-1-ingress" {
  metadata {
    name = "nginx-1-ingress"
    namespace = kubernetes_namespace.namespace-nginx.metadata.0.name
  }
  spec {
    selector = {
      app = "nginx-1"
    }

    port {
      port = 80
    }

    cluster_ip = "None"
    type ="ClusterIP"
  }
  depends_on = [kubernetes_deployment.nginx-1]
}

Creo inoltre un Service di tipo ClusterIp che utilizzerò poi con Ingress. Queste risorse sono create all'interno del Namespace a.

La seconda web application si basa su Apache. In questo caso ho personalizzato la home page perché la pagina di default - It works - è fin troppo banale. Semplificando il tutto ho creato il contenuto della home page in un oggetto ConfigMap:

locals {
  html_home_page = <<EOF
<html>
<head>
  <style>body {font-family:verdana;text-align:center}</style>
</head>
<body>
  <h1>My Apache home page</h1>
  Simple example with content in config map

</body>
</html>
EOF
}

resource "kubernetes_config_map" "html-apache-content" {
  metadata {
    name = "html-apache-content"
    namespace = kubernetes_namespace.namespace-apache.metadata.0.name # <- B
  }
  binary_data = {
    "HomePage" = base64encode(local.html_home_page)
  }
}

E il codice per il deploy di questa pagina con Apache:

resource "kubernetes_deployment" "apache-1" { 
  metadata { 
    name = "apache-1" 
    namespace = kubernetes_namespace.namespace-apache.metadata.0.name
  } 

  spec { 
    replicas = 1 
    selector { 
      match_labels = { 
        app = "apache-1" 
      } 
    }
    template { 
      metadata { 
        labels = { 
          app = "apache-1" 
        } 
      } 
      spec {
        container { 
          image = "httpd:2.4.54-alpine3.16" 
          name  = "apache-1"
          volume_mount {
            name = "config-volume-apache"
            mount_path = "/usr/local/apache2/htdocs/"
            read_only = true
          }
        } 
      
        volume {
          name = "config-volume-apache"
          config_map {
            name = "html-apache-content"
            items {
                key = "HomePage"
                path = "index.html"
            }            
          }
        }
      }
    }
  } 
  depends_on = []
} 

resource "kubernetes_service" "apache-1-ingress" {
  metadata {
    name = "apache-1-ingress"
    namespace = kubernetes_namespace.namespace-apache.metadata.0.name
  }
  spec {
    selector = {
      app = "apache-1"
    }

    port {
      port = 80
    }

    cluster_ip = "None"
    type ="ClusterIP"
  }
  depends_on = [kubernetes_deployment.apache-1]
}

Il tutto sarà creato nel Namespace b.

Ingress e Load Balancer in AWS

A questo link sono presenti informazioni e link su Ingress per Kubernetes. Tralasciando le informazioni di cui avevo già parlato in passato, c'è una sezione di una pagina dal link precedente che spiega come installare Ingress con AWS. In modo semplice si deve solo avviare lo script per Kubernetes con una riga di comando:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.2.1/deploy/static/provider/aws/deploy.yaml

Interessanti e utili le informazioni per la creazione di Ingress per la comunicazione con TLS e certificati, che è proprio quello che voglio:

Volendo usare solo Terraform ora ho due soluzioni: utilizzare provider come kubectl (non il comando) che permette di includere e installare i classici Manifest yaml di Kubernetes, oppure convertire il codice presente nel file Yaml nella versione per Terraform. Io ho scelto la seconda - più avanti farò qualche commento su questo argomento.

Come spiegato nella documentazione per l'uso dei certificati TLS devo inserire l'AWS ARN del mio certificato e il CIDR della mia VPC. Ottengo queste informazioni con le Resource Data apposite:

data "aws_route53_zone" "myzone" {
  name = var.domain_name
}

data "aws_acm_certificate" "cert" {
  domain   = var.domain_name
}

data "aws_eks_cluster" "k8sData" {
  name = var.cluster_name
}

data "aws_vpc" "selected" {
  id = data.aws_eks_cluster.k8sData.vpc_config[0].vpc_id
}

Che utilizzerò nel file convertito:

...
data = {
allow-snippet-annotations = "true"
http-snippet = "server {\n  listen 2443;\n  return 308 https://$host$request_uri;\n}\n"
proxy-real-ip-cidr = data.aws_vpc.selected.cidr_block
use-forwarded-headers = "true"
}
...
annotations = {
    "service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout" = "60"
    "service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled" = "true"
    "service.beta.kubernetes.io/aws-load-balancer-ssl-cert" = data.aws_acm_certificate.cert.arn
    "service.beta.kubernetes.io/aws-load-balancer-ssl-ports" = "https"
    "service.beta.kubernetes.io/aws-load-balancer-type" = "nlb"
}

Se lanciassi ora la configurazione di Terraform dovrei trovare in AWS un nuovo Load Balancer con il certificato configurato correttamente:

Notare che con il codice di quella documentazione viene creato un Network Load Balancer e non un Application Load Balancer. Per il risultato finale non cambia molto, ma è sempre meglio saperlo.

Ingress in Kubernetes

Accantono un attimo il Load Balancer in AWS e passo a scrivere il codice per la configurazione di Ingress:

resource "kubernetes_ingress_v1" "api-ingress-nginx" {
  metadata {
    name = "api-ingress-nginx"
    annotations = {
      "kubernetes.io/ingress.class" = "nginx-ingress"
      "nginx.ingress.kubernetes.io/rewrite-target" =  "/"
    }
    namespace = "default"
  }
  spec {
    rule {
      host = var.domain_name # <- example.com
      http {
        path {
          path = "/"
          backend {
            service {
              name = kubernetes_service.apache-service.metadata.0.name
              port {
                number = 80
              }
            }
          }
        }
      }
    }
    rule {
      host = "nginx.${var.domain_name}" # <- nginx.example.com
      http {
        path {
          path = "/"
          backend {
            service {
              name = kubernetes_service.nginx-service.metadata.0.name
              port {
                number = 80
              }
            }
          }
        }
      }
    }
  }

  depends_on = [
    kubectl_manifest.ingressmanifest
  ]
}

Ho creato due sezioni rule in spec, una per ogni servizio creato (apache/nginx). Nella prima rule ho inserito il dominio principale (example.com) in modo che punti al Pod dove gira Apache, mentre il dominio di terzo livello (nginx) punterà al Pod dove è in esecuzione Nginx e la sua pagina di default.

Avendo creato le due web application in due Namespace diversi, così come i loro Service a cui devo fare riferimento, non posso inserire direttamente il loro nome nella configurazione perché Ingress può solo vedere Service nel suo stesso Namespace. Un passaggio aggiuntivo è necessario. Nel Namespace Default creo altre due Service di tipo ExternalName, nel quale creo un redirect al Service ClusterIp all'interno dei Namespace:

resource "kubernetes_service" "nginx-service" {
  metadata {
    name = "nginx-service"
  }

  spec {
    type = "ExternalName"
    external_name = "${kubernetes_service.nginx-1-ingress.metadata.0.name}.${kubernetes_namespace.namespace-nginx.metadata.0.name}.svc.cluster.local"
  }
}
...
resource "kubernetes_service" "apache-service" {
  metadata {
    name = "apache-service"
  }

  spec {
    type = "ExternalName"
    external_name = "${kubernetes_service.apache-1-ingress.metadata.0.name}.${kubernetes_namespace.namespace-apache.metadata.0.name}.svc.cluster.local"
  }
}

In external ho inserito il dominio completo di Namespace:

{name service}.{namespace}.svc.cluster.localster.local

In questo modo in Ingress posso puntare al nome di questo Service superando il limite prima descritto.

Piccola parentesi su questo oggetto: l'uso dei Service di tipo Externalname è utile anche nel caso si volesse creare un Endpoint esterno da chiamare da tutti i Pod all'interno del Cluster in modo che sia facilmente modificabile senza dover mettere mano a tutte le configurazioni degli oggetti creati. Esempio:

apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: prod
spec:
  type: ExternalName
  externalName: www.google.com

Ora da qualsiasi Pod potrei richiamare questo comando per ottenere come risultato il contenuto della pagina di Google:

curl my-servicemy-service

In futuro si potrebbe modificare il nome del dominio finale in www.altromotorediricerca.it in questo service, e si avrà la sicurezza che tutti i Pod automaticamente chiameranno il nuovo dominio quando utilizzeranno my-service. Chiudo la parentesi.

Prima di configurare i Domini in AWS con Terraform, ecco in questo grafico com'è configurato il tutto: 

Il Service di tipo Load Balancer in Kubernetes è in grado di creare autonomamente un Load Balancer in AWS, da qui potrebbe nascere la domanda del perché complicarsi la vita con l'utilizzo di Ingress. La risposta è semplice è intuibile: per motivi economici. Ogni oggetto di tipo Load Balancer in AWS ha un costo anche per la sola attivazione: supponendo di avere decine di Service da esporre in Internet si può immagine non solo il costo, ma l'inutilità di avere un oggetto di questo tipo per ogni Service interno in Kubernetes. Con Ingress si risolve questo problema visto che tutte le richieste passeranno per un unico Load Balancer e, grazie ad un unico file di configurazione, si potrà avere l'esatta configurazione di tutti i Service in modo chiaro in un'unica risorsa.

Gestione dei domini

Niente di nuovo da quello che avevo già fatto nel post precedente. L'unica variante è che non utilizzerò oggetti creati in CloudFront ma il Load Balancer prima creato. Ora ho bisogno delle informazioni da questo oggetto. Anche in questo caso Terraform mi viene in aiuto:

data "aws_lb" "ingress-load-balancer" {
  tags = {
    "kubernetes.io/cluster/${var.cluster_name}" = "owned"
  }
  depends_on = [
    kubectl_manifest.ingressmanifest
  ]
}

Ora posso completare l'ultimo passo:

resource "aws_route53_record" "www-apache" {
  zone_id = data.aws_route53_zone.myzone.zone_id
  name    = var.domain_name # <- example.com
  type    = "A"

  alias {
    name                   = data.aws_lb.ingress-load-balancer.dns_name
    zone_id                = data.aws_lb.ingress-load-balancer.zone_id
    evaluate_target_health = false
  }
}

resource "aws_route53_record" "www-nginx" {
  zone_id = data.aws_route53_zone.myzone.zone_id
  name    = "nginx.${var.domain_name}" # <- nginx.example.com
  type    = "A"

  alias {
    name                   = data.aws_lb.ingress-load-balancer.dns_name
    zone_id                = data.aws_lb.ingress-load-balancer.zone_id
    evaluate_target_health = false
  }
}

Ora non mi rimane che controllare che le pagine siano raggiungibili. Richiamo l'Url:

https://example.com

E il browser:

Ora controllo la pagina generata da Nginx:

Entrambe le pagine sono protette in https. Ok, funziona.

Convertire il codice Yaml di Kubernetes in Terraform

Alcuni suggerimenti dati dalla mia esperienza per la conversione dei file in Yaml al formato Terraform. Personalmente se la conversione riguarda pochi file la faccio a mano facendomi aiutare dall'Intellisense di VSCode.

Per il file di Ingress preso in considerazione sopra, in cui sono presenti molte Resource e oltre cinquecento righe di codice, ho fatto affidamento per la prima conversione al tool k2tf. C'è subito da dire che non è perfetto come Tool, perché spesso non converte certe configurazioni dimenticando alcune sezioni senza dare warning o errori a riguardo. Consiglio si deve fare una revisione completa del codice prodotto per essere certi che la conversione sia corretta.

Il suo utilizzo è semplice e può gestire qualsiasi file Yaml per Kubernetes, ma può essere usato anche con le risorse già presenti nel Cluster. Per esempio, con questo comando prendo un Service esistente:

kubectl get svc/kube-dns -n kube-system -o yaml

Avrò questo output:

apiVersion: v1
kind: Service
metadata:
  annotations:
    prometheus.io/port: "9153"
    prometheus.io/scrape: "true"
  creationTimestamp: "2022-05-11T19:15:39Z"
  labels:
    k8s-app: kube-dns
    kubernetes.io/cluster-service: "true"
    kubernetes.io/name: CoreDNS
  name: kube-dns
  namespace: kube-system
  resourceVersion: "275"
  uid: d6c08f56-6016-4101-814f-7e8210d4dfee
spec:
  clusterIP: 10.96.0.10
  clusterIPs:
  - 10.96.0.10
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - name: dns
    port: 53
    protocol: UDP
    targetPort: 53
  - name: dns-tcp
    port: 53
    protocol: TCP
    targetPort: 53
  - name: metrics
    port: 9153
    protocol: TCP
    targetPort: 9153
  selector:
    k8s-app: kube-dns
  sessionAffinity: None
  type: ClusterIP
status:
  loadBalancer: {}

Salvato in un file lo posso convertire per Terraform:

k2tf -f input.yaml -o output.tf

Con questo risultato:

resource "kubernetes_service" "kube_dns" {
  metadata {
    name      = "kube-dns"
    namespace = "kube-system"

    labels = {
      k8s-app = "kube-dns"
      "kubernetes.io/cluster-service" = "true"
      "kubernetes.io/name" = "CoreDNS"
    }

    annotations = {
      "prometheus.io/port" = "9153"
      "prometheus.io/scrape" = "true"
    }
  }

  spec {
    port {
      name        = "dns"
      protocol    = "UDP"
      port        = 53
      target_port = "53"
    }

    port {
      name        = "dns-tcp"
      protocol    = "TCP"
      port        = 53
      target_port = "53"
    }

    port {
      name        = "metrics"
      protocol    = "TCP"
      port        = 9153
      target_port = "9153"
    }

    selector = {
      k8s-app = "kube-dns"
    }

    cluster_ip       = "10.96.0.10"
    cluster_i_ps     = ["10.96.0.10"]
    type             = "ClusterIP"
    session_affinity = "None"
    ip_families      = ["IPv4"]
  }
}

E' già presente un errore se si guarda bene il codice: cluster_i_ps invece di cluster_ips.

Consiglio di non prendere troppo alla leggera la conversione di configurazioni già esistenti da Yaml nel formato di Terraform perché si potrebbero trovare anomalie. Faccio un esempio: ecco un semplice file Yaml che crea un nuovo Namespace e al suo interno crea un Pod:

kind: Namespace
apiVersion: v1
metadata:
  name: test
  labels:
    name: test
---
apiVersion: v1
kind: Pod
metadata:
  name: mywebapp1
  namespace: test
  labels:
    role: webserver-role
    app: nginx
spec:
  containers:
  - name: webserver1
    image: nginx:1.2

kubectl da questo file creerà le risorse sequenzialmente: innanzitutto il Namespace, quindi al suo interno il Pod con Nginx. Ipotizzando lo stesso file in Terraform:

provider "kubernetes" { 
    config_path = "~/.kube/config" 
}

resource "kubernetes_namespace" "test" {
  metadata {
    name = "test"

    labels = {
      name = "test"
    }
  }
}

resource "kubernetes_pod" "mywebapp_1" {
  metadata {
    name = "mywebapp1"
    namespace = "test"

    labels = {
      app = "nginx"
      role = "webserver-role"
    }
  }

  spec {
    container {
      name  = "webserver1"
      image = "nginx:1.23.0"
    }
  }
}

Le due risorse saranno create in parallelo ma potrebbe succedere che il Pod venga creato in un Namespace che ancora non esiste con conseguente errore. Questo è solo un esempio perché la creazione del Namespace è molto veloce, ma per altre risorse potrebbe comportare errori e problemi casuali - lo dico perché ho avuto esperienza in merito. In questo caso è sempre meglio intervenire preventivamente con le dipendenze implicite o esplicite come spiegato nel primo post dedicato a Terraform. Ecco il codice qui sopra scritto in modo più sicuro:

resource "kubernetes_pod" "mywebapp_1" {
  metadata {
    name = "mywebapp1"
    namespace = kubernetes_namespace.test.metadata[0].name

    labels = {
      app = "nginx"
      role = "webserver-role"
    }
  }
}

Con questa dipendenza implicita il Pod sarà sempre gestito dopo la creazione del Namespace. La differenza è visibile anche dal log nella console di Terraform. Nel primo caso:

kubernetes_namespace.test: Creating...
kubernetes_pod.mywebapp_1: Creating...
kubernetes_namespace.test: Creation complete after 0s [id=test]
kubernetes_pod.mywebapp_1: Creation complete after 3s [id=test/mywebapp1]

I due oggetti sono creati in parallelo. Con la dipendenza implicita invece la creazione è sequenziale:

kubernetes_namespace.test: Creating...
kubernetes_namespace.test: Creation complete after 0s [id=test]
kubernetes_pod.mywebapp_1: Creating...
kubernetes_pod.mywebapp_1: Creation complete after 3s [id=test/mywebapp1]

Se si controlla il mio file convertito in Terraform incluso nella demo, ho dovuto fare moltissime modifiche a riguardo con l'aggiunta delle dipendenze in modo che tutte le Resource coinvolte venissero create nell'ordine corretto. Ma ne valeva la pena?

Conclusioni

Quello mostrato in questo post è la modalità semplificata per la configurazione di Ingress in AWS. C'è una procedura più complessa e personalizzabile, ma personalmente non padroneggiandola mi astengo nel divulgarla.

Non era lo scopo di questo post, ma avrei potuto inoltre aggiungere CloudFront come layer aggiuntivo tra il dominio e il LoadBalancer in modo da poter distribuire velocemente, grazie alla CDN di AWS, il contenuto statico di queste due pagine. Utilità? Nessuna per questa demo.

Non rimane che postare il link dove trovare il codice qui utilizzato.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese