用 Podman + systemd 部署 Forgejo

使用 Podman 和 systemd 部署与管理 Forgejo-aneksajo

在用户空间以容器化服务的方式运行 Forgejo,并在宿主机上实现 SSH 直通。


上一篇文章中,我介绍了自己初次接触 Forgejo-aneksajo 的经历,以及在树莓派上的部署过程。这次经历让我兴奋不已,促使我开始研究如何在更多机器上部署它。然而我很快意识到,最初采用的 Docker 方案并不是一个我愿意长期维护的选择。

我希望所有依赖都能直接通过 Debianapt install 安装,由 Debian 统一维护,而不是拼凑一堆松散的组件。带着这个目标,我花了几天时间研究各种选项。

结论先说:Podman 是一个与 systemd 深度集成、灵活强大的解决方案,两者合力就能满足所有需求。

简单来说,systemd 是现代 Linux 系统上管理一切的工具,无论 Forgejo 跑在什么机器上,systemd 都是既定的基础设施。

Podman 在很多方面与 Docker 类似,但更像一个"把容器当普通进程"的 Docker(虽然没有 Singularity 那么彻底)。它没有守护进程,完全支持以普通用户身份运行容器(rootless)。Podman 能直接处理镜像、容器以及一组相关容器(Pod)。关于 Podman 有很多值得深聊的内容,这里篇幅有限,推荐从 redhat.com 开始了解——Red Hat 是 Podman 的主要推动者。值得一提的是,Podman 甚至支持 SIF 格式的容器,这种格式在科学计算和 HPC 领域被广泛使用。

部署目标

