DEV Community

Cover image for Golden Images com Packer + Ansible: como automatizamos provisionamento e centralizamos configurações
Kevin Gomes
Kevin Gomes

Posted on

Golden Images com Packer + Ansible: como automatizamos provisionamento e centralizamos configurações

O problema

Toda vez que uma máquina nova precisa subir e o processo depende de alguém lembrar os passos certos, você já tem um problema. Foi exatamente isso que eu vivi trabalhando com EC2 dedicadas: cada instância nova virava uma pequena novela. Subir a máquina, instalar pacote por pacote na mão, ajustar configuração, esquecer algum passo, documentar (às vezes) num lugar que ninguém revisita depois, e torcer para lembrar de fazer tudo igual da próxima vez.

Isso não é só perda de tempo, é falta de padrão. Duas máquinas que deveriam ser idênticas nunca são de fato idênticas, porque o processo de criação delas nunca foi de fato o mesmo. E quando algo dá errado, não tem como saber com certeza o que está instalado e configurado ali sem entrar na máquina e investigar manualmente.

A pergunta que me guiou foi simples: como transformar "instalar uma máquina" em algo repetível, versionado e auditável, sem depender de alguém lembrar os passos certos?

A solução: Packer + Ansible-local

A combinação que resolveu isso pra mim foi:

  • Packer para orquestrar o build da imagem (AMI)
  • Ansible rodando em modo local (ansible-local) dentro da própria instância de build, aplicando os playbooks diretamente na máquina, sem precisar de um control node externo fazendo SSH

O fluxo básico fica assim:

  1. Packer sobe uma instância temporária a partir de uma AMI base
  2. Copia os playbooks Ansible para dentro da instância
  3. Executa o Ansible localmente, aplicando os roles definidos
  4. Roda os passos de limpeza (remove chaves temporárias, limpa logs, zera histórico de bash, remove credenciais de build)
  5. Congela tudo numa AMI nova, versionada

Um exemplo simplificado do template, em HCL2:

