0%
Implantação de aplicações complexas

Aplicações complexas

Juntando as peças: um ponto de entrada, cotas, elasticidade.

15-20 min

Implantação de aplicações complexas

Agora você já sabe escrever um Deployment, expô-lo, injetar sua configuração e dar a ele um volume. Uma aplicação real, por sua vez, é composta por vários serviços que precisam se comunicar, ser acessíveis pela Internet sob um único nome de domínio, não esgotar os recursos uns dos outros e absorver um aumento de tráfego sem intervenção humana.

Três problemas novos aparecem nessa escala. O primeiro é o ponto de entrada: criar um Service do tipo LoadBalancer para cada microsserviço significa pagar por vários endereços públicos, sem roteamento por caminho nem certificado compartilhado. O segundo é o compartilhamento de recursos: sem nenhuma restrição declarada, um serviço com vazamento de memória pode derrubar os Pods vizinhos no mesmo nó. O terceiro é a elasticidade: dimensionar para o pico equivale a pagar pelo pico o tempo todo.

O quarto problema não é técnico, mas organizacional, e costuma ser o que dói mais rápido. Uma aplicação com cinco serviços representa cerca de trinta manifestos, que precisam ser duplicados para cada ambiente alterando três valores. O copiar e colar se torna, então, uma grande fonte de falhas: alguém modifica o arquivo de homologação e esquece o de produção.

Esta lição junta as peças em um exemplo completo e, em seguida, responde a esse último ponto com uma visão geral do Helm. O objetivo não é acrescentar conceitos, mas mostrar como os que você já conhece funcionam em conjunto.

A aplicação de referência

Três componentes em um namespace dedicado: um banco de dados PostgreSQL, uma API e uma interface web. O namespace não é decorativo: ele carrega as cotas, as regras de acesso e permite apagar tudo de uma só vez.

apiVersion: v1
kind: Namespace
metadata:
  name: boutique
  labels:
    environnement: production

O banco de dados

Um StatefulSet com seu Service headless, exatamente conforme o modelo da lição anterior: serviceName: postgres, clusterIP: None, um volumeClaimTemplates de 10 Gi. Duas adições próprias de uma implantação completa, a serem inseridas no contêiner:

          # Uma reserva generosa: um banco de dados agendado em um nó
          # já carregado ficará lento sem que nenhum alerta seja disparado.
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"
          # pg_isready evita que a API receba tráfego antes da inicialização.
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "boutique", "-d", "boutique"]
            initialDelaySeconds: 10
            periodSeconds: 5

A API

Este é o manifesto mais instrutivo: ele reúne configuração, secret, probes e recursos.

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: boutique
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  # O nome do Service basta: o DNS interno o resolve dentro do namespace.
  DB_HOST: "postgres"
  DB_PORT: "5432"
  DB_NAME: "boutique"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: boutique
  labels:
    app: api
    tier: backend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # a capacidade nunca diminui durante a troca
      maxSurge: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
        tier: backend
    spec:
      containers:
        - name: api
          image: exemple/boutique-api:1.4.0
          ports:
            - containerPort: 8080
              name: http
          envFrom:
            - configMapRef:
                name: api-config
            - secretRef:
                name: db-credentials
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              memory: "256Mi"
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: boutique
spec:
  type: ClusterIP
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
      name: http

A interface web

Mesma estrutura: um Deployment com duas réplicas e um Service ClusterIP na porta 80. Um único ponto exige atenção, e ele costuma pegar muita gente:

          env:
            # O navegador NÃO está no cluster: ele não resolve
            # http://api:8080. A URL deve ser um caminho público do Ingress.
            - name: API_BASE_URL
              value: "/api"

Apenas o código executado dentro do cluster se beneficia do DNS interno. O JavaScript do navegador, por sua vez, passa obrigatoriamente pelo ponto de entrada público — daí a próxima seção.

O Ingress

Um Ingress é uma regra de roteamento HTTP: um nome de domínio, caminhos e Services de destino. Um único ponto de entrada atende toda a aplicação.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: boutique
  namespace: boutique
  annotations:
    # Reescreve /api/produits em /produits antes do encaminhamento.
    # Esta annotation é ESPECÍFICA do ingress-nginx.
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - boutique.example.com
      secretName: boutique-tls     # Secret do tipo kubernetes.io/tls
  rules:
    - host: boutique.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  number: 8080
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

O ponto a entender antes de passar uma hora depurando: o objeto Ingress não faz nada por conta própria. Ele é uma declaração inerte enquanto um controlador de entrada (ingress controller) não a tiver lido. Sem um controlador instalado, o apply funciona e nada é roteado.

