如何配合 CI 搭建容器镜像仓库

Gisia 没有内置容器镜像仓库。不过没关系 — 官方的 Docker Registry 可以很好地与 Gisia 配合使用。在本教程中,你将在 registry.nix:5000(本地网络的纯 HTTP,无 TLS)上运行一个镜像仓库,把它的凭据作为 CI/CD 变量接入 Gisia,并编写一条流水线:受保护分支推送镜像,任意分支拉取镜像。

凭据方案使用两个仓库账号,通过不同的变量名暴露:

变量 账号 受保护 可用范围
REGISTRY_USER / REGISTRY_PASSWORD ci-push(可推送) 仅受保护分支和标签
REGISTRY_RO_USER / REGISTRY_RO_PASSWORD ci-pull(仅拉取) 所有流水线

由于推送凭据是受保护变量,随便一个特性分支的流水线根本看不到它们 — 只有 main 这类受保护分支才能发布镜像。

第 1 步 — 运行 Docker Registry

在准备承载镜像仓库的机器上,选择一个工作目录,创建包含两个账号的认证文件:

mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-push 'CHANGE_ME_PUSH' > auth/htpasswd
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-pull 'CHANGE_ME_PULL' >> auth/htpasswd

然后在同一目录下于 5000 端口启动仓库:

docker run -d --restart=always --name registry \
  -p 5000:5000 \
  -v "$(pwd)/auth":/auth \
  -v registry-data:/var/lib/registry \
  -e REGISTRY_AUTH=htpasswd \
  -e REGISTRY_AUTH_HTPASSWD_REALM="Gisia Registry" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  registry:2

纯 htpasswd 认证无法区分推送和拉取 — 在仓库层面两个账号实际上都有完整权限。受保护/非受保护的隔离由 Gisia 负责:非受保护流水线只能拿到 ci-pull 凭据。如果需要硬性限制,可以以后在仓库前面加一个 token 认证代理。

第 2 步 — 让 registry.nix 可解析,且无需 TLS

不要用 /etc/hosts 来解决解析问题:运行在容器里的 CI 作业不会继承主机的 hosts 文件,域名在主机上能解析,到了作业里就会失败。请从下面两种方式中选一种:

  • 本地 DNS — 在本地 DNS 服务器(路由器、dnsmasq、Pi-hole 等)上为 registry.nix 添加一条指向仓库主机的 A 记录。整个网络(包括作业容器)都会以相同方式解析它。
  • 直接用 IP — 完全跳过域名,把下文所有 registry.nix:5000 替换为仓库主机的地址,例如 192.168.1.50:5000(包括下一步的 REGISTRY 变量)。

然后允许纯 HTTP。Docker 默认拒绝非 TLS 仓库,所以要在每台需要推送或拉取的 Docker 主机的 /etc/docker/daemon.json 中加入该地址:

{
  "insecure-registries": ["registry.nix:5000"]
}

之后重启 Docker:

sudo systemctl restart docker

还要确保网络本身允许这些流量:每台访问镜像仓库的机器 — Runner 主机、CI 作业容器、你的工作站 — 都必须能通过 5000 端口访问到仓库主机。检查防火墙和 VLAN 隔离,并在每台机器上验证:

curl -u ci-pull:CHANGE_ME_PULL http://registry.nix:5000/v2/_catalog

第 3 步 — 在 Gisia 中添加 CI/CD 变量

进入项目的 Settings → CI/CD → Variables,添加:

Key Value Protect variable Masked
REGISTRY registry.nix:5000
REGISTRY_USER ci-push
REGISTRY_PASSWORD 推送密码
REGISTRY_RO_USER ci-pull
REGISTRY_RO_PASSWORD 拉取密码

推送凭据要勾选 Protect variable — 正是它把这些变量限制在受保护分支和标签的流水线上。默认分支(main)开箱即为受保护;可以在 Settings → Repository → Protected branches 中管理列表。

第 4 步 — 编写流水线

按照如何运行流水线使用 docker executor 时,每个作业都运行在容器里 — 而默认作业镜像没有 docker CLI(会报 docker: command not found)。需要做两件事。

