跳到主要内容
blog-hexo-astro
返回

如何搭建本地开发流水线

Table of contents#

Open Table of contents

1. 架构设计#

1.1 为什么是两台机器#

一台也能跑,但会把”重活”和”底盘”绑死:CI 构建时 CPU 打满,数据库跟着卡;哪天误操作把机器搞挂,代码和数据一起没。

拆成两台,职责非常清楚:

VM1 · 基础设施层VM2 · DevOps 平台层
配置4C / 9.7G / 196G6C / 15G / 295G
角色所有有状态服务Git + CI + 应用容器
特点数据不能丢,稳定优先天天重建,崩了能重来

1.2 整体拓扑#

本地mac开发机 192.168.100.24
      │  git push
      ▼
┌─────────────────────────────────────────────┐
│ VM2  192.168.100.143                        │
│   ├─ Gitea 1.22          :3000 / ssh:2222   │
│   ├─ act_runner ×2       java-ci / node-ci  │
│   ├─ 前端容器            :8080              │
│   ├─ 后端容器            :48080             │
│   └─ 速查站点            :8090              │
└───────────────┬─────────────────────────────┘
                │ 同子网 192.168.100.0/24
┌───────────────▼─────────────────────────────┐
│ VM1  192.168.100.142                        │
│   ├─ PostgreSQL 16       :5432              │
│   ├─ Redis 7             :6379              │
│   ├─ MinIO               :9000 / :9001      │
│   ├─ Elasticsearch 8     :9200              │
│   ├─ Registry 2.8        :5000  ← 镜像仓库   │
│   └─ Portainer           :9443              │
└─────────────────────────────────────────────┘

镜像流向是单向的:CI 构建 → VM1 Registry → VM2 拉取部署。

把 Registry 放在”数据层”而不是”构建层”有个好处:即使 VM2 的 Docker 被清空、runner 重建,镜像还在,随时能重新拉起服务。

1.3 一次 push 之后发生了什么#

git push
  → Gitea 收到 push,匹配 workflow,生成 task
  → act_runner 轮询到 task,启动 step 容器(ci-runner 镜像)
  → 容器里:clone 代码 → 装依赖 → 构建 → docker build
  → docker push 到 VM1 Registry
  → ssh 到 VM2,docker compose pull + up
  → 轮询健康检查,通了才结束
  → 触发 Registry 清理,只留最近 3 个 tag

整个过程的失败点都能在 Gitea 的 Actions 页面看到逐行日志——这一点比 Jenkins 舒服太多。

2. 软件安装#

2.1 基础环境:Docker 与镜像加速#

两台机器同样的步骤。Ubuntu 22.04 + Docker 29:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

国内拉 Docker Hub 基本不通,必须配镜像加速。这里踩坑最久——网上流传的源大半已经失效:

// /etc/docker/daemon.json
{
  "registry-mirrors": [
    "https://docker.1panel.live",
    "https://docker.m.daocloud.io",
    "https://docker.jsdelivr.fyi"
  ],
  "insecure-registries": ["192.168.100.142:5000"],
  "log-driver": "json-file",
  "log-opts": { "max-size": "50m", "max-file": "3" }
}

USTC / 163 / 百度这几个老源全部 NXDOMAIN 了,别抄。

insecure-registries 必须有:自建 Registry 走 HTTP,不配的话 push 会报 http: server gave HTTP response to HTTPS client。

2.2 VM1:中间件四件套#

四件套用一个 compose 文件管,关键是资源上限——9.7G 内存分给四个服务,不设限会互相抢。

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${PG_PASSWORD}
    command: >
      postgres -c shared_buffers=512MB
               -c max_connections=100
    ports: ["5432:5432"]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 700mb --maxmemory-policy allkeys-lru
    ports: ["6379:6379"]

  minio:
    image: minio/minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    ports: ["9000:9000", "9001:9001"]

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
    restart: unless-stopped
    environment:
      discovery.type: single-node
      xpack.security.enabled: "false"
      ES_JAVA_OPTS: "-Xms1g -Xmx1g"
    ports: ["9200:9200"]

几个必须注意的点:

2.3 VM1:自建镜像仓库#