minikube addons enable ingress
kubectl get pods -n ingress-nginx     # o controlador está rodando?
kubectl get ingress -n boutique       # ADDRESS vazio = regra não atendida
minikube ip                           # o endereço a colocar em /etc/hosts

A segunda ressalva, mais estrutural: as annotations são específicas de cada controlador. nginx.ingress.kubernetes.io/rewrite-target não tem nenhum significado para Traefik, HAProxy ou o AWS Load Balancer Controller, que têm seu próprio prefixo e sua própria sintaxe. Apenas a estrutura rules é padronizada; reescrita, timeouts, limitação de taxa e redirecionamento HTTPS são dialeto de cada controlador. Foi essa divergência que motivou a Gateway API, destinada a substituir o Ingress no longo prazo.

Há três valores de pathType: Exact (correspondência estrita), Prefix (por segmentos de caminho, o caso comum) e ImplementationSpecific, delegado ao controlador e necessário para padrões com captura como o acima.

Requests e limits de recursos

Dois valores, dois papéis distintos. Confundi-los produz falhas difíceis de interpretar.

          resources:
            requests:            # RESERVA: usada no agendamento
              cpu: "100m"        # 100 millicores = 0,1 core
              memory: "128Mi"
            limits:              # TETO: usado para conter desvios
              memory: "256Mi"

requests é o que o scheduler usa para escolher um nó: ele soma os requests dos Pods já presentes e só coloca um novo Pod se a soma continuar abaixo da capacidade. Um Pod sem requests é colocado em qualquer lugar, inclusive em um nó já saturado.

limits é o teto aplicado durante a execução, e CPU e memória não reagem a ele da mesma forma:

  • Ultrapassar o limite de CPU provoca throttling: o contêiner é desacelerado. A aplicação fica lenta, sem erro nem reinício — um sintoma traiçoeiro, muitas vezes atribuído erroneamente à rede ou ao banco de dados.
  • Ultrapassar o limite de memória provoca um OOMKilled: o contêiner é encerrado imediatamente. A memória não é compressível.
kubectl describe pod api-7d4b8c9f5-xk2mp | grep -A5 "Last State"
kubectl top pods -n boutique     # consumo real, se o metrics-server estiver presente
kubectl top nodes

Daí a prática recomendada, contraintuitiva à primeira vista: um limite de memória, mas nenhum limite de CPU — é a escolha feita nos manifestos acima. O limite de memória protege o nó de um vizinho com vazamento, pois a saturação de memória provoca evicções em massa. O limite de CPU, por sua vez, deixa processador ocioso quando uma aplicação poderia usá-lo: o request já garante sua parte em caso de contenção.

O namespace pode impor salvaguardas, em vez de depender da disciplina de cada autor de manifesto:

apiVersion: v1
kind: LimitRange
metadata:
  name: valeurs-par-defaut
  namespace: boutique
spec:
  limits:
    - type: Container
      default:                 # limits aplicados se ausentes no Pod
        memory: "256Mi"
      defaultRequest:          # requests aplicados se ausentes no Pod
        cpu: "100m"
        memory: "128Mi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: quota-boutique
  namespace: boutique
spec:
  hard:
    requests.cpu: "4"
    requests.memory: "8Gi"
    limits.memory: "16Gi"
    persistentvolumeclaims: "10"

Uma consequência a antecipar: assim que um ResourceQuota abrange um recurso de computação, todo Pod do namespace deve declarar o valor correspondente, caso contrário sua criação é recusada. O LimitRange é justamente o que evita que essa regra quebre as implantações existentes.

O autoscaling horizontal

O HorizontalPodAutoscaler ajusta o número de réplicas de acordo com uma métrica observada.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: boutique
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # 70 % do REQUEST, não do limit
  behavior:
    scaleUp:                         # subir rápido para absorver um pico
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:                       # descer devagar, senão oscila
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

O detalhe decisivo está no comentário: averageUtilization: 70 incide sobre o request, não sobre o limit. Com requests.cpu: 100m, o escalonamento é disparado com 70 millicores de consumo médio. Um request mal calibrado torna, portanto, o autoscaling errático: baixo demais, ele adiciona réplicas o tempo todo; alto demais, nunca reage.

Dois pré-requisitos rígidos, que explicam quase todos os HPAs que «não funcionam»: o metrics-server deve estar instalado, e os contêineres-alvo devem declarar requests para a métrica escolhida — sem request de CPU, a porcentagem não pode ser calculada.

