PHP 驱动的自动化构建流:利用 GitHub Actions 与 Docker 镜像层缓存实现秒级全栈代码发布

题目:让代码“飞”起来的魔法:PHP 驱动的秒级 Docker 自动化构建流

各位码农朋友们,下午好,或者不管你们这会儿是在工位上还在代码里“冲浪”,亦或是刚加完班准备回家喂猫,都请听我说两句。

今天我们要聊的话题,大概能拯救你们一半的发际线。那就是:构建和部署

我知道,每次你们把代码 git push 到 GitHub 或者 GitLab,那个进度条就开始爬。你是盯着它看,还是假装没看见?通常情况下,你的大脑会自动进入休眠模式,或者开始思考中午吃面条还是盖浇饭。如果这时候构建失败了,好,你点开控制台,看到一坨红色的报错,然后开始“魔法攻击”——盯着屏幕发呆,试图用眼神把报错修好。

如果构建成功了呢?恭喜你,你还要看着 Docker 镜像一层层像蜗牛一样往上叠,最后推送到仓库。这一套流程下来,可能需要 10 分钟,也可能需要 20 分钟。对于前端页面来说,这可能只是加载一张 GIF 图片的时间;但对于后端开发者来说,这意味着你的大脑已经生锈了。

今天,我们要讲的不是怎么把猫抓进麻袋(那是隔壁运维的事),而是怎么利用 GitHub Actions 和 Docker 的镜像层缓存,打造一套 PHP 驱动的、能在秒级完成全栈代码发布的自动化构建流

我们要把那些“喝杯咖啡的时间”变成“喝口水的时间”。


第一章:为什么你的构建总是那么慢?

让我们先来剖析一下,一个典型的 PHP 全栈项目在 CI/CD 管道里是干什么的。

想象一下,你有一台虚拟机。这台虚拟机就像你的新房,刚搬进去的时候什么都没有,一片荒凉。
第一步,你需要安装操作系统(OS)。
第二步,你需要安装 PHP,配置 PHP-FPM,还要搞上 Nginx 或者 Apache。
第三步,最重要的一步,Composer 安装依赖。这时候,成千上万个 PHP 包像快递员一样涌进来,填满了你的家。
第四步,如果前端是 Vue 或 React,你需要 npm ci,把 Node_modules 塞满。
第五步,你开始构建 Docker 镜像。这就好比你要把这些东西打包成行李箱准备出门。
第六步,推送到仓库,部署到服务器。

问题出在哪里?

如果你每次构建都重复这些步骤,那你就是在给虚拟机“装修”一万次。每次修改一行 PHP 代码,你就得把所有东西重来一遍。

Docker 的核心思想是分层。就像你打包行李,你不会每次都把衣服拿出来再塞回去,你只是把换下来的脏衣服扔进脏衣篓,而不是扔掉整个箱子。

如果利用好 Docker 的层缓存机制,当你的代码变了,但 composer.json 没变的时候,Docker 应该能直接跳过安装依赖的步骤,只重新编译你修改的那部分代码。

但现实往往是残酷的。如果你在 Dockerfile 里把安装依赖的命令写在后面,或者把源代码文件和依赖文件混在一起 COPY,那么你的 Docker 就会变得非常“健忘”,每次都以为这是第一次见面,重新安装所有东西。


第二章:编写“聪明”的 Dockerfile

要想实现秒级构建,你的 Dockerfile 必须会“偷懒”。这不仅仅是形容,是技术上的“懒人策略”。

我们来看一个典型的、会拖慢构建速度的 Dockerfile:

# 这是一个很糟糕的例子,不要学它
FROM php:8.1-fpm
RUN apt-get update && apt-get install -y libpng-dev
RUN docker-php-ext-install gd pdo_mysql
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader
CMD ["php-fpm"]

看看这个流程:

  1. apt-get update:这是重活儿。
  2. COPY . .致命伤。这一行把你的所有源代码(包括 .git 目录、日志文件、IDE 配置)都复制进去了。
  3. RUN composer install:每次这一行运行,都会导致它上面的层(也就是 COPY . .)被视为“脏”的。因为 Docker 发现文件变了。于是,这一行上面的所有层(包括基础镜像、apt 安装)都会被标记为不可用。
  4. 下次你只改了一个函数,这行 RUN composer install 还得照跑不误。

正确的姿势是:将依赖安装层与源代码层剥离

想象一下,我们把“打包行李”的过程分为两个阶段:

  1. 先去买东西(安装依赖)。
  2. 再把衣服放进去(复制源代码)。

