用 Gitea Actions + Harbor 搭一套自己的 CI/CD

把代码从「提交」送到「服务跑起来」,中间有很多重复劳动:跑测试、构建、打包镜像、推仓库、重启服务。这些动作只要有一步靠手工,就迟早会出错。

这篇记录我用 Gitea Actions + Harbor 搭这条链路的过程。选这两个而不是 GitHub + Docker Hub,原因很实际:全部自建,不依赖外部服务,也不需要为了拉私有镜像去折腾网络。

整体链路

git push
   ↓
Gitea Actions (act_runner)
   ↓
go vet → go test → docker build
   ↓
推送到 Harbor
   ↓
服务器拉取新镜像并重启

每一步都是上一步的产物,任何一步失败就中断,不会出现「测试没过但镜像已经推上去」的情况。

组件与分工

Gitea:代码托管,同时提供 Actions 的调度端。

act_runner:执行器。它主动向 Gitea 注册并拉取任务,所以不需要在 Gitea 上开放任何入站端口给 runner。这个方向很重要——runner 通常跑在构建机器上,让它主动出站比让服务端连进来安全得多。

Harbor:镜像仓库。相比裸的 registry:2,它多了项目管理、RBAC、镜像扫描和保留策略。对于「已有几台机器、需要分环境」的场景,这些不是可选项。

Actions Runner 的关键配置是标签(labels)。工作流里用 runs-on: 指定标签,runner 只接自己标签匹配的任务:

# .gitea/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
          cache: true
      - name: Vet
        run: go vet ./...
      - name: Test
        run: go test -race ./...

-race 建议一直开着。它会让测试慢几倍,但能抓到只在并发下出现的数据竞争——而这类问题一旦漏到线上,排查成本远高于让 CI 慢一点。

构建镜像

  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Login to Harbor
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.HARBOR_HOST }}
          username: ${{ secrets.HARBOR_USER }}
          password: ${{ secrets.HARBOR_PASSWORD }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.HARBOR_HOST }}/myapp/api:${{ github.sha }}
            ${{ secrets.HARBOR_HOST }}/myapp/api:latest

这里有几个刻意的选择:

needs: test —— 构建依赖测试,测试不过就不会构建。这是链路里最重要的一条约束。

if: github.ref == 'refs/heads/main' —— 只有主分支才推镜像。PR 分支只跑测试,不污染仓库。

用 commit SHA 做 tag —— 每个镜像都能精确对应到某一次提交。latest 只是给人看的便利标签,部署时永远用 SHA。否则出了问题你没法回答「现在线上跑的到底是哪个版本」。

Dockerfile 用多阶段构建,把编译环境和运行环境彻底分开:

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/app /usr/local/bin/app
USER nobody
ENTRYPOINT ["/usr/local/bin/app"]

先 COPY go.mod go.sum 再 COPY . . 是为了利用层缓存:依赖没变时,go mod download 那层会命中缓存,不用每次重新下载。

CGO_ENABLED=0 得到完全静态的二进制,FROM alpine 甚至 FROM scratch 都能跑。-trimpath 去掉构建路径,-ldflags="-s -w" 去掉符号表和调试信息,镜像能小一截。

密钥管理

这一段值得单独说,因为它最容易出问题。

密钥一律走仓库的 Secrets,不写进工作流文件。 工作流文件是明文提交进仓库的,任何能看到代码的人都能看到里面写的东西。

绝不要 echo 密钥。

# 绝对不能这么写
- run: echo "token is ${{ secrets.TOKEN }}"

一旦打印,它就会进构建日志,而构建日志通常是所有有仓库读权限的人都能看的。Gitea 会对已知的 secret 做日志脱敏,但不要依赖它——脱敏是基于字符串匹配的,密钥被 base64 或拼接后就失效了。

给 CI 用的凭据要单独创建,不要复用管理员账号。 Harbor 里为 CI 建一个机器人账号,只授予推送特定项目的权限。这样即使凭据泄露,影响范围也是可控的。

能不用长期凭据就不用。 部署步骤如果支持 SSH 证书或短期 token,就不要用永久密码。

部署这一步

最朴素的方式是用 docker compose 加一个部署脚本:

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy over SSH
        run: |
          ssh -o StrictHostKeyChecking=yes deploy@${{ secrets.DEPLOY_HOST }} \
            "cd /opt/myapp && ./deploy.sh ${{ github.sha }}"

对应的 deploy.sh 要处理三件事:

  1. 从 Harbor 拉取指定 SHA 的镜像
  2. docker compose up -d 滚动替换
  3. 失败时回滚到上一个可用版本

第 3 步经常被省略,但这正是「自动化部署」和「自动化搞挂线上」的分界线。没有回滚的自动化,出问题时反而恢复得更慢。

另外,StrictHostKeyChecking=yes 而不是 no。设成 no 就等于放弃了中间人防护,让整个部署链路的安全性取决于网络环境。

几个绕不开的坑

runner 的权限。 runner 默认以某个系统用户运行,而这个用户需要能访问 Docker socket。把用户加入 docker 组是标准做法,但要清楚:能访问 Docker socket 等价于能在这台机器上取得 root。因此构建机应该是专用的,不要和生产环境混部署。

镜像 tag 用了 latest 导致拉取不到更新。 如果部署脚本写的是 docker pull myapp:latest,而本地已经有同名镜像,Docker 可能直接用缓存。用 SHA 做 tag 从根上避免这个问题。

缓存导致的假成功。 actions/setup-go 的 cache: true 会缓存模块和构建结果。这在绝大多数时候是好事,但如果测试依赖了某些外部状态,缓存可能掩盖问题。遇到「CI 过了但本地复现不了」时,先试一次禁用缓存的运行。

工作流的触发条件写得太宽。 默认 on: push 会对所有分支触发,包括文档改动。加上 paths-ignore 能省下不少构建时间:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

做完之后的变化

最直接的变化不是「省了时间」,而是每次提交都有一份可验证的记录:这次改动测试过没过、构建出来是哪个镜像、部署到了哪台机器的哪个版本。

之前靠手工的时候,这些信息只存在于人的记忆里。而记忆是不可靠的,尤其是在出故障需要回溯的时候。