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

大家好,欢迎来到今天的“PHP 架构师的晨间咖啡”特别讲座。

我是你们的老朋友,一个看着代码从“Hello World”变成“Hello Database Error”然后又变成“Hello Profit”的资深老兵。

今天,我们不聊抽象工厂模式,也不深究垃圾回收算法的底层实现(虽然我很想聊)。今天我们要聊点实用的——怎么让你的 PHP 项目在 GitHub Actions 上像闪电一样快地跑完。

如果你是一个全栈开发者,或者你在维护一个几百兆的 PHP 仓库,你一定经历过这种绝望:

  • 场景一: 你写了一个简单的 echo "test";,提交代码,去楼下买杯咖啡,回来一看,CI 环境还在 composer install,进度条卡在 90%。
  • 场景二: 团队来了个新人,提交代码触发构建,结果构建失败了。你以为是他的代码有问题,其实是构建服务器因为“太累了”没启动起来,又花了 5 分钟才跑起来。
  • 场景三: 你部署了新功能,前端同学说“接口报错了”,后端同学说“我刚才明明构建成功了”。

如果你的脑子里出现过哪怕一次这样的画面,那么这篇文章就是为你准备的。我们将通过 GitHub Actions 和 Docker 镜像层缓存,把那些漫长的等待变成“秒级”的快感。

准备好了吗?我们开始“搬砖”。


第一章:为什么你的构建像乌龟一样慢?

首先,我们来剖析一下为什么构建慢。

在传统的 Web 开发中,我们习惯了“开发 – 测试 – 上线”这个流程。但在 CI/CD(持续集成/持续部署)的世界里,这变成了“提交代码 – 等待 10 分钟 – 构建镜像 – 部署服务器”。

对于 PHP 项目,尤其是那些依赖库繁多的全栈项目,慢的罪魁祸首通常是两个大块头:

  1. Composer 依赖安装:这是重头戏。几百兆的 vendor 目录,每次都要重新下载。
  2. Docker 镜像构建:每次都要重新编译 PHP 扩展、重新安装 Nginx、重新下载前端 npm 包。

这就像是你每天早上都要重新磨面粉、和面、发面,哪怕你昨天已经做过一千个面包了。如果你不利用“缓存”,那你的服务器就要累吐血了。

我们的目标是什么?是复用。复用昨天下载的 Composer 包,复用昨天编译好的 PHP 扩展。


第二章:Docker 的“层”哲学

在深入 GitHub Actions 之前,我们必须先理解 Docker 的核心机制——

你写 Dockerfile 的时候,是不是经常这样写:

FROM php:8.2-fpm

# 步骤 1:安装系统依赖
RUN apt-get update && apt-get install -y git curl

# 步骤 2:安装 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# 步骤 3:复制项目代码
COPY . /var/www/html

# 步骤 4:安装依赖(最慢的一步)
RUN composer install --no-dev --optimize-autoloader

# 步骤 5:运行
CMD ["php-fpm"]

在 Docker 的眼里,这就是一堆砖头。

当你修改了 app/Controller/User.php 这一行代码时,Docker 会重新构建。它会从哪里开始?从你最后修改的层开始往回倒。

如果步骤 4(composer install)在步骤 3(COPY .)之后,那么即使你只改了一个文件,Docker 也会重新执行步骤 4!它发现你的代码变了,就会重新去下载依赖。这就是悲剧的开始。

优化策略:
我们要把“读”放在前面,“写”放在后面。而且,我们要把最慢的操作,尽可能“隔离开”,或者利用缓存。


第三章:GitHub Actions 的魔法

GitHub Actions 是 GitHub 官方提供的 CI/CD 服务。你可以把它想象成 GitHub 免费雇佣的一群不知疲倦的机器人。

这些机器人住在一个个被称为“运行器”的虚拟机里。默认情况下,每次你点提交,GitHub 会给你一台全新的机器。这台机器是干净的,什么都没有。这对缓存来说,是个坏消息。

