用 Gitea Actions 实现 NAS 服务自部署
今天整理了一下家里 NAS 上的 CI/CD,把业务服务的构建和部署放回了各自的仓库,通过 Gitea Actions 在 NAS 上完成发布。
业务跑通之后,又顺手把 Gitea 和 runner 的更新也做了进去。这样一来,平时改业务代码可以推送后自动部署,改基础设施则手动运行一次 Action。期间遇到了容器挂载、代理和更新 runner 自己的问题,在这里记录一下。
项目自己维护部署
原先 NAS 上有两个仓库,分别放镜像构建和 Compose 配置,叫作 nas-images 和 nas-compose。这次调整后,我希望一个业务项目从源码到运行方式,都能在自己的仓库里找到。
以一个 Rust 服务为例,下面简称为 rust-server,与部署有关的文件就是这几个:
rust-server/
├── src/
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── compose.yaml
└── .gitea/workflows/deploy.yamlDockerfile 负责怎样构建、怎样启动;Compose 定义端口和数据目录等运行参数;workflow 把构建与发布串起来。修改业务时,对应的部署改动可以一起提交,不需要再去另一个仓库找配置。
底层还是 Docker Compose。我只有一台 NAS,镜像就在这台机器上构建,也在这台机器上运行,因此暂时省去了推送镜像仓库、再拉取的过程。
Runner 里的 host 是谁
Gitea 和 runner 都运行在容器中。runner 注册了一个 alpine3 标签,配置如下:
runner:
capacity: 1
labels:
- alpine3:hostworkflow 使用 runs-on: alpine3,任务便会直接在 runner 所在环境中执行。这里的 host 指的是 runner 容器本身,NAS 宿主机不会直接执行这些步骤。官方文档也专门说明了这个区别。
为了让任务能管理 NAS 上的容器,我把 Docker socket 挂载给了 runner,并在 runner 镜像中放入 Docker CLI 和 Compose。命令在 runner 里执行,实际连接的是 NAS 的 Docker daemon。
以这个 Rust 项目为例,整个过程可以理解为:
Gitea 收到 push
→ runner checkout 对应提交
→ runner 执行 docker build
→ NAS Docker 在构建容器里运行 cargo build
→ 生成业务运行镜像
→ Compose 更新 NAS 上的业务容器所以 runner 不需要为了这个项目安装 Rust。项目 Dockerfile 使用多阶段构建,在 Rust 镜像里编译,再把程序复制到 Debian slim 镜像中,最终运行镜像只保留程序和运行依赖。
能执行 workflow 的代码,也就能通过 socket 操作 NAS 上的 Docker。
把发布过程收进一个命令
如果每个业务仓库都复制一份部署脚本,后续改健康检查或镜像清理时,还是得挨个修改。所以我把共同的部分放进 runner,提供了一个 nas-deploy 命令。
这是自己实现的小工具,负责读取业务 Compose,再完成构建和发布。rust-server 的工作流如下:
name: Build and deploy Rust Server
on:
push:
branches:
- master
jobs:
deploy:
runs-on: alpine3
steps:
- name: Checkout the triggering commit
uses: https://github.com/actions/checkout@v4
with:
ref: ${{ gitea.sha }}
persist-credentials: false
- name: Build and deploy
run: >-
nas-deploy
--project rust-server
--image nas/rust-server
--tag "${{ gitea.sha }}"这里显式 checkout 触发工作流的提交,镜像 tag 也使用同一个提交号,方便对应到实际发布的代码。分支名只负责决定何时触发,不拿来当反复覆盖的镜像版本。
nas-deploy 默认读取当前目录的 Dockerfile 和 compose.yaml,也可以通过参数指定其他位置。对应的 Compose 仍然由业务维护:
services:
rust-server:
image: ${NAS_DEPLOY_IMAGE:?Use nas-deploy or set NAS_DEPLOY_IMAGE}
container_name: rust-server
ports:
- '23100:20022'
volumes:
- /volume1/docker/rust-server/config:/app/config
restart: unless-stopped命令会设置 NAS_DEPLOY_IMAGE,将目标服务指向刚构建的镜像。端口映射和目录挂载都按 Compose 应用,环境变量、启动参数或健康检查也可以在业务配置中定义。多个服务的项目需要通过 --service 明确选择,发布只更新这个服务,不顺带重建依赖。
挂载路径属于 NAS
这里有个容易混淆的地方。Compose 虽然是在 runner 里解析,但 bind mount 的源路径由 NAS 上的 Docker daemon 解释,必须指向 NAS 文件系统中的正确位置。
比如上面的 /volume1/docker/rust-server/config,就是 NAS 的业务配置目录。不能把 runner 临时 checkout 中的 ./config 当作宿主机上的业务目录,两边看到的文件系统并不一样。
命名卷也要注意 Compose 项目名。改了项目名,可能就对应到另一组卷和网络。接入已有服务时,我就保留了原来的项目名和镜像名,先把部署过程接上。
新版会根据 owner/repo 推导默认项目名和镜像名,例如 daief/rust-server 对应项目 daief_rust-server,镜像为 nas/daief_rust-server,避免不同用户的同名仓库互相混淆。显式指定的名称仍然优先,已有容器和数据不会自动迁移。上面的 workflow 显式设置了 --project 和 --image,所以更新 runner 后仍会沿用原来的名称。
健康检查之后才记录成功
部署先校验 Compose,再构建镜像并更新容器,接着等待健康检查。通过之后,才记录本次成功版本,并清理之前由这个工具管理的镜像。
项目镜像已经定义了 /healthz 的检查。其他项目也可以提供 --check-url。没有健康检查时,也可以显式使用 --allow-running,以容器短时间内保持运行作为较弱的成功判据。
清理范围同样需要限定。工具只处理自己记录过、镜像 ID 和归属都匹配的历史镜像,仍被其他容器引用的会跳过。数据卷和构建缓存都不在这个清理范围内。
当前选择是失败就报错,留下现场,修复代码或者回退提交后重新部署。更新失败后服务可能不可用,需要自己处理。
把工具和代理放在 runner 里
任务能执行之后,接下来就是环境问题。
我在 runner 中加入了 mise 用来管理 Node 和 uv 等开发工具(好用、跨平台,十分推荐)。业务项目有其他版本要求时,可以自己提供 mise 配置。Rust 项目继续由 Dockerfile 决定构建环境,两种方式并不冲突。
工具是在构建 runner 镜像时预装的,但持久化目录又带来了一个问题:直接把工具装到 /data,运行时挂载的卷会把镜像中的同路径内容遮住。
目前的处理是在镜像中保留一份 /opt/nas-ci/seed,启动时合并到 /data/tool-cache,再生成 shims,并刷新默认版本的别名。这样重建 runner 时可以继续使用缓存,也能拿到新镜像预装的工具,不需要每次启动再下载一遍。
代理和语言镜像源统一放在 runner 的配置中,业务 workflow 不再写 NAS 的代理地址。不过 Docker 拉取基础镜像还涉及 daemon 自身的代理,单改 runner 环境变量是不够的。这次就碰到了宿主机 Docker 仍指向旧代理端口的问题,修正后才恢复正常拉取。
更新 Gitea 和 runner 自己
业务服务跑通之后,自然又想把 Gitea 和 runner 的配置也纳入同样的流程,于是单独建了 nas-gitea 仓库。
但这里不能直接照搬业务发布。假如 Action 里直接替换 runner,执行到一半任务就把自己停了;Gitea 自己也要更新,任务状态还没来得及保存,服务可能就已经退出了。
所以这部分分成两段。Action 先构建新镜像,再启动一个独立的更新容器。更新器查询该仓库、该提交最新一次 Actions 运行的状态,确认结束且成功后,再停止旧服务并应用更新。
sequenceDiagram participant User as 使用者 participant CI as Gitea Actions participant Updater as 独立更新容器 participant Docker as NAS Docker User->>CI: 手动运行 main 的更新工作流 CI->>Docker: 构建 Gitea 和 runner 镜像 CI->>Updater: 启动更新容器并传入提交号 CI-->>CI: 完成本次任务 Updater->>Updater: 确认该仓库、该提交最新一次 Actions 结束且成功 Updater->>Docker: 停止旧 runner 和 Gitea Updater->>Updater: 备份持久数据与运行配置 Updater->>Docker: 应用新配置并启动服务 Updater->>Updater: 检查健康并记录结果
更新工作流仅手动触发,更新任务只在所选分支为 main 时执行:
name: Update Gitea infrastructure
on:
workflow_dispatch:
jobs:
update:
if: ${{ gitea.ref == 'refs/heads/main' }}
runs-on: alpine3
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ gitea.sha }}
persist-credentials: false
- name: Build images and queue update
run: >-
python3 scripts/queue_update.py
--sha "${{ gitea.sha }}"
--repository "${{ gitea.repository }}"这里还有一个区别:Action 成功,只代表镜像构建和更新任务排队成功。 真正替换服务发生在后面,最终要看独立更新器的状态。
我把状态和日志保存在 NAS 的持久目录下,更新容器结束后自动删除,文件仍然保留:
/volume1/docker/gitea/runner/volume-data/
└── updates/nas-gitea/<commit>/
├── status.json
└── update.log当前更新器通过只读查询 Gitea 的 SQLite 数据库确认 Actions 状态。这是针对自己这套环境的实现,换数据库或升级后表结构发生变化时,还需要调整这部分。
更新前先停下来备份
Gitea 的数据中包含数据库和 Git 仓库,还有配置与密钥。更新器会先停止 Gitea,再把完整持久目录打包,同时保存原来的 Compose 和 .env,备份目录带上时间与提交号。
数据库升级之后,简单切回旧镜像不一定能恢复。所以这里也没有自动回滚;如果需要降级,就得人工恢复对应的升级前备份。
健康检查也走了代理
第一次试跑自更新时,健康检查出了问题。Gitea 镜像里的 BusyBox wget 没有按预期遵循 NO_PROXY,对 127.0.0.1 的检查也走向了代理,导致更新失败。
最后在健康检查中显式关闭代理:
wget -Y off -q -O /dev/null http://127.0.0.1:3000/api/healthz同时把健康检查的 start-period 调整为 180 秒,给数据库迁移留出启动宽限期。修改后再次运行,Gitea 和 runner 都通过了健康检查,独立更新器也记录了 succeeded。
结语
目前业务项目已经通过 Actions 实际发布,Gitea 和 runner 也完成了一次自更新。后续业务主要维护自己的 Dockerfile 和 Compose,基础设施的变化则集中到 nas-gitea 中,部署入口总算清楚了一些。
这套实现依赖单台 NAS 的本地 Docker,发布时会重建容器,也没有做零停机切换。对我自己的几个服务而言,暂时已经够用了,可以丝滑地自动部署了。