Ansible 滚动部署零停机

解读

国内互联网业务对可用性要求普遍在 99.9% 以上,PHP 项目上线频繁,传统“全量替换”会造成 3~10 秒 502/504,直接影响收入与 SEO。面试官问“Ansible 滚动部署零停机”,核心想验证:

  1. 你是否理解“滚动”与“蓝绿/金丝雀”的区别;
  2. 能否用 Ansible 原生机制(serial、wait_for、health check)而非盲目拼 Shell;
  3. 是否具备 PHP-FPM/Nginx 运行时的细节知识(opcache 重置、session 保持、文件锁);
  4. 是否能把 Composer 安装、迁移、缓存预热、健康检查、监控告警串成闭环。

答得太浅(只写一句“serial: 1”)会被判为“没上过线”;答得太偏(把 Ansible 当 K8s 用)会被判为“过度设计”。必须给出“中小团队可落地、大厂可复用”的折中方案。

知识点

  1. Ansible 核心机制:serial、max_fail_percentage、pre_tasks/post_tasks、delegate_to、wait_for、uri 模块。
  2. PHP 运行时零停机关键点:
    • opcache 缓存刷新:opcache_reset() 只能在 CLI 下执行,需通过“文件版本号”或“opcache.validate_timestamps=1”避免旧代码残留。
    • FPM 平滑重启:USR2 信号生成新 master,旧 master 处理完存量请求后退出;需监听 /status 或 ping 接口确认 Ready。
    • 共享存储:session.save_handler = redis/memcached,避免本地文件锁导致用户掉线。
    • 代码原子性:使用 symlink 切换目录,确保“mv”操作原子。
  3. 负载均衡层:阿里云 SLB/腾讯云 CLB/Nginx upstream,通过 Ansible 调用 API 或动态配置 consul-template 下线节点。
  4. SQL 迁移:Laravel/Symfony 的 migrate --force 放在滚动批次外,先预演(--pretend),再执行;大表使用 pt-online-schema-change。
  5. 回滚策略:保留上一个版本目录与 release 记录,Ansible 回滚 playbook 能在 30 秒内完成 symlink 回指。

答案

下面给出一套在 20 台 PHP-FPM 节点、Nginx 反向代理、阿里云 SLB 环境下的“最小可用”方案,代码可直接写进面试白板,逻辑清晰无坑。

  1. 目录规范(Capistrano 风格)
/var/www/php-app/
├── releases/
│   ├── 20240625140000/
│   └── 20240625150000/
├── shared/
│   ├── .env
│   ├── storage
│   └── runtime
└── current -> releases/20240625150000
  1. 发布变量(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
  1. 主 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
  1. 回滚 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
  1. 常见坑与对策
  • 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%→全量”的可控阶梯。

拓展思考

  1. 如果集群规模上千台,Ansible 的 ssh 串行瓶颈明显,可改用 Ansible Runner + Message Queue(Redis/RabbitMQ)把“分批”逻辑下沉到自研 Agent,或直接使用 Kubernetes + RollingUpdate,但 PHP 容器化需注意:

    • 容器内 FPM 进程数与 CPU limit 的换算;
    • 镜像体积控制(multi-stage 构建,composer 缓存挂载);
    • 日志输出到 stdout,方便 SLS/ELK 采集。
  2. 对数据库友好型部署,可引入“双写→校验→切流”方案:新业务代码同时写旧字段与新字段,校验数据一致后再通过配置中心切换,实现“代码零停机 + 数据库零停机”。

  3. 安全加固:Ansible Vault 加密 .env 中的密钥;使用 --check --diff 先预演;在 CI(GitLab CI/GitHub Actions)里跑 ansible-lint、phpstan、PHPUnit,确保“可部署”等于“质量达标”。

  4. 监控闭环:在健康检查阶段把版本号写入 Prometheus Pushgateway,Grafana 大盘对比“版本分布→错误率→P99 响应”,若错误率突增自动触发 webhook 调用 rollback.yml,实现“无人值守回滚”。

掌握以上要点,面试时先画架构图(SLB→Nginx→PHP-FPM→Redis/MySQL),再讲 serial 策略,最后给出回滚时间 ≤30 秒的量化指标,基本能拿到“高可用/运维自动化”方向的加分。