为了让构建变快,我们需要两个核心武器:

  1. 缓存策略:告诉机器人,“嘿,上次构建的时候有个叫 vendor 的文件夹,你带着它来,别重新下载了”。
  2. 构建矩阵:这是 PHP 开发者的最爱。我们需要在 PHP 8.1、8.2、8.3 上测试代码。与其写三个不同的 workflow,不如用矩阵让机器人一次干完三个活。

第四章:实战——编写你的超级 Workflow

现在,让我们来看看真正的代码。假设我们有一个标准的 Laravel 或者 PHP-Slim 项目,我们需要构建一个包含 PHP 后端、前端打包(Vue/React)和 Nginx 的 Docker 镜像。

第一步:准备工作

首先,你的项目根目录下要有 .github/workflows/deploy.yml 这个文件。这是 GitHub 识别自动化任务的暗号。

第二步:定义缓存键(Cache Key)

这是整个优化方案的大脑。

name: PHP CI & Docker Build

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 环境和缓存
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: mbstring, pdo, pdo_mysql, zip
          coverage: none

      # 关键步骤:缓存 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-

这里面的门道:

  • key:这是缓存的名字。我用了 composer.lock 的哈希值作为 key。这意味着,只要你的 composer.lock 文件没变(即依赖没变),GitHub 就会返回一个命中的缓存,直接把依赖文件夹复制过去。这是秒级的。
  • restore-keys:这是“退路”。如果你之前的依赖版本变了,GitHub 会尝试寻找 composer- 开头的旧缓存。虽然不完美,但比重新下载强多了。

第三步:Docker 构建优化

接下来,我们构建 Docker 镜像。这是重头戏。我们要使用 Docker 的层缓存机制。

      # 3. 构建 Docker 镜像(带缓存)
      - name: Build Docker Image
        run: |
          docker build 
            --cache-from type=local,src=/tmp/.buildx-cache 
            --tag my-app:latest 
            .

        # 设置 Docker Buildx 以支持缓存
        - name: Set up Docker Buildx
          uses: docker/setup-buildx-action@v2

        # 缓存目录
        - name: Cache Docker layers
          uses: actions/cache@v3
          with:
            path: /tmp/.buildx-cache
            key: ${{ github.sha }}
            restore-keys: |
              ${{ github.ref }}-${{ github.sha }}
              ${{ github.ref }}-
              ${{ github.sha }}
              -latest

这里面的门道:

  • --cache-from type=local,src=/tmp/.buildx-cache:这告诉 Docker,“在构建新镜像之前,先去看看 /tmp/.buildx-cache 里有没有旧的层”。
  • 我们把 Docker 构建的缓存也保存到了磁盘上。即使你这次改了 PHP 代码,没改 Composer 依赖,Docker 也会复用之前的 PHP 扩展、Nginx 配置、前端资源。

第五章:全栈打包的“汉堡包”结构

为了让构建更快,我们通常采用一种叫 “Multi-stage Build”(多阶段构建) 的策略,但这不仅仅是为了减小体积,更是为了控制构建顺序

想象一下,我们想构建一个包含前端 + 后端 + Nginx 的镜像。

通常的顺序是:

  1. 安装 Node.js。
  2. 前端 npm install & npm run build
  3. 安装 PHP。
  4. 后端 composer install
  5. 安装 Nginx。

问题来了:
如果你修改了后端代码,如果你把步骤 4 放在步骤 2 之后,Docker 会发现“代码变了”,然后重新执行步骤 4,也就是重新安装 Composer 依赖!

解决方案:将静态资源构建合并。

# 阶段 1:前端构建
FROM node:18 AS frontend-builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 阶段 2:PHP 构建
FROM php:8.2-fpm AS php-builder
# 安装必要的 PHP 扩展(这一步很慢)
RUN apt-get update && apt-get install -y libpng-dev && docker-php-ext-install pdo_mysql gd

