Ansible 滚动部署零停机
解读
国内互联网业务对可用性要求普遍在 99.9% 以上,PHP 项目上线频繁,传统“全量替换”会造成 3~10 秒 502/504,直接影响收入与 SEO。面试官问“Ansible 滚动部署零停机”,核心想验证:
- 你是否理解“滚动”与“蓝绿/金丝雀”的区别;
- 能否用 Ansible 原生机制(serial、wait_for、health check)而非盲目拼 Shell;
- 是否具备 PHP-FPM/Nginx 运行时的细节知识(opcache 重置、session 保持、文件锁);
- 是否能把 Composer 安装、迁移、缓存预热、健康检查、监控告警串成闭环。
答得太浅(只写一句“serial: 1”)会被判为“没上过线”;答得太偏(把 Ansible 当 K8s 用)会被判为“过度设计”。必须给出“中小团队可落地、大厂可复用”的折中方案。
知识点
- Ansible 核心机制:serial、max_fail_percentage、pre_tasks/post_tasks、delegate_to、wait_for、uri 模块。
- PHP 运行时零停机关键点:
- opcache 缓存刷新:opcache_reset() 只能在 CLI 下执行,需通过“文件版本号”或“opcache.validate_timestamps=1”避免旧代码残留。
- FPM 平滑重启:USR2 信号生成新 master,旧 master 处理完存量请求后退出;需监听 /status 或 ping 接口确认 Ready。
- 共享存储:session.save_handler = redis/memcached,避免本地文件锁导致用户掉线。
- 代码原子性:使用 symlink 切换目录,确保“mv”操作原子。
- 负载均衡层:阿里云 SLB/腾讯云 CLB/Nginx upstream,通过 Ansible 调用 API 或动态配置 consul-template 下线节点。
- SQL 迁移:Laravel/Symfony 的 migrate --force 放在滚动批次外,先预演(--pretend),再执行;大表使用 pt-online-schema-change。
- 回滚策略:保留上一个版本目录与 release 记录,Ansible 回滚 playbook 能在 30 秒内完成 symlink 回指。
答案
下面给出一套在 20 台 PHP-FPM 节点、Nginx 反向代理、阿里云 SLB 环境下的“最小可用”方案,代码可直接写进面试白板,逻辑清晰无坑。
- 目录规范(Capistrano 风格)
/var/www/php-app/
├── releases/
│ ├── 20240625140000/
│ └── 20240625150000/
├── shared/
│ ├── .env
│ ├── storage
│ └── runtime
└── current -> releases/20240625150000
- 发布变量(group_vars/all)
project_name: php-app
releases_keep: 5
serial_count: 20% # 每次滚动 20% 机器
health_check_url: http://{{ ansible_default_ipv4.address }}:9000/ping
health_check_timeout: 30
- 主 playbook:rolling-deploy.yml
- hosts: php_fpm
serial: "{{ serial_count }}"
max_fail_percentage: 0 # 任何一台失败立即终止,保证安全
vars:
release_id: "{{ ansible_date_time.epoch }}"
release_path: "/var/www/{{ project_name }}/releases/{{ release_id }}"
current_path: "/var/www/{{ project_name }}/current"
tasks:
- name: 1. 从 Git 拉新代码
git:
repo: "git@gitlab.xxx.cn:php/{{ project_name }}.git"
dest: "{{ release_path }}"
version: "{{ git_version | default('master') }}"
force: yes
- name: 2. 安装 Composer 依赖
shell: composer install --no-dev --optimize-autoloader
args:
chdir: "{{ release_path }}"
- name: 3. 创建共享文件软链
file:
src: "/var/www/{{ project_name }}/shared/{{ item }}"
dest: "{{ release_path }}/{{ item }}"
state: link
loop:
- .env
- storage
- runtime
- name: 4. 数据库迁移(仅第一台执行)
shell: php artisan migrate --force
when: ansible_play_hosts.index(inventory_hostname) == 0
delegate_to: "{{ groups.php_fpm[0] }}"
run_once: true
- name: 5. 预热 opcache(CLI 下)
shell: php artisan opcache:preload
args:
chdir: "{{ release_path }}"
- name: 6. 下线节点(SLB API)
uri:
url: "https://slb.aliyuncs.com/?Action=RemoveBackendServers&LoadBalancerId={{ slb_id }}&BackendServers=[{{ ansible_default_ipv4.address }}]"
method: GET
headers:
Authorization: "{{ slb_token }}"
delegate_to: localhost
- name: 7. 等待活跃连接为 0
shell: netstat -an | grep :9000 | grep ESTABLISHED | wc -l
register: conn
until: conn.stdout|int == 0
retries: 30
delay: 2
- name: 8. 切换软链
file:
src: "{{ release_path }}"
dest: "{{ current_path }}"
state: link
force: yes
notify: reload php-fpm
- name: 9. 启动 php-fpm 新 master
systemd:
name: php-fpm
state: reloaded
- name: 10. 健康检查
uri:
url: "{{ health_check_url }}"
status_code: 200
register: health
until: health.status == 200
retries: "{{ health_check_timeout }}"
delay: 1
- name: 11. 重新上线节点
uri:
url: "https://slb.aliyuncs.com/?Action=AddBackendServers&LoadBalancerId={{ slb_id }}&BackendServers=[{{ ansible_default_ipv4.address }}]"
method: GET
headers:
Authorization: "{{ slb_token }}"
delegate_to: localhost
post_tasks:
- name: 12. 清理旧版本
shell: ls -t /var/www/{{ project_name }}/releases | tail -n +{{ releases_keep|int + 1 }} | xargs -r rm -rf
delegate_to: "{{ groups.php_fpm[0] }}"
run_once: true
- 回滚 playbook:rollback.yml
- hosts: php_fpm
serial: "{{ serial_count }}"
tasks:
- name: 回指上一个 release
shell: |
ls -dt /var/www/{{ project_name }}/releases/* | sed -n '2p' | xargs -I {} ln -sfn {} /var/www/{{ project_name }}/current
notify: reload php-fpm
handlers:
- name: reload php-fpm
systemd:
name: php-fpm
state: reloaded
- 常见坑与对策
- opcache 未刷新:在 .env 中增加 VERSION 变量,每次部署自动变更,确保 validate_timestamps=1 时重新编译。
- session 漂移:统一使用 redis,key 带前缀 PHPSESSID,cookie_domain 设置 .xxx.cn。
- 大文件上传中断:Nginx 上传进度模块使用 shared memory,部署时不清空 /var/lib/nginx/tmp。
- 灰度需求:把 serial 改成 list [1,2,3,10,100] 实现“先一台→10%→全量”的可控阶梯。
拓展思考
-
如果集群规模上千台,Ansible 的 ssh 串行瓶颈明显,可改用 Ansible Runner + Message Queue(Redis/RabbitMQ)把“分批”逻辑下沉到自研 Agent,或直接使用 Kubernetes + RollingUpdate,但 PHP 容器化需注意:
- 容器内 FPM 进程数与 CPU limit 的换算;
- 镜像体积控制(multi-stage 构建,composer 缓存挂载);
- 日志输出到 stdout,方便 SLS/ELK 采集。
-
对数据库友好型部署,可引入“双写→校验→切流”方案:新业务代码同时写旧字段与新字段,校验数据一致后再通过配置中心切换,实现“代码零停机 + 数据库零停机”。
-
安全加固:Ansible Vault 加密 .env 中的密钥;使用 --check --diff 先预演;在 CI(GitLab CI/GitHub Actions)里跑 ansible-lint、phpstan、PHPUnit,确保“可部署”等于“质量达标”。
-
监控闭环:在健康检查阶段把版本号写入 Prometheus Pushgateway,Grafana 大盘对比“版本分布→错误率→P99 响应”,若错误率突增自动触发 webhook 调用 rollback.yml,实现“无人值守回滚”。
掌握以上要点,面试时先画架构图(SLB→Nginx→PHP-FPM→Redis/MySQL),再讲 serial 策略,最后给出回滚时间 ≤30 秒的量化指标,基本能拿到“高可用/运维自动化”方向的加分。