个人开发者的 CI/CD 快速部署:GitHub Actions + 阿里云 ACR + 服务器自动拉镜像

4 分钟阅读

个人项目最省心的部署链路:Actions 构建镜像推阿里云 ACR,服务器上的 watchtower 盯着 :latest 自动拉取更新。附完整 workflow、compose 配置,以及依赖版本不一致、ACR 拒收 provenance 清单、 多架构镜像是怎么处理的。

一个人维护的小项目,最省心的部署链路是这三段。代码推 GitHub,Actions 里构建镜像并推到阿里云 ACR,服务器上的容器自己发现新镜像然后更新。

它的好处不在快,在于每一段都能单独换掉。GitHub 挂了不影响服务器跑着的老镜像,ACR 里留着历史 tag 随时能回滚,服务器那侧只认镜像,不关心你用什么语言和构建工具。对照 scp 丢 jar 那条路,这套多了一层镜像仓库,换来的是构建和运行彻底分开。

整条链路长这样。

flowchart LR
    A[git push 到 master] --> B[GitHub Actions<br/>docker build]
    B --> C[推送到阿里云 ACR<br/>latest + 时间戳]
    C --> D[服务器 watchtower<br/>轮询到 digest 变化]
    D --> E[拉新镜像<br/>重建容器]

这套要花多少钱

先把账算清楚,三个环节里两个免费。

GitHub Actions 这一端,公开仓库用标准 runner 跑构建不花钱,分钟数和存储都不计。私有仓库吃计划里的免费额度,GitHub Free 每个月 2000 分钟、500MB 制品存储,另外每个仓库还有 10GB 的 Actions 缓存。这套流程主要消耗缓存,cache-to: type=gha 存的镜像层基本都在这 10GB 里。2000 分钟对个人项目也够,一次构建几分钟,一个月跑上百次没问题。真超了,多出来的按 Linux 二核每分钟 0.006 美元计,不至于肉疼。

阿里云 ACR 这一端,个人版免费。官方写的口径是公测限额免费使用,拉取和上传镜像都不收费,代价是没有 SLA 承诺,服务不可用也不赔。规格上有三个硬限制,命名空间 3 个,公开仓库 300 个,并发构建 1 个。个人项目通常一个命名空间装一个应用的镜像,这个量用不完。哪天要企业级 SLA、多架构构建或者镜像安全扫描,才需要切到收费的企业版。

Actions 这一侧

不用手写 docker build,交给官方的 buildx 动作。下面这份是我在用的,直接改改就能跑。

name: Build and Push Docker Image to Aliyun ACR

on:
  push:
    branches:
      - master

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      # QEMU 让 x86 的 runner 也能构建 ARM64 镜像
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 用北京时区生成版本号,避免 UTC 差一天
      - name: Get Current Date Tag
        id: get-date
        run: |
          echo "tag=$(TZ=Asia/Shanghai date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT
          echo "date_only=$(TZ=Asia/Shanghai date +'%Y%m%d')" >> $GITHUB_OUTPUT

      - name: Log in to Aliyun Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ALIYUN_REGISTRY }}
          username: ${{ secrets.ALIYUN_USERNAME }}
          password: ${{ secrets.ALIYUN_PASSWORD }}

      - name: Build and Push to ACR
        uses: docker/build-push-action@v6
        with:
          context: ./app
          file: ./app/deployments/Dockerfile
          push: true
          platforms: linux/amd64,linux/arm64
          tags: |
            ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:${{ steps.get-date.outputs.tag }}
            ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:${{ steps.get-date.outputs.date_only }}
            ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_NAMESPACE }}/${{ secrets.ALIYUN_REPOSITORY }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # 阿里云 ACR 不吃 OCI 的 provenance/sbom 清单,不关会推不上去
          provenance: false
          sbom: false

三个 tag 各有各的用。分秒时间戳是死的,回滚和定位问题时认它;日期那个方便人肉翻历史;latest 是活的,每次覆盖,服务器自动拉镜像就盯它。别只推时间戳,固定 tag 的 digest 不会变,服务器永远不会更新。

provenance: false 和 sbom: false 这两行是踩出来的。buildx 默认会往镜像旁边挂一份来源证明,多推一个 manifest,ACR 那边收不下,构建成功、推送报错,第一次遇到挺懵。

服务器这一侧

推荐让服务器主动去拉。装个 watchtower 盯着容器就行。

services:
  app:
    image: registry.cn-hangzhou.aliyuncs.com/ns/app:latest
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --interval 300 app

它每隔一段时间去 registry 查一次,发现 latest 的 digest 变了,就把新镜像拉下来重建容器。整套流程服务器不需要对外开任何端口,Actions 也不需要持有服务器的 ssh 密钥,两边只通过 ACR 发生关系,谁都不用认识谁。

想回滚就把 compose 里的 latest 改成某个时间戳 tag,docker compose up -d 一次就退回去了。这个时间戳是当初构建时打的,ACR 里还留着那个镜像,回退一定成。

另一个变体是让 Actions 连上服务器拉,不养常驻进程。

      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          script: cd /opt/app && docker compose pull app && docker compose up -d app

省掉一个守护进程,代价是每次部署都要等服务器连得上,Actions 里多存一份私钥。服务器 IP 换过、sshd 端口改过,都得到这边同步。项目就一台机器、部署不频繁的话,这版够用,我倾向于把它当作 watchtower 之外的备用手段。

scp 丢 jar

没有 Docker,或者就是个单体 jar,scp 上去重启 systemd 更省事。构建产物换成一个 jar 文件就行。

      - uses: appleboy/scp-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          source: target/app.jar
          target: /opt/app
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          script: systemctl restart app

环节少,坏了当场能看出来。代价是 JVM、依赖、启动参数全留在服务器上,环境开始漂移,换台机器就得重新对一遍。项目只有一台服务器、永远不扩容,这条也行;一旦有两个环境,镜像那套的省心就会显出来。

报错最多的地方

我遇到最多的报错是依赖编不过,登录失败和网络超时反而少见。package-lock 在开发机上生成,拿到系统版本不同的构建镜像里跑 npm ci,带二进制的可选依赖对不上平台,直接失败。Java 那边同理,本地用 21 编译的 class,镜像里是 17,启动就报版本不匹配。

这跟用哪条链路没关系。docker 只是让问题暴露得早一点,该对的版本在本地也得对上,只是本地那台机器恰好装对了而已。处理办法是构建镜像和运行镜像用同一个基础镜像,多阶段构建顺手就满足了。

FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-bookworm AS run
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]

node 写 20 不写 20.11.1,是为了能吃到安全补丁;但 build 和 run 两行必须一模一样,这条不能松。

架构问题构建那一侧已经处理了。platforms: linux/amd64,linux/arm64 加上 QEMU,推上去是一个多架构 manifest,服务器是 x86 就拉 x86,是 ARM 就拉 ARM,自动挑。代价是 QEMU 模拟的 ARM 构建慢一些,靠 type=gha 缓存能补回来大半。别在 Dockerfile 里硬写 --platform,那会把多架构这层又按回单架构。

为什么值得多这一层

scp 那条线里,服务器上有什么、缺什么,只有那台机器自己知道。换成镜像之后,构建结果被固化成一个能在任何机器上跑起来的东西,服务器退化成一台只会 pull 和 up -d 的机器。

个人项目半年后回头改,最容易忘的是服务器上到底装过什么。镜像仓库把这份记忆留在了自己手里,顺带把回滚变成一次 tag 切换。这就是我推荐它的原因,自动化只是顺带。

评论