AWS Load Balancer Controller no EKS com Terraform: Ingress com ALB

O AWS Load Balancer Controller é o componente que transforma um Ingress do Kubernetes em um Application Load Balancer de verdade, com listeners, regras e target groups. Sem ele, o EKS não sabe o que fazer com um Ingress: o objeto é criado, fica sem endereço e nenhum tráfego chega aos pods.

Este é o sexto artigo da série artigos-eks. O controller entra no mesmo cluster montado no primeiro artigo, instalado com Helm pelo próprio Terraform e com a permissão de AWS vinda do EKS Pod Identity. No fim, uma aplicação de exemplo sai publicada num ALB internet-facing.

Segurança: os comandos deste guia usam o perfil do AWS CLI para autenticar. Nunca coloque access keys no código ou em .tfvars versionado; prefira aws sso login ou um perfil com credenciais temporárias.

Sumário

Pré-requisitos

Os mesmos dos artigos anteriores: AWS CLI v2 autenticado, Terraform >= 1.5 e kubectl. Desta vez entra também o provider hashicorp/helm ~> 3.0, ao lado do hashicorp/aws ~> 6.0. O ambiente recria a VPC, o cluster (agora na 1.36), o node group e os add-ons, e acrescenta o controller.

A versão do chart usada aqui é a 3.4.0 (aplicação v3.4.0). Para ver as disponíveis:

helm repo add eks https://aws.github.io/eks-charts
helm repo update eks
helm search repo eks/aws-load-balancer-controller --versions | head

Como o AWS Load Balancer Controller funciona

O controller roda como um Deployment no kube-system e observa a API do Kubernetes. Quando aparece um Ingress com ingressClassName: alb, ele chama a API do Elastic Load Balancing e cria:

  • o Application Load Balancer, nas subnets descobertas pelas tags;
  • os listeners e as regras de roteamento por host e caminho, a partir das rules do Ingress;
  • um target group por Service, registrando os pods (ou os nós) como destino;
  • os security groups do ALB e as regras que liberam o tráfego até os pods.

Tudo isso fica fora do state do Terraform. É o controller que cria e apaga, seguindo o ciclo de vida do Ingress. O Terraform só cuida do controller em si.

Desde a v3.0.0, o controller também atende a Gateway API, que saiu de beta nessa versão. Este artigo fica no Ingress, que continua sendo o caminho mais comum.

Por que as tags de subnet importam

O controller precisa decidir em quais subnets colocar o ALB. Ele procura subnets da VPC do cluster com estas tags:

  • kubernetes.io/role/elb = 1: subnets públicas, usadas por load balancers internet-facing;
  • kubernetes.io/role/internal-elb = 1: subnets privadas, usadas por load balancers internos.

O terraform-vpc-modules põe essas tags desde o primeiro artigo. Sem elas, o Ingress fica sem endereço e os eventos mostram que nenhuma subnet foi encontrada. A alternativa é listar as subnets à mão em cada Ingress, com a anotação alb.ingress.kubernetes.io/subnets, o que espalha IDs de recurso pelos manifestos da aplicação.

# confira as tags nas subnets da VPC do cluster
aws ec2 describe-subnets \
  --filters Name=vpc-id,Values=$(terraform output -raw vpc_id) \
  --query 'Subnets[].[SubnetId,AvailabilityZone,Tags[?starts_with(Key,`kubernetes.io/role`)].Key|[0]]' \
  --output table

Permissão com Pod Identity

O controller chama APIs do ELB, do EC2 (security groups, subnets) e de outros serviços, como ACM e WAF. A política com tudo o que ele precisa é publicada no próprio repositório do projeto, uma por versão. O módulo terraform-eks-lb-controller-modules guarda o iam_policy.json da v3.4.0 ao lado do código e o usa como política inline da role:

module "pod_identity" {
  source = "../terraform-eks-pod-identity-modules"

  cluster_name = var.cluster_name
  role_name    = "${var.cluster_name}-aws-lb-controller"

  service_accounts = [
    { namespace = var.namespace, name = var.service_account_name },
  ]

  inline_policy_json = file("${path.module}/iam_policy.json")

  tags = var.tags
}

A associação liga a service account kube-system/aws-load-balancer-controller à role. Não há anotação de eks.amazonaws.com/role-arn na service account nem provedor OIDC, como já foi mostrado no artigo de Pod Identity. Ao atualizar o chart, troque também o JSON: versões novas do controller às vezes precisam de permissões novas.

