前言

服务器闲着也是闲着,本着“生命在于运行”的原则,决定给它再找点活干。之前项目一直用 GitHub Actions + 服务器 WebHook 部署,用久了有点腻,正好折腾一下私有流水线。反正最后容器也是在这台机器上跑,干脆连镜像也在这台机器上 build 算了。

所以这次就用了 Gitea + Act Runner。代码 push 到 Gitea,Runner 接到任务后在服务器上拉代码,接着执行 podman build 和 podman run。镜像 build 完就留在本机,省了推到镜像仓库再拉回来的步骤。

image-20261109141806375

先把 Gitea 跑起来

Gitea 我用的是 1.26 的 rootless 镜像。我比较喜欢用 systemctl --user 管服务,所以这次也用了 Podman 的 Quadlet。只需要写一个 gitea.container,Podman 就会生成对应的 gitea.service,以后启动、重启都用 systemctl --user 就行。

下面是我用的 gitea.container(已脱敏)。数据库直接在 Gitea 的初始化页面填,我这里接的是已有的 MariaDB。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Unit]
Description=Gitea Code Service
After=network.target

[Container]
Image=docker.io/gitea/gitea:1.26-rootless
ContainerName=gitea
Network=app-net.network
PublishPort=127.0.0.1:3000:3000

Volume=%h/workspace/gitea/data:/var/lib/gitea:U
Volume=%h/workspace/gitea/config:/etc/gitea:U

Environment=GITEA__server__HTTP_PORT=3000
Environment=GITEA__server__ROOT_URL=https://git.example.com/

[Service]
Restart=always
TimeoutStopSec=10

[Install]
WantedBy=default.target

PublishPort 把 Gitea 的 3000 端口映射到 127.0.0.1:3000,方便宿主机上的 Act Runner(下面会提到) 连进来。ROOT_URL 填 Nginx 代理的域名地址(如果有),不然从网页上复制出来的 clone 地址可能就不对。

先把数据目录建好,再把 gitea.container 文件放到 ~/.config/containers/systemd/。

1
2
3
4
5
mkdir -p ~/.config/containers/systemd ~/workspace/gitea/{data,config}
cp gitea.container ~/.config/containers/systemd/

systemctl --user daemon-reload
systemctl --user start gitea.service

改完配置之后用 systemctl --user restart gitea.service,起不来就用 journalctl --user -u gitea.service -e 看看,启动成功应该是这样的:

image-20261109150342943

让 Act Runner 跑在宿主机上

光有 Gitea 还不行,它只负责安排任务,真正执行命令的是 Act Runner。

一开始我想着 Gitea 都放容器里了,Runner 也一起放进去得了。但是发现 Runner 和 rootless Podman 适配非常不好(官方教程都是基于 Docker 的),Podman 默认的 socket 在 $XDG_RUNTIME_DIR/podman/podman.sock,跟 Docker 常用的 /var/run/docker.sock 不在一个地方,并且权限什么的也不一样。从 Runner 容器里调用宿主机的 Podman,除了挂载路径,还得看容器里的用户有没有权限访问这个 socket。我当时 podman build 跑得好好的,一到 podman run,要么报权限不够,要么报找不到 Docker daemon。

折磨了我很久的报错:

1
failed to create container: 'Error response from daemon: make cli opts(): making volume mountpoint for volume /var/run/docker.sock: mkdir /var/run/docker.sock: permission denied'

折腾了一圈,干脆把 Runner 放回宿主机,用平时跑 Podman 的那个普通用户启动。这样 workflow 里执行的命令,跟我 SSH 上去手敲的是一回事。

Act Runner 的二进制我放在 ~/workspace/gitea/runner。宿主机上除了 Git 和 Podman,还得装 Node.js,actions/checkout@v4 要用它。这里有个容易搞混的地方:项目代码从自己的 Gitea 拉,checkout 这个 action 本身默认还是从 GitHub 下载的。

首先生成 Runner 运行配置:

1
./act_runner generate-config > config.yaml

生成的 config.yaml 挺长,这次主要看这几项:

1
2
3
4
5
runner:
envs:
PATH: "/usr/local/bin:/usr/bin:/bin"
labels:
- "host:host"

如果 Node.js 是用 nvm 装的,先在终端里执行 command -v node,然后把实际的 bin 目录填进 PATH。平时登录进去能运行 node,不代表 systemd 启的 Runner 也能找到它。