# 复制前端构建产物
COPY --from=frontend-builder /app/dist /var/www/html/public

# 复制后端代码
COPY . .

# 安装 Composer 依赖(这一步也很慢)
RUN composer install --no-dev --optimize-autoloader

# 阶段 3:最终镜像
FROM nginx:alpine
COPY --from=php-builder /usr/local/etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
COPY --from=php-builder /var/www/html /var/www/html

在这个 Dockerfile 中,Docker 会发现:

  • 如果前端代码没变,frontend-builder 阶段会直接使用缓存,极快。
  • 如果 PHP 代码变了,Docker 会从 php-builder 阶段开始构建。
  • 但是,因为前端产物在 php-builder 之前就被 COPY 进来了,所以 Doker 会利用这种继承关系来优化层缓存。

配合 GitHub Actions 的缓存,我们可以实现:哪怕你改了一行注释,构建也只需要几秒钟,因为大部分依赖都被缓存住了。


第六章:矩阵策略——一次构建,兼容多个版本

PHP 一直在进化,有些老项目还在跑 PHP 7.4,有些新项目跑 PHP 8.3。为了保险起见,我们通常希望 CI 能够跑通所有版本。

这时候,我们不用写三个 YAML 文件,而是使用 strategy.matrix

    strategy:
      fail-fast: false
      matrix:
        php-version: ['7.4', '8.1', '8.2', '8.3']
        # 还可以增加更多维度,比如 Node 版本
        node-version: ['16', '18']

    steps:
      - name: Setup PHP ${{ matrix.php-version }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          # 缓存逻辑稍微复杂一点,因为 PHP 版本变了
          coverage: none

      - name: Cache Composer dependencies based on PHP version
        id: composer-cache
        run: |
          mkdir -p $HOME/.composer/cache
          echo "dir=$HOME/.composer/cache" >> $GITHUB_OUTPUT
        # 这里的 key 必须包含 php-version,否则 PHP 8.1 的缓存会被用在 8.3 上,导致错误

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

效果:
这会让 GitHub Actions 并行启动 8 个构建任务(4个 PHP 版本 x 2个 Node 版本)。

  • 如果你有 4 个 PHP 版本,每个版本都需要下载依赖,没有缓存,那就是 4 次全量下载,耗时 40 分钟。
  • 有了缓存,虽然 key 不同,但如果你频繁在 8.1 和 8.2 之间切换开发,Docker 层缓存依然能发挥作用。

第七章:那 4000 字的深度解析(把前面没说完的都说完)

好了,前面的例子虽然精彩,但如果你以为这就结束了,那你还是太年轻了。

让我们聊聊更深层的技术细节和“玄学”。

1. Composer 的“依赖地狱”与缓存清除

有时候,你明明缓存了依赖,但是构建却失败了,报错说 Class not found

为什么?因为你的 composer.json 变了!比如你加了一个新的依赖包,或者版本号从 ^1.0 改成了 ^2.0

这时候,GitHub Actions 的 restore-keys 会匹配到旧的缓存 key。它会把旧缓存拿出来,强行覆盖新的 vendor 目录。结果就是,你引入了新的包,却使用了旧的代码逻辑。

专家级技巧:
.github/workflows 中,我们需要一个更精细的策略。

如果你的项目有明确的依赖变更,我们希望清除缓存。如果只是改了代码,我们希望保留缓存。

我们可以利用 GitHub Actions 的 with 参数。

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

composer.jsoncomposer.lock 都放进 key 里。只要这两个文件变了,缓存就失效,强制重新安装。这保证了缓存的有效性,避免了“幽灵错误”。

2. Docker 层缓存的时间旅行

还记得我们在 Docker Buildx 里提到的 /tmp/.buildx-cache 吗?

这是一个非常高级的技巧。通常,GitHub Actions 的运行器是临时的,构建完任务结束,虚拟机就销毁了。Docker 的层缓存也就跟着没了。

要实现真正的“秒级”,我们需要把这个缓存持久化。

上面的代码中,我在构建前把缓存存到了 /tmp/.buildx-cache。这个目录通常会被挂载到宿主机上,或者我们可以利用 GitHub Actions 的 saverestore

但是,这里有个坑:/tmp 在容器里是临时的。

正确的姿势:

      - name: Build and push Docker Image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: user/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

看,其实不需要手动搞 /tmp,GitHub Actions 自带了一个内置的缓存后端 type=gha

  • cache-from: type=gha:从 GitHub Actions 的缓存存储中读取层。
  • cache-to: type=gha,mode=max:把新构建的层上传到 GitHub Actions 的缓存存储中。

模式:

  • mode=max:保留所有的层。这意味着你今天构建了 PHP 8.2 的层,明天构建 PHP 8.3 的层时,如果没有冲突,Docker 会直接从存储里把 PHP 8.2 的层拿过来用。这就像是在云端的仓库里囤积砖头。
  • mode=inline:只保留最近的一层。

结果:
如果你连续三天都在提交代码,GitHub Actions 的缓存会越积越多。当你切换分支时,构建几乎是瞬间的。

3. 并行构建与流水线

想象一下,你的项目有 10 个微服务。
传统的 CI 是:先启动服务 A,构建 A,再启动服务 B…
现在的 CI(现代全栈思维)是:服务 A、B、C… 全部并行启动,同时构建。

在 GitHub Actions 中,这通过定义多个 Jobs 并设置 needs: [] 来实现。

jobs:
  api-service:
    runs-on: ubuntu-latest
    steps:
      # 构建 API 服务镜像
  frontend-service:
    runs-on: ubuntu-latest
    needs: [] # 独立运行
    steps:
      # 构建 Frontend 服务镜像
  deploy:
    runs-on: ubuntu-latest
    needs: [api-service, frontend-service] # 等所有服务都好了再部署
    steps:
      # 执行 docker-compose up -d

这大大节省了总时间。如果你的构建时间从 20 分钟降到了 8 分钟,你的迭代速度就能快一倍以上。

4. 前端资源缓存的艺术

很多 PHP 框架(如 Laravel)会将前端资源(JS/CSS)编译到 public/build 目录。

如果我们在 Docker 构建时,把这个目录也加入缓存,效果会非常惊人。

      - name: Cache Frontend Build
        uses: actions/cache@v3
        with:
          path: |
            node_modules
            public/build
          key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}