minikube addons enable metrics-server
kubectl get hpa -n boutique       # TARGETS em <unknown> = métricas ausentes
kubectl describe hpa api -n boutique

Observe por fim que o HPA adiciona Pods, mas não cria nós: se o cluster estiver cheio, os novos Pods ficam em Pending. Adicionar capacidade de máquina é papel do Cluster Autoscaler ou do Karpenter, específicos de cada provedor de nuvem.

Implantar e verificar o conjunto

A ordem de criação não precisa ser orquestrada: os loops de reconciliação convergem, e um Pod que ainda não encontra sua dependência será reiniciado.

kubectl apply -f k8s/ --recursive
kubectl rollout status deployment/api -n boutique
kubectl rollout status statefulset/postgres -n boutique
kubectl get all -n boutique
kubectl get events -n boutique --sort-by=.metadata.creationTimestamp

O diagnóstico de uma aplicação com vários serviços segue sempre o mesmo caminho, de fora para dentro:

kubectl describe ingress boutique -n boutique   # 1. a regra está sendo atendida?
kubectl get endpoints -n boutique               # 2. os Services apontam para Pods?
kubectl get pods -n boutique                    # 3. READY 3/3, e não apenas Running?
kubectl port-forward -n boutique service/api 8080:8080   # 4. contornar o Ingress

Essa progressão isola a falha em quatro comandos. Se o port-forward funciona, mas o Ingress não, o problema está no roteamento e não na aplicação — uma hora de leitura de logs da aplicação economizada.

Uma visão geral do Helm

Esses manifestos funcionam. O problema surge no segundo ambiente: a mesma coisa com duas réplicas, outro nome de domínio e uma imagem de homologação. O copiar e colar cria duas fontes de verdade que vão divergir.

O Helm é um gerenciador de pacotes para Kubernetes. Um chart reúne manifestos parametrizados cujos valores são extraídos para um arquivo separado: Chart.yaml descreve o pacote, values.yaml contém os valores padrão e templates/ contém os manifestos.

# values.yaml
api:
  image:
    repository: exemple/boutique-api
    tag: "1.4.0"
  replicas: 3
ingress:
  host: boutique.example.com
# templates/deployment-api.yaml — trecho
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-api
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.api.replicas }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
# Verificar a renderização SEM enviar nada ao cluster: faça isso sempre
helm template boutique ./boutique -f values-production.yaml

helm install boutique ./boutique -n boutique --create-namespace
helm upgrade boutique ./boutique -n boutique -f values-production.yaml
helm history boutique -n boutique
helm rollback boutique 1 -n boutique

O que o Helm oferece: um único conjunto de manifestos para vários ambientes, versionamento das entregas e rollback em escala de toda a aplicação — enquanto o kubectl rollout undo trata apenas um objeto por vez. O custo, e é preciso dizer: a templatização prejudica a legibilidade. Um chart muito parametrizado vira um exercício de lógica de template em que não se enxerga mais o YAML gerado. É por isso que alternativas como o Kustomize, que sobrepõe patches a um YAML que continua legível, conquistaram seu público. Adquira o reflexo de rodar helm template antes de qualquer helm upgrade: você verá o que o cluster realmente vai receber.

Pontos-chave

  • O Ingress centraliza um ponto de entrada HTTP para todos os seus Services. Ele fica inerte sem um controlador de entrada (Ingress controller) instalado, e suas anotações são específicas de cada controlador: apenas a estrutura rules é portável.
  • requests serve para o agendamento (scheduling), limits para a execução. Ultrapassar o limite de CPU causa lentidão silenciosa (throttling); ultrapassar o limite de memória mata o contêiner (OOMKilled). Daí o uso comum: sempre um limite de memória, raramente um limite de CPU.
  • O HPA raciocina em porcentagem da requisição (request), não do limite. Ele exige o metrics-server e requests declaradas, e adiciona Pods sem criar nós.
  • Faça o diagnóstico de fora para dentro: Ingress, depois endpoints, depois estado dos Pods e, por fim, port-forward para contornar o roteamento e isolar a camada com problema.
  • O Helm evita a duplicação de manifestos entre ambientes e oferece um rollback global. O preço é a legibilidade: sempre confira o resultado com helm template antes de aplicar.

Para ir além: os assuntos que prolongam naturalmente este curso são RBAC, NetworkPolicy, observabilidade (Prometheus, Grafana) e entrega contínua com GitOps. A documentação oficial kubernetes.io/fr/docs/concepts/ continua sendo a referência a consultar em primeiro lugar.

Comentários

Os comentários são alimentados pelo GitHub Discussions

Conecte-se com o GitHub para participar da discussão

Link copiado!