下文描述的 Forgejo 部署方案具备以下特性:

  • 服务完全运行在用户空间
  • 使用 systemd 启动、停止和监控服务
  • 通过反向代理配置对外提供服务(如 https://git.example.com
  • 使用「SSH 直通」,避免运行 Forgejo 内置的 SSH 服务器,也不需要在宿主机已有 SSH 服务器的基础上额外暴露端口。所有用户都通过通用的 git@<host>/... 访问 Forgejo,由 Forgejo 根据所用 SSH key 决定是否允许访问对应仓库。
  • 从源码构建 Forgejo-aneksajo 容器镜像,其余依赖全部来自 Debian 12+ 的官方软件包仓库
  • 兼容 Pi OS 以及云服务商/VPS 提供商提供的标准 Debian 12 镜像

Debian 12 自带的 Podman 版本为 4.3。但从 4.4 版本起,systemd 集成方面有了显著改进。考虑到未来升级(Debian 13 很可能搭载 Podman v5),本文也会简要介绍这些新特性。

软件依赖

除 systemd(默认已有)外,本方案还需要以下软件包,均可通过 Debian 的 apt install 安装:

  • podman:用于构建和运行容器
  • caddy:用作 Forgejo 的反向代理。其他方案(如 nginx)同样可行,但本文不作介绍。

用户设置

首先在目标系统上创建一个专用的 git 用户。这个用户不仅负责运行 Forgejo 服务,还将作为宿主机上 SSH 访问的入口。

以下所有步骤均在目标机器的普通用户账号下执行,需要提升权限时使用 sudo

我们创建一个普通(非系统)git 用户,计划将所有 Forgejo 数据放在 /home/git 下,直接用 adduser 即可:

sudo adduser git --disabled-password

如果不加 --disabled-password,可以设置密码,这样就能从其他机器直接将已有的 Forgejo 容器镜像推送到这台机器(将 example.com 替换为目标主机地址):

podman image scp localhost/forgejo-aneksajo:7-latest-rootless git@example.com::

但本文将直接在目标机器上构建容器。

此时有必要检查 git 用户的用户命名空间映射是否正确配置。这是运行 rootless 容器的必要条件——容器内的进程需要以不同的用户和组运行。/etc/subuid/etc/subgid 中必须有 git 用户的映射定义(具体数值因系统而异):

❯ grep 'git' /etc/subuid
git:265536:65536
❯ grep 'git' /etc/subgid
git:265536:65536

这篇文章有关于用户命名空间的更多背景知识。

我们计划通过 systemd 以 git 用户身份运行容器,这要求即使该用户未登录,systemd 也要为其维护一个控制进程。通过 systemd 的 user lingering 功能实现:

sudo loginctl enable-linger git

这同时会在 /run/user/$UID 创建 git 用户的工作目录。

该目录需要通过 XDG_RUNTIME_DIR 环境变量声明,systemd 才能正常为该用户工作。为使其自动生效,将以下代码片段加入 git 用户的 shell 配置文件:

if [ -z "${XDG_RUNTIME_DIR}" ]; then
  XDG_RUNTIME_DIR=/run/user/$(id -u)
  export XDG_RUNTIME_DIR
fi

具体放在哪个文件取决于所用 shell:对于 zsh 放在 /home/git/.zshenv,对于 bash 放在 /home/git/.bashrc注意不要放在任何「profile」文件中,那些是登录 shell 用的,我们不会用到。

本文在需要时仍会显式 export XDG_RUNTIME_DIR,已完成上述配置的读者可以忽略这些步骤。

服务配置

我希望所有 Forgejo 相关配置都放在 /etc 下,以便通过 etckeeper 与机器上的其他配置一同追踪版本。

所有配置统一放在 /etc/forgejo 下:

sudo mkdir -p /etc/forgejo

SSH 直通

我们希望对 Forgejo 的 SSH 访问通过 git 用户和宿主机上运行的 SSH 服务器来完成。这样可以完全禁用 Forgejo 自带的 SSH 服务器,无需单独暴露一个端口。所有用户都使用 git@<host>/... 的通用方式访问 Forgejo,Forgejo 根据所用 SSH key 决定是否允许访问特定仓库。Gitea 文档中有更多相关说明。

为实现这一目标,我们创建一个小脚本,作为 git 用户的受限登录 shell。它的作用是将命令转发给 Forgejo 服务容器(容器名为 forgejo,需与其余配置保持一致):

sudo mkdir /etc/forgejo/bin

cat << 'EOF' | sudo tee /etc/forgejo/bin/forgejo-shell
#!/bin/sh
#
podman exec -i --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" forgejo sh "$@"
EOF

sudo chmod +x /etc/forgejo/bin/forgejo-shell

# 将此脚本设为 git 用户的登录 shell
sudo usermod --shell /etc/forgejo/bin/forgejo-shell git

SSH 直通设置的第二个关键部分是将 git 用户的访问授权委托给容器内的 Forgejo。为此,我们在宿主机的 sshd 配置中添加一个 drop-in 文件,使用 AuthorizedKeysCommand 在容器内调用 gitea keys

cat << 'EOF' | sudo tee /etc/ssh/sshd_config.d/forgejo-ssh-passthrough.conf
Match User git
  AuthorizedKeysCommandUser git
  AuthorizedKeysCommand /usr/bin/podman exec -i forgejo /usr/local/bin/gitea keys -c /etc/gitea/app.ini -e git -u %u -t %t -k %k
EOF

重启宿主机 SSH 服务以加载新配置:

sudo systemctl restart sshd

反向代理设置

Forgejo 容器运行后,将通过 3000 端口提供 HTTP 访问。我们告诉 Podman 同时将该端口映射到宿主机的 3000 端口,使 Forgejo 可通过 http://localhost:3000 或机器的主机名访问。

但我们希望使用 HTTPS 和类似 https://git.example.com 的域名(将 example.com 替换为你的域名)。Caddy 可以最简便地实现这一点,并自动申请证书。

首先将域名的 DNS git 子域名指向服务器 IP 地址,然后:

sudo apt install caddy

/etc/caddy/Caddyfile 中添加以下内容,之后执行 sudo systemctl reload caddy(同样将 example.com 替换为你的域名):

# forgejo
git.example.com {
    reverse_proxy localhost:3000
}

Caddy 会自动申请所需证书。

获取 Forgejo-aneksajo 容器镜像

目前可以从 Docker Hub 获取最新版本的镜像(支持 amd64arm64)。以 git 用户身份拉取:

podman pull docker.io/mihanke/forgejo-aneksajo:7-latest-rootless

如果不想使用 Docker Hub 账号,或所需版本暂时还不可用,也可以直接从源码构建。以下脚本完成这一操作,注意我们将直接以 git 用户身份构建镜像

# 切换到 git 用户
sudo -u git -s

# 将 Forgejo-aneksajo 源码克隆到 src/ 目录
mkdir ~/src
git clone https://codeberg.org/matrss/forgejo-aneksajo.git ~/src/forgejo-aneksajo
# 切换到要构建的发布版本
git -C ~/src/forgejo-aneksajo checkout v7.0.5-git-annex1

# 设置并确认 git 用户的 systemd 工作正常
export XDG_RUNTIME_DIR="/run/user/$UID"
systemctl --user status

# 构建镜像
podman build \
  --tag forgejo-aneksajo:7.0.5-rootless \
  --tag forgejo-aneksajo:7-latest-rootless \
  -f ~/src/forgejo-aneksajo/Dockerfile.rootless

podman build 指向的是 Dockerfile.rootless,这一点很重要。「rootless」容器的设置方式与普通方式有本质区别,本文只介绍 rootless 方式。

Forgejo 初次启动

在首次启动 Forgejo 之前,需要让配置目录对 git 用户可写,因为 Forgejo 会将默认配置写入 /etc/forgejo/app.ini。当该文件已存在时,Forgejo 的行为会有所不同;当目录不可写时,Forgejo 会直接报错。之后我们会收回这一权限。

sudo chown git: /etc/forgejo

当然也可以提前手动写好完整的配置文件。但让初始化向导自动运行有其好处——它会读取一些环境属性(这也是为什么我们提前配置了 Caddy),生成一个开箱即用的配置,且适配当前部署的 Forgejo 版本,未来切换版本时同样有效。

以下脚本将准备好 Forgejo 的运行环境、创建容器、生成对应的 systemd 服务单元,并首次(通过 systemd)启动容器。所有操作以 git 用户身份执行。

几点说明:

  • 我们创建 gitea/git/ 两个目录,分别存放 Forgejo 实例数据(数据库、附件等)和 Git 仓库。后者就是一组标准的裸 Git 仓库(支持 annex)。
  • 将宿主机的 /etc/forgejo 绑定挂载到容器内 Forgejo 期望的位置
  • 容器名设为 forgejo,对应的 systemd 服务名为 container-forgejo
  • forgejo 容器只暴露 3000 端口用于 HTTP(S) 访问和 Web UI
  • 将宿主机 git 用户映射到容器内 uid=1000 的 git 用户,这样容器内创建的所有文件在宿主机上都归 git 用户所有
  • 还可以配置更多选项,例如网络设置。在 podman container create 中添加 --network=slirp4netns:allow_host_loopback=true 可让 Forgejo 容器访问宿主机的回环网络接口,从而使用宿主机的 SMTP 服务器通过机构中继发送邮件。
# 以下代码以 git 用户身份执行
sudo -u git -s

# 创建存放 Forgejo 实例数据和仓库的目录
mkdir ~/gitea ~/git

# systemctl 需要 XDG_RUNTIME_DIR
export XDG_RUNTIME_DIR="/run/user/$UID"

# 创建容器
podman container create \
  --name forgejo \
  -p 3000:3000 \
  -v "$HOME/gitea:/var/lib/gitea:Z" \
  -v "$HOME/git:/var/lib/gitea/git:Z" \
  -v '/etc/forgejo:/etc/gitea:Z' \
  --userns 'keep-id:uid=1000,gid=1000' \
  localhost/forgejo-aneksajo:7-latest-rootless

# 从容器生成 systemd 服务单元
mkdir -p ~/.config/systemd/user
( cd ~/.config/systemd/user \
  && podman generate systemd \
       --restart-policy=always --new --files --name forgejo )

# 容器只是为了生成服务单元,不需要保留
podman rm forgejo

# 首次启动
systemctl --user start container-forgejo

如果觉得这个流程有些笨拙,你不是一个人——从 Podman 4.4+ 开始,有更优雅的方式

对于 Podman v4.3,我们像使用 Docker 一样先创建一个容器,但目的只是从中生成 systemd 服务单元,之后可以立即删除这个容器,因为服务单元每次启动时都会创建一个新容器(--new)。可以查看 .config/systemd/user/ 下生成的 .service 文件。如果启动容器时遇到问题,临时注释掉 Restart= 行有助于获得更清晰的错误信息。

容器运行后,打开浏览器访问 Web UI。按前述步骤配置了反向代理后,访问 https://git.example.com(替换为你的域名),会出现初始化向导。所有选项保持默认即可配置一个使用 SQLite 数据库的 Forgejo 实例(独立数据库服务器/容器的配置不在本文范围内,但 Podman Pod 是实现这一目标的合适工具)。

点击按钮初始化 Forgejo 实例,系统会填充 git 用户主目录下的 gitea/ 目录。

接下来通过 Web UI 注册第一个用户,该用户会自动获得管理员权限,后续可以将其他用户提升为管理员。

完成后停止容器:

systemctl --user stop container-forgejo

(以 git 用户执行)

收尾工作

初始化 Forgejo 实例后,/etc/forgejo/app.ini 中已生成一份合理的默认配置,但可以进一步调整。详细选项请参考 Forgejo 文档

以下是几个值得关注的配置片段:

[annex]
ENABLED = true

此项启用 git-annex 支持。不启用时,Forgejo-aneksajo 的行为与原版 Forgejo 完全相同。

[server]
DISABLE_SSH = false
; 使用 SSH 直通,无需 Forgejo 内置 SSH 服务器
START_SSH_SERVER = false
; 从显示的 URL 中去掉自定义端口号
SSH_PORT = 22

在本文的 SSH 直通方案下,可以完全禁用 Forgejo 内置的 SSH 服务器。但注意不要把 SSH 功能整体禁掉!将 SSH_PORT 设为标准的 22,可以让 Forgejo 在显示的 SSH URL 中不附加自定义端口号。这很重要,因为 SSH 访问实际上通过宿主机 22 端口的 SSH 服务器进行。

[service]
DISABLE_REGISTRATION = true

禁用公开注册是个好主意。管理员仍可通过 Web UI 后台创建账号,对于小规模用户群来说已经足够,同时也避免了处理不明来源的注册请求。

现在收紧配置文件的访问权限:

sudo chown root:git /etc/forgejo/app.ini
sudo chmod 640 /etc/forgejo/app.ini

将配置文件设为只有 root 可写、git 组(git 用户的主要组)可读。这样可以防止运行 Forgejo 服务的用户修改配置,也让在配置文件中添加密钥(如 SMTP 密码)时稍微安全一些。

最后以 git 用户身份设置开机自启并重启服务:

systemctl --user enable container-forgejo
systemctl --user start container-forgejo

现在服务应该已经完全可用。建议验证以下几点:

  • 重启机器,确认容器会由 git 用户的 systemd 自动启动
  • 通过 Web UI 为某个 Forgejo 用户添加 SSH key,从另一台机器测试克隆和推送

使用 Podman 4.4 及更高版本

Podman v4.4 引入了 Quadlets,解决了上述流程中一些相对笨拙的部分。

有了 Quadlets,不再需要先创建 Podman 容器、生成 systemd 服务单元、再将其放到 systemd 能识别的位置。

取而代之的是:Podman 提供了一个 systemd generator,能从 quadlet 文件自动生成临时服务单元。Quadlet 文件的语法和语义与 service unit 相似,可以定义容器、卷、网络等资源,在某种程度上取代了 compose 文件,但与 systemd 的集成更加紧密。

关于这一特性的完整介绍超出了本文范围,但下面的小例子足以说明它有多好用。

用户空间容器的 Quadlet 文件可以放在多个位置,包括 /etc 下,这样 etckeeper 也能对其进行版本管理。

sudo mkdir -p /etc/containers/systemd/users/$(id -u git)

在该目录下放置容器定义文件,命名为 container-forgejo.container.container 扩展名使其成为容器规范文件,加 container- 前缀是为了让生成的服务单元名称与 Podman v4.3 方式保持一致):