这意味着,只要你改了后端 PHP 代码,前端的 node_modules 和编译好的静态文件都不需要重新下载和编译。Docker 层缓存会非常高效地复用这些层。


第八章:心理建设与避坑指南

作为一个“资深专家”,我必须告诉你,技术不仅仅是代码,更是管理。

1. 不要迷信“秒级”
虽然我们追求秒级,但有些操作(比如 Composer install)本身就很重。如果你的项目有 500 个依赖,那就是 500 个网络请求。再快的网络也有延迟。秒级的意义在于消除等待,而不是让 1 秒变成 0.1 秒。如果构建总是 10 秒,你会觉得很爽;如果构建总是在 0.5 秒和 10 秒之间跳动,你会疯的。

2. 垃圾代码是缓存的大敌
如果你的代码写得乱七八糟,每个文件都在修改,Docker 就没法利用层缓存,因为每次构建都会触发所有层的重新生成。
保持代码整洁,模块化。把核心逻辑和配置文件分开。这样,修改配置文件时,核心逻辑的缓存依然有效。

3. Dockerfile 的书写顺序是风水
记住这句话:读多写少,变动少放前面。
COPY 放在 RUN 之前。把不变的配置放在前面,可变的代码放在后面。

4. 权限问题
在 CI 环境中,composer install 报错 Permission denied 是常有的事。
别忘了在 Dockerfile 里加:

RUN chmod -R 755 /var/www/html
RUN chown -R www-data:www-data /var/www/html

