Table of contents#
Open Table of contents
1. 架构设计#
1.1 为什么是两台机器#
一台也能跑,但会把”重活”和”底盘”绑死:CI 构建时 CPU 打满,数据库跟着卡;哪天误操作把机器搞挂,代码和数据一起没。
拆成两台,职责非常清楚:
| VM1 · 基础设施层 | VM2 · DevOps 平台层 | |
|---|---|---|
| 配置 | 4C / 9.7G / 196G | 6C / 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"]
几个必须注意的点:
- ES 的
-Xms和-Xmx要相等,否则一堆性能警告;1G 是这台机器的舒适区 - Redis 一定要设
maxmemory,不设会被 OOM Killer 找上门 restart: unless-stopped每个服务都要写,这是重启机器后自动恢复的前提
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:
| Runner | label | 执行镜像 | 内容 |
|---|---|---|---|
| 后端 | java-ci | ci-runner-java:latest | node20 + pnpm + docker CLI + JDK11 + Maven |
| 前端 | node-ci | ci-runner:latest | node20 + 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_TOKEN | clone 私有仓库的 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-cache | pnpm store / maven .m2 |
| 镜像构建缓存 | BuildKit 本地 | RUN --mount=type=cache 的 npm 层 |
第一层让 pnpm install 从 90 秒降到 10 秒;第二层让 Docker build 只重建变化的层。
3.3 踩坑清单#
按出现顺序,都是真金白银踩出来的:
| # | 现象 | 根因 | 解法 |
|---|---|---|---|
| 1 | runner 显示 0 executors | 假阴性,实际在跑 | 看 docker events 或 Gitea UI |
| 2 | step 容器里没有 docker | 官方镜像精简 | 自建 ci-runner 镜像 |
| 3 | docker:27-cli 里没有 bash | 镜像基于 alpine | COPY --from 拼到 node:20-bullseye 上 |
| 4 | clone 报 could not read Username | 私有库 + 无凭据 | secret CI_TOKEN |
| 5 | uses: actions/checkout 卡住 | 去 github.com 拉 | 改纯 shell |
| 6 | step 容器连不上 localhost:3000 | 容器网络隔离 | 用真实 IP |
| 7 | ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION | pnpm 9.15.9 新增供应链策略 | 加 --config.minimum-release-age=0 |
| 8 | ERR_PNPM_IGNORED_BUILDS | 同上,默认禁止跑构建脚本 | --config.dangerously-allow-all-builds=true |
| 9 | verify-deps-before-run 反复重装 | 同上 | .npmrc 加 verify-deps-before-run=false |
| 10 | 部署报 compose 冲突 | 并发 recreate | flock 串行化 |
pnpm 那三条是同一个版本升级带来的”三连击”,第一次遇到会怀疑人生。
3.4 结果#
从 Gitea 的 action_run 表里拉出来最近几十次构建的真实耗时:
| 仓库 | 样本 | 平均 | 最快 | 最慢 |
|---|---|---|---|---|
ai-platform-ui(前端) | 8 次 | 3 min 28 s | 3 min 07 s | 3 min 40 s |
ai-platform(后端) | 6 次 | 1 min 27 s | 46 s | 2 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 还欠的账#
这套东西能跑,但不等于生产级。目前明确的不足:
- 构建不可复现:
pnpm-lock.yaml被 gitignore 了,每次构建依赖版本可能不同 - runner 镜像无备份:只在 VM2 本地,registry 里没有,误删要手工重建
- 密钥明文:密码写在
.env和文档里,没上 Vault/sealed-secrets - 没有监控:只有 node-exporter,缺 Prometheus + Grafana,出事靠
docker stats - 单点:两台机器都是单实例,VM1 挂了整套服务不可用
但作为个人开发环境,这套已经比”本地跑起来,手动 docker build,手动 scp”强了不止一个数量级。真正的好处是习惯——你会自然地按”能通过 CI”的标准写代码,而不是攒一堆改动最后一起手动部署。
下一步准备拿这套流水线去跑真正的业务功能了。