Buzeli
buzeliSoluções Digitais
DevSecOps

PHP 7.4 → 8.4 em produção no ASG sem downtime: o processo de bake AMI que funciona (e os 2 erros que aprendemos)

Publicado em 30 de abril de 2026

O contexto: PHP em container, worker baked na AMI

A stack rodava PHP-FPM em containers Docker dentro de instâncias EC2 ARM no ASG. Três pools: pool0 (porta 9000), pool1 (9001), pool2 (9002). O worker de filas também rodava como serviço systemd dentro de uma das instâncias — tudo baked na AMI do Launch Template.

Upgrade de PHP 7.4.34 para 8.4.12. Versão major com breaking changes conhecidos. A aplicação já havia sido testada pelo time de desenvolvimento em ambiente de staging — a nossa parte era executar o upgrade em produção sem downtime.

Uma restrição importante aprendida em sessão anterior: Instance Refresh manual só pode ser executado durante o horário diurno. Com mínimo de 1 instância à noite, qualquer refresh substitui a única instância ativa — o que causa ~2 minutos de downtime. Durante o dia, com mínimo de 2 instâncias e MinHealthyPercentage=100, o ASG substitui uma por vez mantendo disponibilidade. Documentamos isso em outro post sobre o incidente de deploy noturno.

O processo de bake AMI: os 8 passos

O processo padrão para qualquer mudança de infraestrutura neste ambiente é sempre o mesmo — nunca modificar uma instância que está no ASG recebendo tráfego do ALB:

Copiar
1. Obter AMI e configs do Launch Template (LT) default
2. Subir instância temporária isolada a partir dessa AMI
   (fora do ASG, fora do target group do ALB)
3. Aplicar as mudanças na instância temporária e validar
4. Criar nova AMI da instância temporária (--no-reboot)
5. Terminar a instância temporária
6. Criar nova versão do Launch Template com a nova AMI
   (source = versão default atual, só muda ImageId)
7. Setar nova versão como default no LT
8. Iniciar Instance Refresh no ASG
   (MinHealthyPercentage=100, InstanceWarmup=120)

A instância temporária nunca entra no ALB. Todas as mudanças são testadas nela antes de virar AMI. Se algo der errado, a produção não é afetada — a temporária simplesmente é terminada.

Copiar
# Subir instância temporária a partir da AMI do LT default
TEMP_ID=$(aws ec2 run-instances --profile app-profile \
  --image-id <ami-lt-default> \
  --instance-type c6g.2xlarge \
  --key-name app-key \
  --security-group-ids <sg-id> \
  --subnet-id <subnet-privada> \
  --iam-instance-profile Arn=<iam-profile-arn> \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app-temp-bake}]' \
  --query 'Instances[0].InstanceId' --output text)

# Aguardar 2/2 health checks (não usar wait instance-running)
aws ec2 wait instance-status-ok --profile app-profile --instance-ids $TEMP_ID

# Instance Refresh (executar somente em horário diurno)
aws autoscaling start-instance-refresh --profile app-profile \
  --auto-scaling-group-name app-asg \
  --preferences '{"InstanceWarmup":120,"MinHealthyPercentage":100}' \
  --query 'InstanceRefreshId' --output text

Bake 1: o upgrade principal

O operador aplicou o upgrade de PHP manualmente na instância temporária e validou os containers:

Copiar
$ docker exec php-fpm-pool0 php --version
PHP 8.4.12 (cli) (built: Aug 28 2025 18:47:43) (NTS)
Zend Engine v4.4.12
  with Zend OPcache v8.4.12

$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' | grep php
php-fpm-pool2   php-fpm-custom:8.4.12   Up 3 minutes
php-fpm-pool0   php-fpm-custom:8.4.12   Up 3 minutes
php-fpm-pool1   php-fpm-custom:8.4.12   Up 3 minutes

AMI criada, instância temporária terminada, nova versão do LT criada, Instance Refresh iniciado às 19:14 -03:00. Concluído às 19:22 — exatos 8 minutos. Duas instâncias produção confirmadas com PHP 8.4.12.

Mas ao verificar os logs do CloudWatch nas horas seguintes, identificamos que os logs de erro do PHP-FPM não estavam chegando. Os pools rodavam, mas o log group estava silencioso.

O erro do Bake 1: logs PHP-FPM sem permissão de escrita

A investigação mostrou o problema: o diretório '/var/log/php-fpm/' pertencia a 'apache:root' com permissão 'drwxrwx---'. O processo PHP-FPM rodava como usuário 'www-data', que estava na categoria 'others' — sem acesso de escrita ao diretório.

Os pools tinham 'php_admin_value[error_log]' configurado para '/var/log/php-fpm/pool{0,1,2}.log', mas os arquivos não conseguiam ser criados porque o diretório não era acessível. Os erros PHP eram descartados silenciosamente.

Copiar
# Estado problemático (após Bake 1)
$ ls -la /var/log/ | grep php-fpm
drwxrwx---  2 apache root 4096 Mar 28 10:00 php-fpm/
# www-data não tem acesso (está em "others" = ---)

# Correção necessária para o Bake 2
sudo chgrp www-data /var/log/php-fpm/
sudo chmod g+rwx /var/log/php-fpm/
sudo touch /var/log/php-fpm/pool0.log \
           /var/log/php-fpm/pool1.log \
           /var/log/php-fpm/pool2.log