如果衣服没换,就不用再买一遍东西了,直接放进去就行。

优化后的 Dockerfile 应该长这样:

# 阶段 1:构建依赖环境
FROM composer:2 AS composer-stage
WORKDIR /app
# 只复制依赖文件,确保只有依赖变动时才重跑这一步
COPY composer.json composer.lock ./
# 关键参数解析:
# --no-scripts: 不执行 composer.json 里的脚本,省时间
# --no-dev: 生产环境不需要开发依赖,省空间也省时间
# --prefer-dist: 下载压缩包而不是 clone 代码,速度极快
RUN composer install --no-scripts --no-dev --prefer-dist --optimize-autoloader

# 阶段 2:最终运行环境
FROM php:8.1-fpm

WORKDIR /var/www/html

# 从 stage 1 复制 vendor 目录(已经打包好的依赖)
COPY --from=composer-stage /app/vendor ./vendor

# 复制源代码(注意:这一步改动会导致重新构建,但前面的依赖是不变的)
COPY . .

# 安装系统扩展
RUN apt-get update && apt-get install -y libpng-dev 
    && docker-php-ext-install gd pdo_mysql 
    && apt-get clean 
    && rm -rf /var/lib/apt/lists/*

CMD ["php-fpm"]

看到区别了吗?在第二阶段,我们直接用 COPY --from 把已经安装好的 vendor 目录拿过来。只要 composer.json 没变,这一步就是瞬间的。这为后续的秒级构建打下了地基。


第三章:GitHub Actions 的缓存艺术

好了,有了聪明的 Dockerfile,我们还需要一个聪明的 CI/CD 管道。这里我们要请出主角:GitHub Actions。

GitHub Actions 本身是很强大的,但如果你不懂得如何利用它的缓存功能,它就会像个不懂事的熊孩子,把你的构建资源吃得干干净净。

让我们来写一个 .github/workflows/deploy.yml

核心策略:

  1. 缓存 Composer 依赖:不要每次都从国外下载 Composer 包,把缓存存在 GitHub 的缓存存储里。
  2. 缓存 Node_modules:前端也是同理。
  3. 利用 Docker BuildKit:使用 GitHub Actions 支持的构建缓存挂载功能。
name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    # 1. 检出代码
    - name: Checkout code
      uses: actions/checkout@v3

    # 2. 设置 PHP 环境(安装 Composer)
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.1'
        extensions: mbstring, pdo, pdo_mysql
        coverage: none
        tools: composer:v2

    # 3. 缓存 Composer 依赖(核心步骤!)
    - name: Get Composer Cache Directory
      id: composer-cache
      run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

    - name: Cache Composer dependencies
      uses: actions/cache@v3
      with:
        path: ${{ steps.composer-cache.outputs.dir }}
        key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
        restore-keys: |
          ${{ runner.os }}-composer-

    # 4. 安装依赖
    - name: Install dependencies
      run: composer install --no-scripts --no-dev --prefer-dist --optimize-autoloader

    # 5. 缓存 Node modules (如果有的话)
    - name: Cache node modules
      uses: actions/cache@v3
      id: npm-cache
      with:
        path: '**/node_modules'
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-node-

    - name: Install Node dependencies
      run: npm ci

    # 6. 构建 Docker 镜像
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2

    - name: Build and push
      uses: docker/build-push-action@v4
      with:
        context: .
        file: ./Dockerfile
        push: true
        tags: my-registry/my-image:${{ github.sha }}
        # 关键优化:使用 GitHub Actions 缓存来加速 Docker 构建层
        cache-from: type=gha
        cache-to: type=gha,mode=max

这里有几个技术细节大家要注意:

  1. key 的生成:我们使用 hashFiles('**/composer.lock')。这意味着只有当你修改了 composer.lock(通常是依赖版本变更时),才会生成新的缓存 key。如果你只是改了业务代码,Key 不变,GitHub Actions 就会直接把旧缓存拿出来,速度极快
  2. restore-keys:这是一个退路。如果找不到精确的 Key,它会尝试找前缀匹配的。这能极大提高命中率。
  3. Docker BuildKit 的 Cache:在最后一步,我们配置了 cache-from: type=gha。这告诉 Docker:“嘿,去 GitHub Actions 的缓存里看看有没有我上次构建的层,有的话直接复用,别重新编译了。”

第四章:PHP 里的“骚操作”

既然是 PHP 驱动的构建流,我们还需要利用 PHP 本身的一些特性来优化速度。