cat << 'EOF' | sudo tee /etc/containers/systemd/users/$(id -u git)/container-forgejo.container
[Unit]
Description=Forgejo-aneksajo container
Wants=network-online.target
After=network-online.target

[Container]
ContainerName=container-forgejo
# 使用的容器镜像
# 若镜像本地不存在,会自动拉取,因此也可以直接写:
#Image=docker.io/mihanke/forgejo-aneksajo:7-latest-rootless
Image=localhost/forgejo-aneksajo:7-latest-rootless
PublishPort=3000:3000
Volume=%h/gitea:/var/lib/gitea:Z
Volume=%h/git:/var/lib/gitea/git:Z
Volume=/etc/forgejo:/etc/gitea:Z
# 容器内运行 git 的 forgejo 用户 uid/gid 为 1000,将其映射到宿主机上运行容器的用户
UserNS=keep-id:uid=1000,gid=1000
Network=host

[Service]
Restart=always
TimeoutStartSec=900
WorkingDirectory=%t

[Install]
# 开机默认启动
WantedBy=default.target
EOF

上述内容与之前的容器设置基本对应,看起来就像一个 service unit 文件,可以使用 systemd 的所有特性(模板、占位符等),其中只有 [Container] 部分是 Quadlet 特有的。

有了这个 Quadlet 文件(并删除旧的 service 文件),git 用户无需任何额外操作,直接执行 systemctl --user start|stop container-forgejo 即可。不再会生成静态的 .service 文件,也不需要显式 enable 容器服务,systemd 的依赖管理会自动处理。

总结

这套方案比我最初搭建的版本好得多。容器外的所有软件依赖直接来自 Debian 官方,与宿主机的集成也更加紧密。容器化的 Forgejo 服务由 systemd 统一管理,与其他所有服务的管理方式完全一致。Forgejo 运行在非特权宿主机账号下,通过 git 用户进行的 SSH 访问体验与 GitHub/GitLab 别无二致。

等到需要为 Forgejo 部署 Actions runner 的时候,我可能会再回来更新这套配置。不过就目前而言,我对这个结果相当满意。

有人可能会说,要理解足够多的 systemd 知识才能做到这些,并不比学 Docker 轻松。但我认为,投入时间学习 systemd 的回报远不止于容器这一个领域,对我来说这些时间花得很值。