host:host 前一个 host 是标签名,对应 workflow 里的 runs-on: host;后一个 host 是执行方式,表示直接在宿主机上跑。启动 Runner 的用户要跟平时手动执行 Podman 的用户是同一个,因为 rootless Podman 的镜像和容器是按用户存的。

在仓库的设置里拿到注册 token:

image-20261109151146282

然后在 Runner 目录里执行注册命令:

1
./act_runner --config config.yaml register

按提示输入 Runner 能访问的 Gitea 地址、注册 token 和名称。注册之后会生成 .runner 文件,不用管他。

接下来跟之前管其他后台服务差不多,写一个 ~/.config/systemd/user/act_runner.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Gitea Runner
After=network.target gitea.service

[Service]
Type=simple
WorkingDirectory=%h/workspace/gitea/runner
ExecStart=%h/workspace/gitea/runner/act_runner daemon --config %h/workspace/gitea/runner/config.yaml
Restart=always
TimeoutStopSec=10

[Install]
WantedBy=default.target
1
2
systemctl --user daemon-reload
systemctl --user enable --now act_runner.service

WorkingDirectory 别漏了,上面的 .runner 是相对路径。如果希望服务器重启之后不登录也能启动这两个用户服务,可以给部署用户开启 linger:sudo loginctl enable-linger "$(id -un)"。

编写部署 workflow

这部分和以前写 GitHub Actions 差不多。项目根目录放 Containerfile,workflow 放在 .gitea/workflows/deploy.yml。平时 push 到 main 自动部署,想手动跑一遍就去 Actions 页面点。

我常用的 Workflow 大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
name: Build and deploy

on:
push:
branches:
- main
workflow_dispatch:

concurrency:
group: app-production
cancel-in-progress: false

jobs:
deploy:
runs-on: host

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Check Podman
run: podman version

- name: Build and deploy
shell: bash
run: |
set -euo pipefail

image_name="app-image"
container_name="app-container"
build_ref="${image_name}:build"

podman build \
--file Containerfile \
--tag "${build_ref}" \
.

new_id="$(podman image inspect --format '{{.Id}}' "${build_ref}")"
old_id=""
if podman image exists "${image_name}:latest"; then
old_id="$(podman image inspect --format '{{.Id}}' "${image_name}:latest")"
fi

if [[ -n "${old_id}" && "${new_id}" == "${old_id}" ]]; then
podman rmi "${build_ref}" >/dev/null 2>&1 || true
echo "镜像未变化(${new_id}),跳过部署"
exit 0
fi

if podman image exists "${image_name}:rollback"; then
podman rmi "${image_name}:rollback"
fi
if [[ -n "${old_id}" ]]; then
podman tag "${image_name}:latest" "${image_name}:rollback"
fi
podman tag "${build_ref}" "${image_name}:latest"
podman rmi "${build_ref}" >/dev/null 2>&1 || true

podman rm --force "${container_name}" >/dev/null 2>&1 || true
podman run \
--detach \
--name "${container_name}" \
--restart unless-stopped \
"${image_name}:latest" >/dev/null

echo "已部署 ${image_name}:latest(${new_id})"
sleep 2
podman logs "${container_name}" || true

- name: Clean old resources
if: always()
shell: bash
run: |
set -euo pipefail

image_name="app-image"
podman image prune --force >/dev/null

podman images \
--filter "reference=${image_name}:*" \
--format '{{.Repository}}:{{.Tag}}' |
while read -r image; do
case "${image}" in
"${image_name}:latest"|"${image_name}:rollback")
;;
*)
podman rmi "${image}" >/dev/null 2>&1 || true
;;
esac
done

这个部署脚本其实就干这么几件事:先用 podman build 打一个带 :build 标签的镜像,再跟现在的 :latest 比镜像 ID。如果一样,摘掉临时标签就结束,容器接着跑。如果不一样,就把原来的 :latest 留作 :rollback,让新镜像接手 :latest,再用它重新起容器。

:rollback 留着是为了真出问题时能手动退回上一版,虽然我几乎从来没用到过。

最后

折腾了这么一圈,总算达到了和 Github Actions 相似的效果,但并不是一无所获,起码我累到了 🤓

image-20261109152238463