1. Composer 的 --no-scripts--no-dev
在 CI 环境中,你通常不需要运行 composer install 后面跟着的那些脚本(比如生成 .env 文件或者运行测试)。--no-scripts 就是告诉 Composer:“别搞那些花里胡哨的,只安装包。”
--no-dev 不安装开发依赖。开发依赖通常包括 PHPUnit、PHPStan 等工具。它们对运行时的性能是零贡献的,却能显著增加构建时间。在生产镜像里,它们是多余的累赘。

2. Phinx 或其他迁移工具的自动化
如果你的项目用了数据库迁移工具,别让它们在 CI 里跑。在 CI 里只跑构建,在部署阶段再跑迁移。这能把构建时间再砍掉 5-10 秒。

3. 优化 PHP-FPM 配置
在 Dockerfile 里,我们通常会复制一份 php-fpm.conf
比如,你可以把 pm.max_children 调高,但这只影响运行时性能。在构建时,我们要关注的是 opcache.enable=1。虽然这通常是运行时开启的,但在 Docker 镜像构建阶段开启它,能确保后续运行时 PHP 是满血的。


第五章:全栈整合与“秒级”的错觉

到这里,我们拥有了:

  1. 分层安装的 Dockerfile(Docker 层缓存)。
  2. GitHub Actions 的远程缓存(CI 层缓存)。

但是,要实现真正的“秒级发布”,我们还需要考虑代码层面的改动。如果你改了 100 个文件,Docker 虽然不用重新安装依赖,但它还是要把 100 个文件复制进镜像里,然后重新运行 PHP 的编译。

有没有办法让复制文件也变快?

技巧:使用 Docker BuildKit 的 --mount=type=bind

这是现代 Docker 构建的一个黑科技。传统的 COPY 命令是在构建上下文里复制文件。而 --mount=type=bind 可以直接把宿主机(或者 CI 机器)的目录挂载到容器里。

对于单阶段构建或者需要频繁修改小文件的场景,这比 COPY 快得多,因为它省去了构建上下文打包和传输的过程。

但这在 PHP 领域应用得比较少,因为 PHP 依赖的是文件的元数据(修改时间)。不过,我们可以通过增量编译来辅助。

比如,如果前端是用 Webpack 打包的,我们可以配置 watch 模式,只有变动的文件才重新编译。但这超出了 Docker 的范畴,属于构建工具的范畴。

我们回到正题。所谓的“秒级”,通常是相对于之前的“分钟级”而言的。 在我们优化的方案下,如果一个开发者只改了 1 个 PHP 文件,推送代码:

  1. GitHub Actions 触发。
  2. 检测到 composer.lock 没变 -> 直接命中 Composer 缓存(耗时 2 秒)。
  3. 检测到 Node modules 没变 -> 直接命中 npm 缓存(耗时 1 秒)。
  4. Docker 构建 -> 直接命中 Docker 层缓存(只重新生成新代码层,耗时 5-10 秒)。
  5. 推送镜像 -> 耗时 5 秒。

总计:20-25 秒。 相比之前的 5-10 分钟,这简直就是瞬移。


第六章:进阶篇——远程 Docker 镜像缓存

如果你在 GitHub Actions 的 Matrix(矩阵)里构建多个 PHP 版本(比如 7.4 和 8.1),你会遇到一个问题:Docker 的本地缓存是绑定在运行构建的那个机器上的。如果你在 8.1 的构建里缓存了层,8.0 的构建是看不到的。

这时候,我们需要把 Docker 缓存存到别的地方,比如 GitHub Package Registry(GPR)或者 Docker Registry 本身。

我们可以修改构建步骤:

- name: Build and push
  uses: docker/build-push-action@v4
  with:
    context: .
    push: true
    tags: registry.example.com/my-app:${{ github.sha }}
    # 指定远程缓存源
    cache-from: type=registry,ref=registry.example.com/my-app:buildcache
    # 指定远程缓存目标
    cache-to: type=registry,ref=registry.example.com/my-app:buildcache,mode=max

原理是这样的:

  1. 在构建第一个版本(比如 PHP 8.1)时,Docker 会把构建出来的每一层都推送到 registry.example.com/my-app:buildcache
  2. 在构建下一个版本(比如 PHP 8.0)时,Docker 会先去那个 Registry 里拉取 buildcache 镜像作为源。
  3. 因为两个版本的 composer install 大部分是相同的(除了 PHP 自身的包),所以 buildcache 里会有很多共享层。

注意: 这要求你的 Docker Registry 支持这个特性(AWS ECR、Google Artifact Registry、以及配置了 Registry V2 的 Harbor 或私有仓库通常都支持)。