首先,通过在 Runner 配置中挂载 Docker socket,让作业容器使用主机的 Docker 守护进程。启动 Runner 时把配置目录挂载出来,让 config.toml 保存在主机上:

docker run -d --name gisia-docker-runner \
  -v ./config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:v18.11.2

(如果 Runner 已经在没有配置挂载的情况下运行,可以用上面的命令重建并重新注册,或者直接在容器内编辑:docker exec -it gisia-docker-runner vi /etc/gitlab-runner/config.toml。)

然后编辑 ./config/config.toml,把 socket 加入作业卷:

[[runners]]
  [runners.docker]
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]

Runner 会自动重新加载配置。由于作业现在通过 socket 与主机守护进程通信,第 2 步中在 Runner 主机上配置的 insecure-registries 就是生效的那份 — 无需针对每个作业再做配置。

其次,让作业运行在带有 Docker CLI 的镜像里:docker:28-cli

项目还需要有可构建的内容。如果仓库里还没有 Dockerfile,先在仓库根目录提交一个最简版本:

FROM alpine:3.22
CMD ["echo", "Hello from the Gisia registry"]

然后添加一个 .gitlab-ci.ymlmain 分支推送,任意分支拉取:

stages:
  - build
  - test

build-and-push:
  stage: build
  image: docker:28-cli
  script:
    - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
    - docker build -t "$REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHORT_SHA" -t "$REGISTRY/$CI_PROJECT_PATH:latest" .
    - docker push "$REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHORT_SHA"
    - docker push "$REGISTRY/$CI_PROJECT_PATH:latest"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

smoke-test:
  stage: test
  image: docker:28-cli
  script:
    - echo "$REGISTRY_RO_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_RO_USER" --password-stdin
    - docker pull "$REGISTRY/$CI_PROJECT_PATH:latest"
    - docker run --rm "$REGISTRY/$CI_PROJECT_PATH:latest"

main 上两个作业都会运行:镜像被构建、打上提交 SHA 和 latest 标签并推送。在特性分支上只有 smoke-test 运行,而且由于流水线不受保护,$REGISTRY_PASSWORD 根本不存在 — 误推送会直接在 docker login 失败。

第 5 步 — 验证

main 推送一个提交,等流水线成功后,查看仓库:

curl -u ci-pull:CHANGE_ME_PULL http://registry.nix:5000/v2/_catalog

你应该能看到列表中出现你的项目路径。此后每次合并到 main 都会发布一个新镜像,任何分支都可以拉取它用于测试或部署。

安全注意事项

这套方案用隔离性换取了简单性,需要清楚自己接受了什么:

Docker socket 等同于 Runner 主机的 root 权限。 任何能访问 /var/run/docker.sock 的 CI 作业都可以启动特权容器、挂载主机文件系统、读取机器上的任何内容 — 包括同一主机上其他作业的机密。Docker-in-docker(privileged = true)并不更安全:特权容器同样可以逃逸到主机。受保护变量保护的是仓库凭据,而不是主机本身。

所以关键问题是谁能在这个 Runner 上运行流水线:

  • 可信团队或家庭实验室 — 按现状使用没有问题。
  • 把 Runner 放在专用的、可随时销毁的主机上(小型虚拟机即可),绝不要放在 Gisia 服务器本机。
  • 如果允许外部贡献者,把 Runner 限定到受保护分支,让陌生人的特性分支流水线永远碰不到 socket。
  • 共享实例上有不可信用户 — 彻底去掉 socket,改用 buildah 这类无守护进程的工具构建,它不需要守护进程,也可以在无特权下运行(kaniko 曾是这里的首选,但已于 2025 年归档;Chainguard 维护着一个分支)。

镜像仓库本身是纯 HTTP 加基础认证。 凭据和镜像内容在网络上明文传输,且 htpasswd 无法实现只读账号。在私有局域网里可以接受;绝不要在没有 TLS 和真正 token 认证的情况下把 5000 端口暴露到公网。