这里做过一次选型。

方案价格问题
阿里云 ACR 个人版免费命名空间/仓库数受限,绑定阿里云账号
阿里云 ACR 企业版按量个人玩不起
Harbor免费组件重,一台小机器扛不住
registry:2免费无 UI、无扫描——但够用

最后选 registry:2 + 一个 Registry UI 容器,总共不到 200 MB 内存。

带认证的最小配置:

mkdir -p /opt/stack/registry/{config,auth,data}

# 生成 bcrypt 密码文件
docker run --rm --entrypoint htpasswd httpd:2 \
  -Bbn admin 'YourStrongPassword' > /opt/stack/registry/auth/htpasswd
# /opt/stack/registry/config/config.yml
version: 0.1
storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true          # 允许删 tag,清理必需
http:
  addr: :5000
  auth:
    htpasswd:
      realm: basic-realm
      path: /etc/docker/registry/htpasswd
services:
  registry:
    image: registry:2.8
    restart: unless-stopped
    ports: ["5000:5000"]
    volumes:
      - ./config/config.yml:/etc/docker/registry/config.yml:ro
      - ./auth:/etc/docker/registry:ro
      - ./data:/var/lib/registry
    environment:
      REGISTRY_STORAGE_DELETE_ENABLED: "true"

验证:

docker login 192.168.100.142:5000 -u admin
docker pull busybox && docker tag busybox 192.168.100.142:5000/test/busybox
docker push 192.168.100.142:5000/test/busybox
curl -u admin:xxx http://192.168.100.142:5000/v2/_catalog

为什么要清理脚本:registry:2 删 tag 只删引用,不删数据层,磁盘不会变小。必须补一刀 GC:

#!/usr/bin/env bash
# cleanup.sh <repo> [keep]
REPO=$1; KEEP=${2:-3}
TAGS=$(curl -s -u "$USER:$PASS" "http://$REG/v2/$REPO/tags/list" \
       | jq -r '.tags[]' | grep -v latest | sort -r | tail -n +$((KEEP+1)))
for t in $TAGS; do
  curl -s -u "$USER:$PASS" -X DELETE "http://$REG/v2/$REPO/manifests/$t"
done
docker exec vm1-registry registry garbage-collect /etc/docker/registry/config.yml

2.4 VM2:Gitea#

services:
  gitea:
    image: gitea/gitea:1.22
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__server__DOMAIN: 192.168.100.143
      GITEA__server__HTTP_PORT: "3000"
      GITEA__server__SSH_PORT: "2222"
      GITEA__database__DB_TYPE: sqlite3     # 个人用 sqlite 足够,省一个数据库
      GITEA__service__DISABLE_REGISTRATION: "true"
    ports:
      - "3000:3000"
      - "2222:22"
    volumes:
      - ./gitea:/data

两个坑:

坑 1:22 端口被抢。 Gitea 镜像里的 s6 会自动拉起内置 sshd,和宿主的 sshd 抢 22,导致 SSH 登录 Gitea 失败。解决是不启用内置 SSH server,把容器的 22 映射到宿主 2222:

# 不要设 START_SSH_SERVER=true
ports: ["2222:22"]

坑 2:INSTALL_LOCK。 首次启动进入安装向导,如果之前配置过数据库但引导页没走完,会一直卡在安装页,且 admin 用户建不出来。容器里改 /data/gitea/conf/app.ini:

[security]
INSTALL_LOCK = true

然后用 USER_UID=1000 的用户重启触发 migration。

2.5 VM2:act_runner#

Gitea 自己不带执行器,要装 act_runner(Gitea Actions 的执行器,兼容 GitHub Actions 语法)。

services:
  gitea-runner:
    image: gitea/act_runner:latest
    restart: unless-stopped
    depends_on: [gitea]
    volumes:
      - ./gitea-runner/data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      GITEA_INSTANCE_URL: http://192.168.100.143:3000
      GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
      GITEA_RUNNER_NAME: vm2-runner-java
      GITEA_RUNNER_LABELS: "java-ci:docker://ci-runner-java:latest"

GITEA_RUNNER_LABELS 这一行是整个方案的核心机制:

<label 名>:docker://<step 容器镜像>

workflow 里写 runs-on: java-ci,runner 就会用 ci-runner-java:latest 这个镜像起一个容器跑每一步。这就是为什么可以给不同技术栈准备不同的执行环境——下面项目里就跑着两个 runner:

Runnerlabel执行镜像内容
后端java-cici-runner-java:latestnode20 + pnpm + docker CLI + JDK11 + Maven
前端node-cici-runner:latestnode20 + pnpm + docker CLI

为什么要自建 runner 镜像:官方 act_runner 起的容器里没有 docker 命令,而 CI 要 docker build。最省事的做法是拿 node:20-bullseye 当底,从 docker 官方镜像里把 CLI 拷进来:

FROM docker:27-cli AS dockercli
FROM node:20-bullseye

COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/ /usr/local/libexec/docker/cli-plugins/

RUN corepack enable && corepack prepare pnpm@9 --activate
SHELL ["/bin/bash", "-c"]
WORKDIR /workspace

Java 版在它基础上加 JDK 和 Maven:

FROM ci-runner:latest

# bullseye 已 EOL,换阿里云 archive 源
RUN sed -i \
      -e "s|http://deb.debian.org/debian-security|https://mirrors.aliyun.com/debian-archive/debian-security|g" \
      -e "s|http://deb.debian.org/debian|https://mirrors.aliyun.com/debian-archive/debian|g" \
      /etc/apt/sources.list