sudo chown www-data:www-data /var/log/php-fpm/pool*.log
Este erro não aparece em staging se o ambiente de staging usa configurações diferentes de permissão. É o tipo de problema que só aparece em produção — e só depois de um bake.

Bake 2: o ajuste de permissões e CloudWatch

Segunda instância temporária, desta vez a partir da AMI do Bake 1 (que já tinha PHP 8.4.12). As mudanças foram aplicadas em sequência:

1. Corrigir permissões do diretório /var/log/php-fpm/ (chgrp www-data + chmod g+rwx + criar arquivos pool*.log com owner www-data)

2. Atualizar configuração do CloudWatch agent para coletar os três arquivos de log dos pools

3. Validar escrita real nos arquivos antes de criar a AMI (docker exec sh -c 'echo x >> /var/log/php-fpm/pool0.log')

A configuração do CloudWatch agent foi atualizada para incluir os três pools. Um detalhe crítico: nunca usar o comando 'fetch-config' do CloudWatch agent para atualizar a configuração — ele sobrescreve o arquivo com um nome duplicado e o agente para silenciosamente. O correto é escrever o JSON diretamente no arquivo e reiniciar via systemctl.

Copiar
# ERRADO: fetch-config destrói a configuração existente
# sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
#   -a fetch-config -m ec2 -s -c file:/path/config.json  # NAO USAR

# CORRETO: escrever direto no arquivo + reiniciar o serviço
sudo python3 -c "
import json
config = {
    'logs': {
        'logs_collected': {
            'files': {
                'collect_list': [
                    {
                        'file_path': '/var/log/php-fpm/pool0.log',
                        'log_group_name': 'php-fpm-errors',
                        'log_stream_name': '{instance_id}-pool0'
                    },
                    {
                        'file_path': '/var/log/php-fpm/pool1.log',
                        'log_group_name': 'php-fpm-errors',
                        'log_stream_name': '{instance_id}-pool1'
                    },
                    {
                        'file_path': '/var/log/php-fpm/pool2.log',
                        'log_group_name': 'php-fpm-errors',
                        'log_stream_name': '{instance_id}-pool2'
                    }
                ]
            }
        }
    }
}
path = '/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.d/file_amazon-cloudwatch-agent.json'
open(path, 'w').write(json.dumps(config, indent=2))
print('OK')
"
sudo systemctl restart amazon-cloudwatch-agent
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -m ec2 -a status

Instance Refresh do Bake 2 iniciado às 19:34, concluído às 19:41 — mais 7 minutos. Logs do PHP-FPM chegando no CloudWatch nos streams '{instance_id}-pool0', '{instance_id}-pool1', '{instance_id}-pool2'.

Estado final: o histórico dos Launch Templates

Ao final da sessão, o histórico do Launch Template documentava o caminho percorrido:

Copiar
| Versão | Conteúdo                                    | Status          |
|--------|---------------------------------------------|-----------------|
| v606   | PHP 7.4.34 (base anterior)                  | Descartada      |
| v607   | PHP 8.4.12 (Bake 1 — sem permissões log)    | Descartada      |
| v608   | PHP 8.4.12 + permissões + logs CW (Bake 2)  | DEFAULT ATUAL   |

Instâncias de produção pós-refresh final: duas instâncias ARM rodando PHP 8.4.12 com logs de erro chegando no CloudWatch. Monitoramento ativo nas horas seguintes para detectar erros de compatibilidade PHP 8 que a aplicação pudesse gerar em produção.

Os 2 erros e o que eles ensinam

Erro 1: permissões do /var/log/php-fpm/

O diretório de logs foi criado com ownership 'apache:root' em algum momento histórico. O processo PHP-FPM (www-data) nunca conseguiu escrever. O erro era silencioso: nenhuma mensagem de falha, apenas ausência de logs.

Lição: ao configurar qualquer diretório de log para um processo que roda com usuário diferente do dono do diretório, validar explicitamente que o processo consegue escrever antes de bake. O teste é simples: 'docker exec sh -c "echo x >> arquivo.log"' e verificar se não há erro de permissão.

Erro 2: fetch-config destrói a configuração do CloudWatch agent

O comando 'amazon-cloudwatch-agent-ctl -a fetch-config' não atualiza a configuração — ele cria um arquivo novo com nome duplicado ('file_file_nome.json'), pode deixar o arquivo original com 0 bytes e o agente para silenciosamente. O status aparece como 'stopped' sem mensagem de erro clara.

Lição: para atualizar a configuração do CloudWatch agent, escrever diretamente no arquivo JSON existente e reiniciar o serviço via systemctl. Validar com '-a status' antes de criar a AMI.

Por que 2 bakes são normais, não um sinal de problema

Cada ciclo de bake revela uma camada que o anterior não antecipou. O primeiro bake cobre a mudança principal. O segundo cobre o que a validação do primeiro revelou. Em upgrades de versão major, dois ciclos é o padrão — não exceção. O processo existe justamente para absorver essas descobertas antes que afetem produção.

O custo total: dois Instance Refreshes de ~8 minutos cada, duas instâncias temporárias que existiram por menos de 20 minutos. Nenhum downtime. Nenhuma transação perdida.