curl -o iam_policy.json \
  https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v3.4.0/docs/install/iam_policy.json

Instalando o AWS Load Balancer Controller com Helm

O provider helm é configurado no próprio ambiente e conversa com o cluster recém-criado. O token sai do aws eks get-token a cada chamada, então nenhuma credencial do Kubernetes fica gravada no state:

provider "helm" {
  kubernetes = {
    host                   = module.eks.endpoint
    cluster_ca_certificate = base64decode(module.eks.certificate_authority_data)
    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args        = ["eks", "get-token", "--cluster-name", module.eks.name, "--region", var.region]
    }
  }
}

Repare na sintaxe: na versão 3 do provider, kubernetes e exec são atributos (com =), não blocos. Copiar um exemplo da versão 2 dá erro de validação logo no terraform validate.

Dentro do módulo, o helm_release instala o chart oficial passando região e VPC de forma explícita. Assim o controller não depende de consultar o metadata da instância (IMDS) para descobrir esses valores:

resource "helm_release" "this" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  version    = var.chart_version
  namespace  = var.namespace

  set = [
    { name = "clusterName", value = var.cluster_name },
    { name = "region", value = var.region },
    { name = "vpcId", value = var.vpc_id },
    { name = "replicaCount", value = tostring(var.replica_count) },
    { name = "serviceAccount.create", value = "true" },
    { name = "serviceAccount.name", value = var.service_account_name },
  ]

  depends_on = [module.pod_identity]
}

No ambiente, o módulo é chamado depois dos add-ons, porque precisa do eks-pod-identity-agent para receber credencial e dos nós para rodar:

module "lb_controller" {
  source = "../../terraform-eks-lb-controller-modules"

  cluster_name  = module.eks.name
  region        = var.region
  vpc_id        = module.vpc.vpc_id
  chart_version = var.lb_controller_chart_version

  depends_on = [module.addons]
}

O chart cria a IngressClass chamada alb, com duas réplicas do controller. Ele também liga um webhook que faz Service novo do tipo LoadBalancer ser atendido pelo controller, como Network Load Balancer, em vez do controlador legado do Kubernetes.

terraform plan

terraform init
terraform validate
terraform plan -var 'public_access_cidrs=["SEU_IP/32"]' -out=tfplan

O código deste artigo foi validado com terraform validate e terraform plan contra uma conta AWS real, sem apply. O plan fecha em 32 recursos: os 28 do cluster base mais quatro do controller (role, política inline, associação do Pod Identity e o release do Helm):

  # module.lb_controller.helm_release.this will be created
  # module.lb_controller.module.pod_identity.aws_eks_pod_identity_association.this["kube-system/aws-load-balancer-controller"] will be created
  # module.lb_controller.module.pod_identity.aws_iam_role.this will be created
  # module.lb_controller.module.pod_identity.aws_iam_role_policy.inline[0] will be created
  ...
Plan: 32 to add, 0 to change, 0 to destroy.

O plan passa mesmo sem o cluster existir: o provider helm só se conecta ao Kubernetes no apply, quando o endpoint já é conhecido. No apply, o Terraform cria o cluster, os nós e os add-ons e só então instala o chart.

Outputs do Terraform

Este é o bloco Changes to Outputs do plan. O que depende de recurso ainda não criado aparece como (known after apply):

Changes to Outputs:
  + cluster_name                = "eks-blog-castilho"
  + kubeconfig_command          = "aws eks update-kubeconfig --region us-east-1 --name eks-blog-castilho"
  + kubernetes_version          = "1.36"
  + lb_controller_chart_version = "3.4.0"
  + lb_controller_role_arn      = (known after apply)
  + public_subnet_ids           = [
      + (known after apply),
      + (known after apply),
    ]
  + vpc_id                      = (known after apply)
  • cluster_name e kubernetes_version: nome e versão do cluster.
  • vpc_id: a VPC passada ao controller. Use para conferir as tags de subnet com o comando da seção anterior.
  • public_subnet_ids: as subnets com a tag kubernetes.io/role/elb. São onde o ALB internet-facing vai nascer: terraform output -json public_subnet_ids.
  • lb_controller_role_arn: role do controller. Depois do apply, terá o formato arn:aws:iam::111122223333:role/eks-blog-castilho-aws-lb-controller (exemplo, com a conta fictícia da documentação da AWS). Confira a associação com aws eks list-pod-identity-associations --cluster-name eks-blog-castilho.
  • lb_controller_chart_version: versão do chart instalada. É o número a comparar com o iam_policy.json do módulo.
  • kubeconfig_command: configura o kubectl. Rode com $(terraform output -raw kubeconfig_command).