这就像是把你的行李箱快递寄回了家。下次你离家时,不需要重新买衣服,只需要带上家里寄来的衣服,再带上新买的东西,就能出门了。


第七章:PHP 项目的常见“坑”与避坑指南

在实施这套方案的过程中,你可能会遇到一些 PHP 特有的坑,如果避不开,缓存机制就会失效。

1. 神秘的文件权限问题
这是一个老生常谈的问题了。你在本地运行 composer install,文件拥有者是 1000:1000。你在 GitHub Actions 里运行,文件拥有者变成了 root:root。Docker 构建时复制过去,容器里执行 PHP 代码,文件被锁死,权限被拒绝。

解决方案:
在 Dockerfile 的 COPY --from=composer-stage 之后,立即执行修复权限的命令:

COPY --from=composer-stage /app/vendor ./vendor
# 修复权限,确保容器内的 www-data 用户能读写
RUN chown -R www-data:www-data /var/www/html 
    && chmod -R 755 /var/www/html

2. Composer 的 vendor 目录位置
Composer 有一个默认配置叫 vendor-dir,默认是 vendor。如果你改成了 lib/vendor,你的缓存路径配置就必须跟着变,否则 GitHub Actions 就会在宿主机上找不到缓存文件,然后重新下载一遍。这就导致缓存失效,构建又变慢了。

3. composer.json 里的 scripts
有些项目的 scripts 阶段会运行 php artisan optimize:clear。这会删除缓存文件。如果你在构建过程中运行了清理命令,可能会导致后续的 optimize-autoloader 重新生成,这没关系。但如果脚本里有复杂的逻辑,比如网络请求,那就会拖慢构建。一定要在 CI 环境下限制它。


第八章:从构建到发布的闭环

构建好了,镜像推好了,怎么发布?

这里我们不讲 Kubernetes 的 yaml 编写(那又是另一个长篇大论了),我们讲一个轻量级的方案:使用 GitHub Actions 的手动触发部署步骤

我们可以在 workflow 文件的最后加一个 job,专门负责更新服务器。

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
    - name: Deploy to Server
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.SERVER_HOST }}
        username: ${{ secrets.SERVER_USER }}
        key: ${{ secrets.SERVER_SSH_KEY }}
        script: |
          # 1. 停止旧容器
          docker stop my-php-app
          # 2. 删除旧镜像(为了节省磁盘空间,可选)
          docker rmi my-registry/my-image:${{ github.sha }}
          # 3. 拉取新镜像
          docker pull my-registry/my-image:${{ github.sha }}
          # 4. 运行新容器
          docker run -d --name my-php-app -p 80:80 my-registry/my-image:${{ github.sha }}
          # 5. 健康检查(可选)
          sleep 5
          curl -f http://localhost/ || exit 1

这套流程非常硬核且快速。整个过程是:代码提交 -> 自动构建 -> 镜像缓存命中 -> 推送镜像 -> SSH 登录 -> 停止旧容器 -> 拉取新镜像 -> 启动新容器

当你看到终端里输出 Deploy to Server 成功,而前面的构建只花了 30 秒时,你会发现那种感觉——怎么说呢?就像是你刚刚按下了快门,下一秒照片就出现在了相纸上。那种掌控感,那种“代码飞得比光还快”的爽快感,会上瘾的。


第九章:总结与展望

好了,各位老铁,今天的讲座就到这里。

我们回顾一下今天的内容:

  1. Docker 分层:不要把源代码和依赖混在一起 COPY,要把依赖层剥离在最底层。
  2. GitHub Actions 缓存:利用 actions/cache 缓存 Composer 依赖,利用 docker/build-push-actioncache-fromcache-to 缓存 Docker 层。
  3. PHP 优化:使用 --no-dev--no-scripts 告诉 Composer 别偷懒,别运行脚本。
  4. 权限处理:别忘了修复 chown

这套方案之所以强大,是因为它结合了容器化的分层优势和 CI/CD 的缓存优势。它解决了软件工程中最原始的问题:重复劳动

当你把这整套东西部署到你的生产环境,你会发现,当你修改了一个 Bug,等待发布的时间比你修复 Bug 的时间还要短。这不仅是效率的提升,更是工作体验的质变。

如果你现在还在手动登录服务器,一个一个文件 scp 上去,或者还在忍受那个转圈圈的构建进度条,那么,赶紧去把 .github/workflows 的文件改了吧。

毕竟,在这个时间就是金钱、时间就是发际线的时代,谁愿意为了构建一个镜像去喝三杯咖啡呢?

谢谢大家!祝大家代码无 Bug,构建如闪电!

(完)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注