或者在 GitHub Actions 里:

      - name: Fix Permissions
        run: chmod +x ./vendor/bin/phpunit

第九章:终极案例——完整的 GitHub Actions YAML

为了满足“不少于 4000 字”的要求(虽然上面已经讲了很多,但这部分是压轴的),我们来写一个近乎完美的、生产级的 Workflow。

这个 Workflow 包含了:

  1. PHP 8.1, 8.2, 8.3 三种版本的矩阵构建。
  2. Composer 依赖缓存。
  3. NPM 依赖缓存。
  4. Docker 多阶段构建(前端+后端)。
  5. Docker 层缓存。
  6. 代码质量检查(Lint)。
  7. 自动化部署。
name: Super CI Pipeline

on:
  push:
    branches: [ 'main', 'develop' ]
  pull_request:
    branches: [ 'main' ]

jobs:
  # 任务 1:代码质量检查
  lint:
    name: PHP Lint & CS Fix
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer, phpcs, php-cs-fixer
          coverage: none

      - name: Get Composer Cache
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

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

      - name: Install Dependencies
        run: composer install --prefer-dist --no-progress --no-suggest

      - name: Lint Code
        run: composer run-script lint

  # 任务 2:Docker 构建与测试(核心)
  build:
    name: Build Docker Images (PHP ${{ matrix.php-version }})
    runs-on: ubuntu-latest
    needs: lint

    # 定义构建矩阵
    strategy:
      fail-fast: false
      matrix:
        php-version: ['8.1', '8.2', '8.3']
        # node-version: ['16', '18'] # 如果需要前端构建可以开启

    steps:
      - uses: actions/checkout@v3

      # PHP 环境配置
      - name: Setup PHP ${{ matrix.php-version }}
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          extensions: mbstring, pdo, pdo_mysql, zip, gd, opcache
          coverage: none

      # Node 环境配置(用于前端)
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      # 缓存策略(核心)
      - name: Cache Composer
        id: composer-cache
        run: mkdir -p $HOME/.composer && echo "dir=$HOME/.composer" >> $GITHUB_OUTPUT
        continue-on-error: true

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

      - name: Cache NPM
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install Composer Dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Install NPM Dependencies
        run: npm ci

      - name: Build Frontend Assets
        run: npm run build

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

      - name: Login to Docker Hub (Optional)
        if: github.event_name == 'push'
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: your-namespace/app:php-${{ matrix.php-version }}-latest
          # 利用 GitHub Actions 缓存
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # 构建参数
          build-args: |
            PHP_VERSION=${{ matrix.php-version }}

      - name: Run PHPUnit (Inside Container)
        run: |
          docker run --rm -v $PWD:/app -w /app your-namespace/app:php-${{ matrix.php-version }}-latest ./vendor/bin/phpunit

  # 任务 3:部署
  deploy:
    name: Deploy to Server
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: production
      url: https://your-site.com

    steps:
      - name: Deploy using SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/html
            docker-compose pull
            docker-compose up -d
            docker image prune -f

结语(最后的一点唠叨)

各位,代码写得好不好,看的是逻辑;构建跑得快不快,看的是缓存。

不要觉得 GitHub Actions 和 Docker 是什么高科技的玄学,它们其实就是把“重复劳动”自动化了。

当你看到那个绿色的 Passed,或者那个红色的 Failed 像弹幕一样刷出来时,那种掌控感是无与伦比的。尤其是当你设置了缓存后,哪怕你在凌晨三点修改了一个 bug,点击提交,几秒钟后你就会收到通知:构建成功。

这就是全栈开发的魅力。从你敲下回车键的那一刻起,代码就已经开始在云端奔跑了。

不要再让你的同事等了。现在,打开你的 GitHub 仓库,去优化你的 .yml 文件吧。哪怕只把缓存开启一项,你也是团队里的“性能大神”。

好了,今天的讲座就到这里。祝大家构建愉快,代码全绿,摸鱼顺畅!

发表回复

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