source "amazon-ebs" "app_base" {
  ami_name      = "app-golden-{{timestamp}}"
  instance_type = "t3a.large"
  region        = "sa-east-1"
  source_ami_filter {
    filters = {
      name                = "al2023-ami-*-arm64"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    owners      = ["amazon"]
    most_recent = true
  }
  ssh_username = "ec2-user"
}

build {
  sources = ["source.amazon-ebs.app_base"]

  provisioner "shell" {
    inline = ["sudo mkdir -p /etc/ansible/facts.d"]
  }

  provisioner "ansible-local" {
    playbook_file = "playbooks/site.yml"
    role_paths    = ["roles/hardening", "roles/app-setup", "roles/config-sync"]
  }

  provisioner "shell" {
    script = "scripts/cleanup.sh"
  }
}
Enter fullscreen mode Exit fullscreen mode

O ponto central é que o Ansible não toca a máquina de fora, ele roda dentro dela, como parte do build. Isso elimina a dependência de conectividade SSH externa durante o processo e faz o build funcionar de forma consistente em qualquer conta ou VPC, já que não precisa abrir rota de rede para um control node.

Um detalhe que fez diferença: Packer se conectando via SSM

Um ponto que vale a pena registrar é que o builder amazon-ebs do Packer não depende só de SSH tradicional para se conectar na instância de build. Existe a opção de usar o Session Manager (SSM) como interface de conexão, configurando ssh_interface = "session_manager" no builder.

Na prática, o Packer sobe um túnel via SSM até a porta de SSH da instância (por padrão 22) e conversa com ela por dentro desse túnel, sem precisar que a instância tenha uma rota de rede pública nem uma Security Group liberando a porta 22 para o seu IP. Para isso funcionar é necessário:

  • Anexar um Instance Profile válido com permissão de Systems Manager na instância de build (para o SSM Agent conseguir abrir a sessão)
  • Ter o session-manager-plugin instalado na máquina local que está rodando o Packer
  • Manter communicator = "ssh", já que o SSM aqui só substitui a forma como o túnel é estabelecido, o comunicador continua sendo SSH por baixo dos panos

Um exemplo de builder usando esse modelo:

source "amazon-ebs" "app_base_ssm" {
  ami_name             = "app-golden-{{timestamp}}"
  instance_type        = "m6a.large"
  region               = "sa-east-1"
  communicator         = "ssh"
  ssh_username         = "ec2-user"
  ssh_interface        = "session_manager"
  iam_instance_profile = "packer-ssm-instance-profile"
  source_ami_filter {
    filters = {
      name                = "al2023-ami-*-arm64"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    owners      = ["amazon"]
    most_recent = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Isso resolveu um incômodo real: builds de imagem deixaram de depender de sub-redes com saída para a internet ou de Security Groups abrindo porta 22 pontualmente durante o processo. A instância de build fica isolada, sem exposição de SSH, e o Packer se conecta por dentro da rede da AWS usando as mesmas permissões IAM que já governam o resto do ambiente. Menos superfície de ataque durante o processo de build, que é justamente o momento em que a máquina está mais "aberta" para receber comandos externos.

O pulo do gato: configuração fora da imagem

Aqui está a parte que, na minha visão, separa "automatizei o install" de "resolvi o problema de verdade": a imagem não carrega a configuração da aplicação embutida.

O Packer builda a AMI com tudo que é estrutural: pacotes, hardening, usuários, estrutura de diretórios, serviços systemd. Mas a configuração específica da aplicação (endpoints, parâmetros de conexão, feature flags, e por aí vai) fica no AWS Systems Manager Parameter Store, como fonte única de verdade.

Quando a instância sobe de fato em produção, fora do contexto de build, um serviço systemd dispara na inicialização, busca os parâmetros correspondentes ao ambiente/aplicação no Parameter Store e aplica a configuração local:

[Unit]
Description=Sync config from Parameter Store
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/app/bin/config-sync.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env bash
set -euo pipefail

APP_ENV=$(cat /etc/app/environment)
PREFIX="/app/${APP_ENV}/config"

aws ssm get-parameters-by-path \
  --path "${PREFIX}" \
  --recursive \
  --with-decryption \
  --query "Parameters[*].[Name,Value]" \
  --output text | while read -r name value; do
    key=$(basename "${name}")
    echo "${key}=${value}" >> /etc/app/app.env
  done

systemctl restart app.service
Enter fullscreen mode Exit fullscreen mode

Isso separa claramente duas responsabilidades:

  • Packer/Ansible cuidam do "o quê está instalado" (imutável, versionado, buildado uma vez)
  • Parameter Store cuida do "como está configurado agora" (mutável, centralizado, com revisão de histórico nativa do SSM)

Na prática, isso trouxe ganhos que não eram o objetivo inicial, mas viraram os mais valiosos:

  • Rastreabilidade de configuração: todo parâmetro tem histórico de mudança no próprio Parameter Store, então dá pra saber quem mudou o quê e quando, sem precisar caçar em log de aplicação
  • Configuração centralizada por ambiente: a mesma AMI serve para DEV, UAT e PROD, o que muda é só o path que ela lê no Parameter Store
  • Redeploy de configuração sem rebuild de imagem: ajustar um parâmetro não exige gerar uma AMI nova, só reiniciar o serviço de sync (ou deixar o próximo boot pegar)

O efeito colateral bom: Disaster Recovery

Esse foi o ponto que só ficou óbvio depois que a arquitetura já estava rodando: uma vez que você tem a golden AMI e a configuração centralizada e desacoplada no Parameter Store, replicar a aplicação para outra AZ ou outra região vira um problema de infraestrutura, não de aplicação.

O racional é direto:

  1. A AMI já tem tudo que a aplicação precisa para rodar (pacotes, hardening, serviços)
  2. A configuração não está embutida na imagem, está no Parameter Store, que também pode ser replicado ou lido cross-region, dependendo do desenho
  3. Logo, subir a aplicação numa nova AZ ou região é: copiar a AMI para o destino, subir a instância, deixar o config-sync buscar a configuração certa

Isso transformou o cenário de DR de "vamos ter que reconstruir isso na mão e torcer para lembrar de tudo" para "isso já é testado e replicável, porque é o mesmo processo usado todo dia para gerar imagem nova". Fizemos inclusive simulações de falha de AZ cobrindo essa camada, e a parte provisionada via Packer foi de longe a mais previsível de recuperar, justamente por não depender de passos manuais.

Hardening: CIS Level 1/2 embutido no build

Outro ganho direto de ter o Ansible rodando como parte do processo de build: hardening deixou de ser uma tarefa "depois que a máquina já está no ar" e virou parte do próprio pipeline de criação da imagem.

Os controles de CIS Benchmark, com Level 1 como baseline e alguns controles de Level 2 aplicados pontualmente onde fazia sentido, viraram uma role Ansible própria, aplicada em todo build:

- name: hardening
  hosts: localhost
  connection: local
  become: true
  roles:
    - role: cis-benchmark
      vars:
        cis_level: 1
        cis_al2023_specific: true
Enter fullscreen mode Exit fullscreen mode

Isso significa que toda AMI nova já nasce hardenizada, sem depender de rodar um scanner depois e corrigir desvio por desvio. O hardening é parte do artefato, não um processo paralelo.

Migração Amazon Linux 2 → Amazon Linux 2023

A mesma estrutura de Packer + Ansible foi o que viabilizou a migração de AL2 para AL2023 sem drama. Como o pipeline já era declarativo, roles Ansible descrevendo o estado desejado da máquina, e não passos manuais amarrados a uma distro específica, trocar a AMI base de origem e ajustar os pontos de incompatibilidade (principalmente em torno de pacotes renomeados e mudança de cgroups) foi um trabalho localizado, não um retrabalho do zero.

Isso reforça um ponto que vale destacar: o valor de ter Packer + Ansible não é só economizar tempo hoje, é ter a distro e a versão do sistema operacional como parâmetro do pipeline, não como algo enraizado no processo manual de alguém.

Antes Depois
Instalação manual, sem processo padronizado Build automatizado e versionado via Packer
Conexão de build dependendo de SSH exposto Build via SSM, sem exposição de porta 22
Configuração embutida/ad-hoc na máquina Configuração centralizada no Parameter Store, aplicada no boot
DR dependente de reconstrução manual DR via replicação de AMI + config-sync automático
Hardening aplicado depois, sob demanda Hardening (CIS) embutido no build
Migração de distro = retrabalho grande Migração de distro = ajuste pontual no pipeline

O maior aprendizado prático desse case não foi técnico, foi de desenho: a decisão de não colocar a configuração de aplicação dentro da imagem foi o que abriu espaço para os ganhos de DR e centralização. Se a configuração estivesse embutida na AMI, cada mudança de parâmetro exigiria rebuild, e a imagem deixaria de ser "golden" para virar só mais um artefato acoplado a um ambiente específico.

Packer resolve o "como eu construo isso de forma repetível". Ansible-local resolve o "como eu aplico o estado desejado sem depender de infraestrutura externa durante o build". A conexão via SSM resolve o "como eu faço isso sem expor a instância de build". E o Parameter Store resolve o "como eu mantenho isso configurável sem quebrar a imutabilidade da imagem". Separados, cada um resolve uma fatia do problema. Juntos, o resultado foi bem maior que a soma das partes.

Top comments (0)