在ASG上将PHP 7.4升级到8.4且零停机:有效的AMI烘焙流程(以及我们从2个错误中学到的教训)
发布于 2026年4月30日
背景:容器中的PHP,worker烘焙到AMI中
技术栈在ASG的ARM EC2实例内的Docker容器中运行PHP-FPM。三个进程池:pool0(端口9000)、pool1(9001)、pool2(9002)。队列worker也作为systemd服务在其中一个实例内运行——一切都烘焙到Launch Template的AMI中。
从PHP 7.4.34升级到8.4.12。这是一个有已知破坏性变更的主要版本。应用程序已由开发团队在预发布环境中测试——我们的工作是在不停机的情况下在生产环境执行升级。
在之前的会话中学到的一个重要约束:手动Instance Refresh只能在白天时段执行。夜间最小实例数为1时,任何刷新都会替换唯一的活动实例——导致约2分钟的停机。白天,最小实例数为2且MinHealthyPercentage=100时,ASG每次替换一个实例同时保持可用性。我们在关于夜间部署事故的另一篇文章中记录了这一点。
AMI烘焙流程:8个步骤
此环境中任何基础设施变更的标准流程始终相同——绝不修改正在ASG中接收ALB流量的实例:
1. 从默认Launch Template(LT)获取AMI和配置
2. 从该AMI启动隔离的临时实例
(在ASG外,在ALB目标组外)
3. 在临时实例上应用变更并验证
4. 从临时实例创建新AMI(--no-reboot)
5. 终止临时实例
6. 使用新AMI创建新版本的Launch Template
(源版本 = 当前默认版本,只更改ImageId)
7. 将新版本设为LT中的默认版本
8. 在ASG上启动Instance Refresh
(MinHealthyPercentage=100,InstanceWarmup=120)临时实例永远不会加入ALB。所有变更都在临时实例上测试后才成为AMI。如果出现问题,生产环境不受影响——临时实例直接被终止。
# 从默认LT的AMI启动临时实例
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 <私有子网> \
--iam-instance-profile Arn=<iam-profile-arn> \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app-temp-bake}]' \
--query 'Instances[0].InstanceId' --output text)
# 等待2/2健康检查(不要使用wait instance-running)
aws ec2 wait instance-status-ok --profile app-profile --instance-ids $TEMP_ID
# Instance Refresh(只在白天时段执行)
aws autoscaling start-instance-refresh --profile app-profile \
--auto-scaling-group-name app-asg \
--preferences '{"InstanceWarmup":120,"MinHealthyPercentage":100}' \
--query 'InstanceRefreshId' --output text烘焙1:主要升级
操作员在临时实例上手动应用了PHP升级并验证了容器:
$ 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分钟
php-fpm-pool0 php-fpm-custom:8.4.12 Up 3分钟
php-fpm-pool1 php-fpm-custom:8.4.12 Up 3分钟AMI创建完毕,临时实例已终止,新LT版本已创建,Instance Refresh于19:14 -03:00启动。于19:22完成——恰好8分钟。两个生产实例确认运行PHP 8.4.12。
但在随后几小时检查CloudWatch日志时,我们发现PHP-FPM错误日志没有到达。进程池在运行,但日志组是静默的。
烘焙1的错误:PHP-FPM日志没有写入权限
调查显示了问题所在:'/var/log/php-fpm/'目录属于'apache:root',权限为'drwxrwx---'。PHP-FPM进程以'www-data'用户运行,而该用户在'others'类别中——没有对该目录的写入权限。
各进程池的'php_admin_value[error_log]'配置指向'/var/log/php-fpm/pool{0,1,2}.log',但由于目录不可访问,文件无法被创建。PHP错误被静默丢弃。
# 问题状态(烘焙1之后)
$ ls -la /var/log/ | grep php-fpm
drwxrwx--- 2 apache root 4096 Mar 28 10:00 php-fpm/
# www-data没有访问权限(在"others"中 = ---)
# 烘焙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如果预发布环境使用不同的权限设置,这个错误不会在预发布中出现。这是只在生产环境中才会出现的问题——而且只在烘焙之后才会发现。
烘焙2:权限和CloudWatch修复
第二个临时实例,这次从烘焙1的AMI(已经有PHP 8.4.12)启动。按顺序应用变更:
1. 修复/var/log/php-fpm/的权限(chgrp www-data + chmod g+rwx + 以www-data为所有者创建pool*.log文件)
2. 更新CloudWatch agent配置以收集三个进程池的日志文件
3. 创建AMI前验证文件实际写入(docker exec sh -c 'echo x >> /var/log/php-fpm/pool0.log')
CloudWatch agent配置已更新以包含三个进程池。一个关键细节:永远不要使用CloudWatch agent的'fetch-config'命令来更新配置——它会用重复的名称覆盖文件,agent会静默停止。正确的做法是直接将JSON写入文件,然后通过systemctl重启。
# 错误:fetch-config会破坏现有配置
# sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
# -a fetch-config -m ec2 -s -c file:/path/config.json # 不要使用
# 正确:直接写入文件 + 重启服务
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('完成')
"
sudo systemctl restart amazon-cloudwatch-agent
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -m ec2 -a status烘焙2的Instance Refresh于19:34启动,19:41完成——又是7分钟。PHP-FPM日志以'{instance_id}-pool0'、'{instance_id}-pool1'、'{instance_id}-pool2'的流名称到达CloudWatch。
最终状态:Launch Template历史
会话结束时,Launch Template历史记录了所经历的路径:
| 版本 | 内容 | 状态 |
|-------|------------------------------------------------|--------------|
| v606 | PHP 7.4.34(之前的基础版本) | 已废弃 |
| v607 | PHP 8.4.12(烘焙1——缺少日志权限) | 已废弃 |
| v608 | PHP 8.4.12 + 权限 + CW日志(烘焙2) | 当前默认版本 |最终刷新后的生产实例:两个运行PHP 8.4.12的ARM实例,错误日志到达CloudWatch。随后几小时内进行主动监控,以检测应用程序在生产中可能产生的PHP 8兼容性错误。
2个错误及其教训
错误1:/var/log/php-fpm/的权限
日志目录在某个历史时刻以'apache:root'所有权创建。PHP-FPM进程(www-data)从未能够写入。错误是静默的:没有失败消息,只是日志缺失。
教训:为与目录所有者不同的用户运行的进程配置任何日志目录时,在烘焙前明确验证进程能够写入。测试很简单:'docker exec sh -c "echo x >> file.log"',验证没有权限错误。
错误2:fetch-config破坏CloudWatch agent配置
'amazon-cloudwatch-agent-ctl -a fetch-config'命令不会更新配置——它会创建一个名称重复的新文件('file_file_name.json'),可能让原始文件变为0字节,agent会静默停止。状态显示为'stopped',没有明确的错误消息。
教训:要更新CloudWatch agent配置,直接写入现有JSON文件并通过systemctl重启服务。创建AMI前用'-a status'验证。
为什么2次烘焙是正常的,而不是问题的标志
每个烘焙周期都揭露了前一个没有预料到的层面。第一次烘焙涵盖主要变更。第二次烘焙涵盖第一次验证所揭露的内容。在主版本升级中,两个周期是标准做法——而不是例外。这个流程的存在正是为了在这些发现影响生产之前将其吸收。
总成本:两次各约8分钟的Instance Refresh,两个存在不到20分钟的临时实例。没有停机。没有丢失的交易。