RUN apt-get update -qq && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
      openjdk-11-jdk-headless maven && \
    rm -rf /var/lib/apt/lists/*

node:20-bullseye 自带 git / curl / ssh,不用再 apt 装一遍,镜像小一半。

2.6 仓库 Secrets#

在 Gitea 仓库的 Settings → Actions → Secrets 里配置:

Secret用途
CI_TOKENclone 私有仓库的 token(不能叫 GITEA_TOKEN,会被平台过滤)
REGISTRY_USER / REGISTRY_PASS登录自建 Registry
VM2_HOST / VM2_USER部署目标
VM2_SSH_KEY_B64部署私钥,base64 后存入

私钥用 base64 是因为 YAML 里直接放多行私钥会被表达式和缩进搞坏:

base64 -i ~/.ssh/gitea_deploy | tr -d '\n' | pbcopy

3. 流水线测试#

3.1 完整的 8 步流水线#

两个项目(前端 Vue3、后端 Spring Boot)结构完全对齐,只是工具链不同。

name: build-and-deploy
on:
  push:
    branches: [main]

env:
  GITEA_HOST: 192.168.100.143:3000
  REGISTRY: 192.168.100.142:5000
  IMAGE_NAME: ai-platform-ui
  DEPLOY_DIR: /opt/stack/frontend
  SERVICE: frontend
  HEALTH_URL: http://localhost:8080/healthz
  KEEP_TAGS: "3"

jobs:
  ci:
    runs-on: node-ci
    steps:
      - name: 1. Checkout
        env: { CI_TOKEN: "${{ secrets.CI_TOKEN }}" }
        run: |
          git clone --depth 1 "http://tom:${CI_TOKEN}@${GITEA_HOST}/tom/${IMAGE_NAME}.git" .
          git log --oneline -1

      - name: 2. Toolchain
        run: |
          node -v; pnpm -v; docker --version

      - name: 3. Install dependencies
        run: |
          mkdir -p /cache/pnpm-store
          pnpm install --store-dir /cache/pnpm-store

      - name: 4. Quality gate(非阻塞)
        run: |
          pnpm lint:eslint || true
          pnpm ts:check || echo "WARN: ts:check 有报错,不阻塞构建"

      - name: 5. Resolve image tag
        id: tag
        run: echo "tag=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"

      - name: 6. Docker build & push
        env:
          REG_USER: "${{ secrets.REGISTRY_USER }}"
          REG_PASS: "${{ secrets.REGISTRY_PASS }}"
          TAG: "${{ steps.tag.outputs.tag }}"
        run: |
          set -e
          echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin "$REGISTRY"
          docker build -t "${REGISTRY}/${IMAGE_NAME}:${TAG}" \
                       -t "${REGISTRY}/${IMAGE_NAME}:latest" .
          docker push "${REGISTRY}/${IMAGE_NAME}:${TAG}"
          docker push "${REGISTRY}/${IMAGE_NAME}:latest"

      - name: 7. Deploy
        env:
          KEY_B64: "${{ secrets.VM2_SSH_KEY_B64 }}"
          TAG: "${{ steps.tag.outputs.tag }}"
        run: |
          set -e
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          echo "$KEY_B64" | base64 -d > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
              "${VM2_USER}@${VM2_HOST}" \
              "REGISTRY='$REGISTRY' IMAGE_NAME='$IMAGE_NAME' TAG='$TAG' \
               DEPLOY_DIR='$DEPLOY_DIR' SERVICE='$SERVICE' HEALTH_URL='$HEALTH_URL' bash -s" <<'REMOTE'
          set -e
          cd "$DEPLOY_DIR"
          exec 9>"/tmp/deploy-${SERVICE}.lock"
          flock -w 900 9
          IMAGE="$REGISTRY/$IMAGE_NAME:$TAG" docker compose pull "$SERVICE" || true
          for retry in 1 2 3 4 5; do
            IMAGE="$REGISTRY/$IMAGE_NAME:$TAG" docker compose up -d --force-recreate "$SERVICE" && break
            sleep 5
          done
          for i in $(seq 1 24); do
            curl -sf "$HEALTH_URL" && exit 0
            sleep 5
          done
          exit 1
          REMOTE

      - name: 8. Cleanup old tags
        if: success()
        run: |
          ssh -i ~/.ssh/deploy_key tom@192.168.100.142 \
              "bash /opt/stack/registry/cleanup.sh ${IMAGE_NAME} ${KEEP_TAGS}"

3.2 四个关键设计#

① 不要用 uses:

这是 Gitea Actions 最容易踩的坑。它兼容 GitHub Actions 语法,但 uses: actions/checkout@v4 会真的去 github.com 拉取,国内直接卡死到超时。所有 action 都得用纯 shell 重写:

# ❌ 卡死
- uses: actions/checkout@v4
# ✅ 手动 clone
- run: git clone --depth 1 "http://tom:${CI_TOKEN}@${GITEA_HOST}/tom/${IMAGE_NAME}.git" .

② step 容器里的 localhost 不是宿主

每一步跑在独立容器里(挂在 act_runner 建的 bridge 网络),localhost:3000 指向容器自己。访问 Gitea 必须用真实 IP:

GITEA_HOST: 192.168.100.143:3000   # 不能写 localhost:3000

同理,容器的端口映射也不会出现在这个网络里,跨服务通信只能走 IP。

③ 并发部署要用 flock 串行化

两个 workflow 同时触发(比如前后端一起改),docker compose up 会互相踩,报 recreate 重试 5 次仍失败。加锁:

exec 9>"/tmp/deploy-${SERVICE}.lock"
flock -w 900 9            # 最多等 15 分钟

锁按 service 分文件,前端后端互不阻塞;再加一层 --force-recreate 重试,基本不会挂。

④ 缓存分两层

层位置内容
CI 依赖缓存runner 挂载 /opt/ci-cachepnpm store / maven .m2
镜像构建缓存BuildKit 本地RUN --mount=type=cache 的 npm 层

第一层让 pnpm install 从 90 秒降到 10 秒;第二层让 Docker build 只重建变化的层。

3.3 踩坑清单#

按出现顺序,都是真金白银踩出来的:

#现象根因解法
1runner 显示 0 executors假阴性,实际在跑看 docker events 或 Gitea UI
2step 容器里没有 docker官方镜像精简自建 ci-runner 镜像
3docker:27-cli 里没有 bash镜像基于 alpineCOPY --from 拼到 node:20-bullseye 上
4clone 报 could not read Username私有库 + 无凭据secret CI_TOKEN
5uses: actions/checkout 卡住去 github.com 拉改纯 shell
6step 容器连不上 localhost:3000容器网络隔离用真实 IP
7ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATIONpnpm 9.15.9 新增供应链策略加 --config.minimum-release-age=0
8ERR_PNPM_IGNORED_BUILDS同上,默认禁止跑构建脚本--config.dangerously-allow-all-builds=true
9verify-deps-before-run 反复重装同上.npmrc 加 verify-deps-before-run=false
10部署报 compose 冲突并发 recreateflock 串行化

pnpm 那三条是同一个版本升级带来的”三连击”,第一次遇到会怀疑人生。

3.4 结果#

从 Gitea 的 action_run 表里拉出来最近几十次构建的真实耗时:

仓库样本平均最快最慢
ai-platform-ui(前端)8 次3 min 28 s3 min 07 s3 min 40 s
ai-platform(后端)6 次1 min 27 s46 s2 min 18 s

前端慢在 vite build + 镜像层导出;后端因为 maven 依赖和 Docker 层都能命中缓存,反而更快。冷启动(缓存全空)时前端会到 4-5 分钟。

数据查法:docker exec vm2-gitea sqlite3 /data/gitea/gitea.db "select id,stopped-started from action_run order by id desc limit 20;"

从 git push 到浏览器能看到改动,都在几分钟内。失败时 Actions 页面能逐行看到哪一步挂了,日志和本地终端几乎一样。

3.5 上线之后还得管的两件事#

① 磁盘会悄悄涨

CI 反复构建,两周就吃掉 15 GB。更隐蔽的是 act_runner:每跑一个 job 会留下两个命名卷(工作目录 + env),任务结束不删——而 docker volume prune 只清匿名卷,这些命名卷永远清不掉。

写个定时任务,每天凌晨跑:

# /etc/cron.d/docker-cleanup
30 3 * * * root runuser -u tom -- /opt/stack/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1

脚本要点:

# 1. 有 CI 在跑就跳过,别打断构建
docker ps --format '{{.Names}}' | grep -q 'GITEA-ACTIONS' && exit 0

# 2. 构建缓存按时间清
docker builder prune -a -f --filter "until=24h"

# 3. 镜像按时间清,但必须保护 CI runner 镜像(它们本地构建、registry 没备份)
#    注意:docker image prune -a 没有排除选项,会连 ci-runner 一起删!
for repo in $(docker image ls --format '{{.Repository}}:{{.Tag}}'); do
  case "$repo" in ci-runner:*|ci-runner-java:*) continue;; esac
  docker rmi "$repo"
done

# 4. 清 act_runner 残留命名卷(只删 dangling 的,安全)
docker volume ls -q --filter dangling=true --filter name=GITEA-ACTIONS | xargs -r docker volume rm

我第一版直接用了 docker image prune -a,把 ci-runner:latest 删了——下一次流水线直接起不来,因为 runner 找不到 step 镜像,而 registry 里没有备份。恢复方式是本地重建(Dockerfile 一定要放在 /opt 而不是 /tmp)。

② 重启机器要能自动恢复

服务器关机会遇到两个问题:服务不自启、启动顺序错乱。

# 1. docker 服务开机自启
systemctl enable docker

# 2. 每个容器都要 restart 策略
#    compose 里必须写,只对运行中容器 docker update 是没用的——
#    下次 compose up 会按文件重置回 no
restart: unless-stopped

unless-stopped 的语义要记清:只要手动 docker stop 过,再重启机器它就不会起来。

启动顺序问题体现为:runner 比 Gitea 先起,注册失败退出,日志里刷两行 fail to invoke Declare,然后自动重启成功。属于正常自愈,不用管。同理,VM2 的应用容器连 VM1 的数据库时也可能重试几十秒。

3.6 还欠的账#

这套东西能跑,但不等于生产级。目前明确的不足:

但作为个人开发环境,这套已经比”本地跑起来,手动 docker build,手动 scp”强了不止一个数量级。真正的好处是习惯——你会自然地按”能通过 CI”的标准写代码,而不是攒一堆改动最后一起手动部署。

下一步准备拿这套流水线去跑真正的业务功能了。



上一篇
Agent Loop:一个循环,就是模型与真实世界之间的全部距离(Agent架构实操一)
下一篇
大模型配置设计架构思考