Depois do apply, confirme que o controller subiu:

$(terraform output -raw kubeconfig_command)
kubectl get deployment -n kube-system aws-load-balancer-controller
kubectl get ingressclass

Ingress com ALB internet-facing

Com o controller rodando, publicar uma aplicação é só Kubernetes. Salve como loja-web.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: loja
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: loja
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: public.ecr.aws/nginx/nginx:stable
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: loja
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: loja
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/healthcheck-path: /
    alb.ingress.kubernetes.io/group.name: loja
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80
  • scheme: internet-facing: ALB público, nas subnets com kubernetes.io/role/elb. Com internal, ele vai para as privadas.
  • target-type: ip: o ALB manda o tráfego direto para o IP do pod. Funciona porque, com o VPC CNI, cada pod tem um IP da VPC. O Service pode ser ClusterIP, e o tráfego não dá a volta extra por um NodePort.
  • healthcheck-path: caminho que o target group usa para saber se o pod está saudável.
  • group.name: coloca o Ingress num grupo, assunto da próxima seção.
kubectl apply -f loja-web.yaml
kubectl get ingress -n loja web -w

# quando o ADDRESS aparecer (leva alguns minutos):
curl -I http://$(kubectl get ingress -n loja web -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')

Se o ADDRESS não aparecer, os eventos do Ingress contam o motivo: kubectl describe ingress -n loja web. Os mais comuns são subnet sem tag e erro de permissão (role ou associação do Pod Identity faltando). Os logs do controller ficam em kubectl logs -n kube-system deploy/aws-load-balancer-controller.

IngressGroup: um ALB para várias aplicações

Por padrão, cada Ingress gera um ALB próprio, e cada ALB tem custo fixo por hora. Com a anotação alb.ingress.kubernetes.io/group.name, vários Ingress, inclusive de namespaces diferentes, compartilham o mesmo ALB. O controller junta as regras de todos eles num único conjunto de listeners.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
  namespace: loja
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/group.name: loja
    alb.ingress.kubernetes.io/group.order: "10"
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 8080

O group.order define a prioridade das regras quando os caminhos se sobrepõem: número menor é avaliado primeiro. Atenção a um efeito colateral: qualquer Ingress com o mesmo group.name pode acrescentar regras ao ALB. Em cluster compartilhado entre times, combine grupo e namespace com cuidado, ou restrinja quem pode criar Ingress em cada namespace com as permissões vistas no artigo de Access Entries.

Custos e limpeza

  • Controller: sem custo próprio; roda nos nós do cluster.
  • ALB: cobrança por hora e por LCU (unidade de capacidade, que mede conexões, requisições e bytes). Cada grupo de Ingress é um ALB.
  • Cluster base: control plane, NAT Gateway e nós, como no primeiro artigo.

A ordem da limpeza é obrigatória. O ALB, os target groups e os security groups foram criados pelo controller, não pelo Terraform. Se você rodar o destroy com o Ingress ainda no cluster, o controller some antes de apagar o ALB, que fica órfão e ainda prende as subnets, travando a exclusão da VPC:

kubectl delete -f loja-web.yaml
kubectl get ingress -A          # espere a lista ficar vazia

terraform destroy -var 'public_access_cidrs=["SEU_IP/32"]'

Conclusão

Com o AWS Load Balancer Controller instalado pelo Terraform, publicar uma aplicação no EKS volta a ser só um Ingress. As peças dos artigos anteriores se encaixam aqui: as tags de subnet do primeiro artigo dizem onde criar o ALB, o Pod Identity dá a permissão sem OIDC e o target-type: ip aproveita o fato de cada pod ter IP da VPC.

No próximo artigo, o cluster passa a escalar os próprios nós conforme a demanda, com o Karpenter e instâncias Spot.

Referências oficiais

Interessado em saber mais sobre esse assunto?

Deixe uma resposta

Descubra mais sobre Blog do Castilho - Tecnologia | FinOps | DevOps | Cloud

Assine agora mesmo para continuar lendo e ter acesso ao arquivo completo.

Continuar lendo