安全开发-Gitea

前言

  • 上一篇《安全开发-Rancher平台搭建》把内网底座搭起来了:k3s 集群、私有 CA、Traefik 网关、Rancher 多集群管理。这篇补齐最后一环:用 Gitea Actions 把 CI/CD 真正跑起来。
  • CI(Continuous Integration,持续集成) 负责代码合进来后自动检查、自动构建;CD(Continuous Delivery/Deployment,持续交付/部署) 负责把成品自动发布出去。以前“把代码变成软件包“要人手动敲命令,编译、测试、打包、上传一步步来,慢还容易出错,现在代码一提交这些步骤就全自动跑一遍。
  • 为什么选 Gitea Actions:它跟 GitHub Actions 语法兼容,你在网上见过、学过的那套 workflow + job + step 写法基本能直接搬过来用。Jenkins 功能强但插件体系重、配置要靠 Jenkinsfile,Woodpecker 轻但生态小,Gitea Actions 学习成本最低。

Gitea Web 界面

图 1:Gitea 主界面(hub.kali-team.internal)

1 Gitea:安装与公钥配置

1.1 代码托管平台:流水线的源头

  • 流水线要处理的代码、流水线配置、触发事件(提交、打 tag)都发生在代码托管平台上,平台一动,流水线才动。
  • Gitea 是一个轻量级的自托管 Git 服务,功能类似 GitHub/GitLab,但资源占用更低。内网场景代码本来就不能出内网,自托管是必然选择。

1.2 安装 Gitea:官方 Helm Chart 一键部署

  • Gitea 提供了官方 Helm Chart,安装完成后通过 hub.kali-team.internal 访问。
  • 需要手动改的配置只有一个 Service:Gitea 的 SSH 端口 22 默认只在集群内部开放(ClusterIP,只在集群内可达),宿主机克隆不了代码,所以把 SSH 服务改成 NodePort,映射到宿主机 32222 端口(NodePort 在集群每个节点的一个固定端口上开口,外部机器按“宿主机 IP:端口“访问,32222 是从 30000–32767 区间里挑的,避开常用端口)。改完之后,连“任意节点 IP:32222“就等于连到 Gitea 的 SSH。

在 Rancher 的 Gitea chart 里修改 Service 配置,改完的页面:

Gitea chart 的 Service 配置页

图 2:Rancher 里 Gitea chart 的 Service Settings 页:SSH 服务改为 NodePort、端口映射到宿主机 32222

对应的 values 改动(diff 格式:- 是原值,+ 是改后值):

@@ -282,9 +282,9 @@
loadBalancerClass: null
loadBalancerIP: null
loadBalancerSourceRanges: []
-    nodePort: null
+    nodePort: 32222
port: 22
-    type: ClusterIP
+    type: NodePort
serviceAccount:
annotations: {}
automountServiceAccountToken: false

最后点击安装,终端输出:

level=WARN msg="--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher"
Release "gitea" has been upgraded. Happy Helming!
NAME: gitea
LAST DEPLOYED: Sun Sep 13 01:32:10 2026
NAMESPACE: default
STATUS: deployed
REVISION: 2
DESCRIPTION: Upgrade complete
NOTES:
1. Get the application URL by running these commands:
  http://hub.kali-team.internal/
1. Review these warnings:
  - Gitea uses 'memory' for caching which is not recommended for production use. See https://docs.gitea.com/next/administration/config-cheat-sheet#cache-cache for available options.
  - Gitea uses 'leveldb' for queue actions which is not recommended for production use. See https://docs.gitea.com/next/administration/config-cheat-sheet#queue-queue-and-queue for available options.
  - Gitea uses 'memory' for sessions which is not recommended for production use. See https://docs.gitea.com/next/administration/config-cheat-sheet#session-session for available options.

1.3 Gitea 在 DevSecOps 中的作用

装好 Gitea,只是有了“代码的家“。在这套 DevSecOps 体系里,它还承担六项职能:

  • 代码托管:存储源代码,支持 Git 协议
  • Webhook 触发:代码提交时触发 CI/CD 流水线(Webhook 是一种“事件回调“:提前在平台里登记一个 URL,push 了代码这类事件发生时,平台主动向这个 URL 发 HTTP 请求通知对方)
  • 代码审查:Pull Request 审查流程,安全团队可以参与评审(PR 是“合并请求“:开发者把改动的代码提请团队审查,通过后合入主干)
  • Issue 管理:跟踪安全漏洞、技术债务等问题(Issue 是平台里的“工单“,用来登记要处理的问题和待办)
  • Package Registry:存储构建产物、Helm Chart 等(平台自带的“制品仓库“,构建出的包存在这里,不用散落在各处)
  • 提供 OAuth / OIDC 登录:其它平台可以直接接入 Gitea 账号做统一登录(8.4 展开)

1.4 添加公钥

  • 在宿主机上生成密钥对(ssh-keygen 是系统自带的生成密钥对命令;-t ed25519 指定密钥类型,ed25519 是较新的椭圆曲线算法,短小、快速、安全;-C 后面的文字是给密钥加的备注,方便以后区分“这把钥匙是给哪台服务器用的“):
➜  ~ ssh-keygen -t ed25519 -C "hub.kali-team.internal"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/kali-team/.ssh/id_ed25519): /home/kali-team/.ssh/kali-team.internal
Enter passphrase for "/home/kali-team/.ssh/kali-team.internal" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/kali-team/.ssh/kali-team.internal
Your public key has been saved in /home/kali-team/.ssh/kali-team.internal.pub
The key fingerprint is:
SHA256:GcSGRR0uq1cEVqiEVbmWJtbm35DdQY1sWqnhTE9fp9Y hub.kali-team.internal
The key's randomart image is:
+--[ED25519 256]--+
|     o.*B=o. . + |
|    . oo=o. o O +|
|     . +ooo+ X +o|
|      + B*  = = E|
|     . *S .o o . |
|       ...o . .  |
|      . .. o     |
|       .  . .    |
|                 |
+----[SHA256]-----+
➜  ~
➜  ~ cat .ssh/kali-team.internal.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP1ovd63A23WxDJy58RylWDuC+9Sy2DOF4Op+uJR8K58 hub.kali-team.internal
➜  ~
  • 把公钥加进 Gitea:打开 Gitea 网页 → 点右上角头像 → 设置 → SSH / GPG 密钥,把公钥全文粘贴进密钥内容框、给个标题(比如 hub.kali-team.internal),保存即可。

Gitea SSH 密钥页

图 3:Gitea SSH / GPG 密钥页:本节登记的 hub.kali-team.internal 公钥已在列表(另有一把 arch 密钥)

加完公钥,这台宿主机就能以 SSH 方式克隆 hub.kali-team.internal 上的仓库了,克隆地址会带上 32222 端口,正是 1.2 节改出来的 SSH NodePort。仓库页面见 6.2 节的图 7。

2 概念:Action Runner 到底在干什么

最容易被绕晕的一点:Gitea 自己并不执行流水线。

2.1 三张角色的分工:Gitea 派单,Runner 干活,任务容器动手

一套 Actions 跑起来有三个角色:

角色干什么
Gitea(hub)读代码仓库里的流水线 YAML,把任务挂进待办队列,把结果和日志展示出来
Runner(运行器)常驻进程,每隔几秒问 Gitea“有没有我的活“,有就领回来执行并回报结果
任务容器Runner 执行流水线时按配置新建一个 Docker 容器,流水线的命令都在这个容器里跑
  • Gitea 只负责“调度和展示“,真正的编译、打包都是在任务容器里发生的。

2.2 三个关键概念:labels、Secrets、事件上下文

  • labels(标签):流水线里每个 job 都会写 runs-on: <标签>,意思是“我要一个能干这个活的机器“,Gitea 把 job 派给标签匹配的 Runner。标签值(如 ubuntu-host)可以附带一个镜像地址,表示“任务装进这个镜像的容器里跑“。
  • Secrets(机密):仓库级的加密环境变量,只有这个仓库的流水线能读到,日志里一旦打印出来 Gitea 会自动打码。Harbor 的 robot 密码、SonarQube 的 token 这类“给程序用的密码“都放这里。
  • 事件上下文(github.*):平台自动注入的“情报“,如 ${{ github.event_name }}、${{ github.ref }} 这类写法,是这次运行由什么触发、在哪个分支/标签上等信息,让流水线能“看情况办事“。

2.3 自托管 Runner 和 GitHub 托管 Runner 的区别

GitHub 官方也提供“托管 Runner“(你什么都不用装,GitHub 出机器)。自托管的区别主要在两点:

  1. 任务容器自己掌控:我们能让任务跑在“内网能访问、且带齐内网 CA 证书“的镜像里;
  2. 没有外部依赖:内网根本连不上 github.com,托管 Runner 那种“去公共库拉东西“的模式在内网行不通。所以 Runner 必须在内网自托管,而且流水线里引用的一切资源(镜像、依赖、Action)都得是内网可达的。

3 部署自托管 Runner:act_runner

Gitea 官方的 Runner 实现叫 act_runner(gitea/act_runner)。它兼容 GitHub Actions 的 workflow 语法,所以 GitHub 上写过的流水线大多能直接搬过来。

3.1 先解决两个“内网特有“的问题

Runner 是 Go 写的,任务容器是 Docker 的,启动前有两个坑必须先堵上,否则它起不来或连不上。

问题 1:Runner 访问 Gitea(https)要校验证书。 我们的 Gitea 证书是内网 CA 签的,而 Go 程序默认只信系统 CA 库。解法是把“系统 CA + 内网 CA“合并成一个文件,用环境变量 SSL_CERT_FILE 指给 Go 程序(这是 Go 官方支持的证书加载方式):

# 合并:系统 CA 库 + 内网 CA(step 中间 CA + 根 CA)拼成一个 bundle
cat /etc/ssl/certs/ca-certificates.crt kali-team-ca.crt > ca-bundle-kali.pem

问题 2:任务容器解析不了内网域名。 任务容器默认走 Docker 的 bridge 网络,它的 /etc/hosts 和 DNS 跟宿主机完全隔离,宿主机里写的 hub.kali-team.internal 等内网域名容器里查不到,内网自建的 DNS 又不解析这些域名。解法:启动 Runner 时让它给每个任务容器追加 --add-host 参数(Docker 的“手动往 /etc/hosts 写条目“开关):

# runner/config.yaml(节选)
container:
  # 任务容器加入的 Docker 网络。不指定时 Runner 每个任务随机建一个网络,越积越多
  network: "action_default"
  # 字符串形式,条目之间用空格分隔
  options: "--add-host=hub.kali-team.internal:10.111.13.105 --add-host=mirror.kali-team.internal:10.111.13.105 --add-host=nexus.kali-team.internal:10.111.13.105 --add-host=sonarqube.kali-team.internal:10.111.13.105 --add-host=rancher.kali-team.internal:10.111.13.99 --add-host=ca.kali-team.internal:10.111.13.99"
runner:
  capacity: 1        # 同一时间最多并行跑 1 个任务(小机器保守起见)
  timeout: 3h        # 单任务超时;Gitea 服务端也有 3h 默认超时,取较短者
  labels:
    # 标签:<标签名>:<任务容器镜像>
    - "ubuntu-host:docker://mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest"
  • action_default 这个网络要提前建好(docker network create action_default)。
  • 上面标签里引用的 dev-sec-ops/gitea/runner-images:ubuntu-latest,就是 4 要自己构建的“任务容器镜像“,镜像走内网 Harbor 的 pull-through。

3.2 启动 Runner(docker compose / docker run 二选一)

我推荐 docker compose(对应仓库的 runner/docker-compose.yml)。宿主机没装 compose 插件的话,用一条等价的 docker run 也行:

export REGISTRATION_TOKEN=<在 Gitea 界面获取的注册 token>
export RUNNER_NAME=kali76-runner-01
export RUNNER_LABELS="ubuntu-host:docker://mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest"

docker run -d --name kali76-runner \
  --network action_default \
  --add-host hub.kali-team.internal:10.111.13.105 \
  -e CONFIG_FILE=/config.yaml \
  -e GITEA_INSTANCE_URL="https://hub.kali-team.internal/" \
  -e GITEA_RUNNER_REGISTRATION_TOKEN="$REGISTRATION_TOKEN" \
  -e GITEA_RUNNER_NAME="$RUNNER_NAME" \
  -e GITEA_RUNNER_LABELS="$RUNNER_LABELS" \
  -e SSL_CERT_FILE=/etc/ssl/certs/ca-bundle-kali.pem \
  -v $(pwd)/config.yaml:/config.yaml \
  -v $(pwd)/data:/data \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v $(pwd)/ca-bundle-kali.pem:/etc/ssl/certs/ca-bundle-kali.pem:ro \
  mirror.kali-team.internal/docker.io/gitea/act_runner:latest
  • 几个挂载点的作用:/var/run/docker.sock 是 Docker 的“控制接口“,Runner 靠它才能在宿主机上创建/销毁任务容器;data 目录存注册结果(.runner 状态文件里有 Runner 的服务端 ID 和随机密钥,丢了就得重新注册)。
  • 注册 token 从哪来:仓库 → 设置 → Actions → Runner,页面顶部有“注册 token“,点 Copy 复制。

alt text

  • Runner 启动后日志出现 Runner registered successfully 即注册成功,之后 Runner 进入守护状态,按 fetch_interval(默认 60s)周期性来 Gitea 取任务。
  • 注册 token、Gitea 访问令牌、Harbor robot 密码、SonarQube token 等一律放仓库 Secret / 系统环境,文章和命令行里只用占位符,绝不写真实值。

3.3 验证 Runner 在线

设置侧的 Runner 列表页(空闲即在线、未执行任务):

Runner 列表页

图 4:仓库 → 设置 → Actions → Runner:kali76-runner-01(仓库级 Runner、标签 ubuntu-host、版本 v0.6.1)状态空闲(Idle),空闲 = 在线且当前没有任务在跑

3.4 生产化部署:用官方 Helm Chart(helm-actions)在 k3s 上跑 Runner

  • 前面 3.2 用 docker run 在单机上把 act_runner 跑起来了,学习和单机小环境没问题,但生产部署短板明显:进程挂了不会自动拉起、没法扩副本、状态依赖宿主机目录、没有资源限制。
  • 这一节把同一个 act_runner 用 官方 Helm Chart(helm-actions) 包装成 StatefulSet 跑进 k3s:自动重启、可扩副本、持久化存储、资源限制、由集群统一管理。1.2 已经用 Helm 在 k3s 上装了 Gitea 服务端本身,本节装的是 Runner。
  • StatefulSet 是 Kubernetes 的“成组管理“工具:统一管理一组 Pod(Pod 是部署的最小单位,“一起运行的几个容器”),给每个成员稳定的序号名字和持久存储,某个挂了就自动拉起一个补上。这里一个 StatefulSet 就是一组 Runner 副本,每个副本都能独立领任务。

alt text

图 5:Rancher「应用程序 → Chart 仓库」里的 actions chart(gitea-charts 仓库、版本 0.1.2)页面:Gitea Actions Helm chart for Kubernetes,Quick-start 即下面三步的 helm repo add / helm show values / helm upgrade --install 三步

先获取 Runner Token(与 3.2 相同)

方式和 3.2 一样:在 Gitea 侧添加 Runner(实例 / 组织 / 仓库三级的 Actions 设置里都可以),页面会给出注册 token。区别只有一处:token 不再写进配置文件,而是放进 Secret。

再创建 Secret

把下面内容存成token用占位符,真值以 Gitea 界面给出的为准),hub为命名空间:

ubuntu@nuc-node:~$ kubectl create secret generic runner-secret \
  --from-literal=runner-token=jmOxTd7nNfuCfwDoLqDFawbcmO9Y9Ztt3oGPOIOX -n hub
secret/runner-secret created
ubuntu@nuc-node:~$

容易踩的坑:Secret 的 namespace 必须和 chart 部署的 namespace 一致。Secret 是“只认本命名空间“的对象,两边不一致,Runner 就读不到 token,注册会一直重试。

添加 chart 仓库,拉默认 values 模板

helm repo add gitea-charts https://dl.gitea.com/charts/ && helm repo update  # 加官方 chart 仓库并刷新索引
helm show values gitea-charts/actions > values.yaml                          # 把 chart 的默认 values.yaml 导到本地

为什么先拉模板再改?chart 的默认值很多,逐条手敲容易漏也容易错;导出完整模板后只改需要的字段,其余一律用默认值,最稳。

填写 values.yaml

下面只列要改的字段(其余保持模板默认值),每行都注释了“为什么这么设“:

@@ -1,7 +1,7 @@
-enabled: false
-existingSecret: ''
-existingSecretKey: ''
-giteaRootURL: ''
+enabled: true
+existingSecret: runner-secret
+existingSecretKey: runner-token
+giteaRootURL: http://gitea-http.hub.svc.cluster.local:3000
 global:
   imagePullSecrets: []
   imageRegistry: ''
@@ -48,6 +48,9 @@
         level: debug
       cache:
         enabled: false
+      runner:
+        labels:
+          - "ubuntu-host:docker://mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest"
       container:
         require_docker: true
         docker_timeout: 300s

部署

helm install --labels=catalog.cattle.io/cluster-repo-name=gitea-charts --namespace=hub --timeout=10m0s --values=/home/shell/helm/values-actions-0.1.2.yaml --version=0.1.2 --wait=true action /home/shell/helm/actions-0.1.2.tgz
level=WARN msg="--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher"
NAME: action
LAST DEPLOYED: Wed Oct 28 07:52:44 2026
NAMESPACE: hub
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
---------------------------------------------------------------------
SUCCESS: helm install --labels=catalog.cattle.io/cluster-repo-name=gitea-charts --namespace=hub --timeout=10m0s --values=/home/shell/helm/values-actions-0.1.2.yaml --version=0.1.2 --wait=true action /home/shell/helm/actions-0.1.2.tgz
---------------------------------------------------------------------
  • 部署成功可以查看日志得到
time="2026-10-28T08:25:23Z" level=info msg="Log level debug requested, setting up report caller for further debugging" func="[initLogging]" file="[daemon.go:236]"
time="2026-10-28T08:25:23Z" level=info msg="log level set to debug" func="[initLogging]" file="[daemon.go:240]"
time="2026-10-28T08:25:23Z" level=info msg="Starting runner daemon" func="[func5]" file="[daemon.go:42]"
time="2026-10-28T08:25:23Z" level=info msg="Docker is ready" func="[func5]" file="[daemon.go:86]"
time="2026-10-28T08:25:23Z" level=info msg="labels updated to: [ubuntu-host:docker://mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest]" func="[func5]" file="[daemon.go:127]"
time="2026-10-28T08:25:23Z" level=info msg="runner: action-runner-0, with version: v2.0.1, with labels: [ubuntu-host], declare successfully" func="[func5]" file="[daemon.go:148]"

alt text

图 6:Gitea「dev-sec-ops」组织 → 设置 → 工作流 → 运行器:action-runner-0(ID 2、v2.0.1、组织级、标签 ubuntu-host)状态空闲——Helm 部署的 Runner 已注册并在线

# .gitea/workflows/test.yaml(放法与 6 章相同)
name: test
on: [workflow_dispatch]        # 手动触发
jobs:
  hello:
    runs-on: ubuntu-host       # 必须与本集群 Runner 注册的 label 一致
    steps:
      - run: echo "hello from k3s runner"

runs-on 的值必须和本集群 Runner 的注册 label 对上:注册时声明了与 3.2 相同的 ubuntu-host label(values 模板里有对应 label 参数,以模板为准),就能直接复用第 4 章的内网任务镜像;若注册的是 chart 默认 label,就把这一行改成对应 label,任务镜像用该 label 指向的镜像。跑通即说明“Gitea 派单 → 集群内 Runner 领单 → 任务容器干活“整条链路可用。

4 任务容器镜像:自建一个“ubuntu-latest“

  • Runner 只负责把任务容器起起来,容器里装了什么工具由镜像决定。
  • GitHub 上流水线常见 runs-on: ubuntu-latest,用的是 GitHub 官方维护的镜像;内网没有这个源,所以我自己造了一个功能对等的镜像,推上 Harbor,标签里指向它。

任务镜像 runner-images:ubuntu-latest 里预埋的东西(都是“流水线干活要用、但内网拉不到“的):

工具为什么任务里需要怎么来的
Docker CLI任务里要 docker login / docker buildx build拷贝 Runner 宿主机的二进制(29.8.2,与 daemon 同版本,最稳)
buildx 插件增强版 Docker 构建(多阶段、多平台、buildkit 缓存)宿主机 Go 1.27.1 源码编译(v0.38.0)
Go 工具链Go 项目要 go mod/go buildgolang 基础镜像自带(1.25)
JRESonarScanner 扫描要 Java 运行时预装
curl / wget / git / openssl下载、克隆、证书基础镜像自带
sudo(shim)不少 action 脚本无脑 sudo xxx用 exec "$@" 直通 shim 兜底

两个“为什么这么麻烦“的解释:

  • 为什么 Docker CLI 不自己装,而要拷宿主机的? 任务容器里跑 docker buildx build,最终是连到宿主机 Docker 守护进程干活的。daemon 是宿主机上的,客户端和 daemon 版本差太多可能出兼容问题,最稳的就是“同一个机器、同一个版本“。试过在容器里 go install 最新 docker CLI,但它依赖要 Go ≥ 1.26,容器里只有 1.25,编译不过,直接拷二进制最干脆。buildx 同理:最新版 v0.38.0 也要 Go ≥ 1.26,容器编不了,就到宿主机(Arch,Go 1.27.1)上源码编译好再放进去。
  • 为什么 buildx 编译要吃一次 GOPROXY? 编译 buildx 要拉一堆 Go 依赖,内网到不了 proxy.golang.org,所以走 Nexus 的 goproxy(后面会说到),这也提前埋下“构建环境也要接 Nexus“这条线。

这里踩过一个很隐蔽的坑:这台 docker CLI 不加载 cli-plugins。buildx 的设计是“docker CLI 的插件“(放进 ~/.docker/cli-plugins/ 就能 docker buildx 调用),但本环境这套 docker 29.8.2 无论如何不加载插件。解法是写一个 wrapper 脚本放 /usr/local/bin/docker(PATH 最前):遇到 buildx 子命令转给独立编译的 buildx 二进制,其余子命令原样透传给真正的 docker CLI。

构建并推到一个私有 project(robot 账号登录,见Harbor 一节):

docker build -t mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest .
echo "$MIRROR_TOKEN" | docker login mirror.kali-team.internal -u "$MIRROR_USER" --password-stdin
docker push mirror.kali-team.internal/dev-sec-ops/gitea/runner-images:ubuntu-latest

5 自托管 Actions:把常用 action 搬上 Gitea

5.1 什么是 Action,为什么要自托管

GitHub/Gitea 流水线里,一步经常写成:

- uses: actions/checkout@v4

这叫 Action:一个可复用的“工具包“,别人把“克隆代码““登录镜像仓库”“构建镜像“这些通用操作封装好,你用一行就引用进来。

但 Action 本质是“别人写的、会在你的流水线里执行的代码“,这就是著名的供应链攻击面:引用外部 Action = 允许别人写的代码在你的 CI 环境里跑(能读到你仓库的 Secrets)。内网场景更现实:任务容器根本访问不了 github.com,uses: actions/checkout@v4 直接拉不到。

解法干脆:把需要的 Action 自己实现一份,托管在自己的 Gitea 上。我在 Gitea 建了 actions 组织,将常用的action镜像下来:

仓库标签干什么
actions/checkoutv4克隆当前仓库到工作区
actions/login-actionv2docker login 登录 Harbor
actions/setup-qemu-actionv3注册 QEMU binfmt(多架构构建用;单架构自动跳过)
actions/cachev4缓存占位(本机场景由 buildkit 缓存挂载替代,见 6)
actions/setup-buildx-actionv5设置/选择 buildx builder
actions/build-push-actionv6docker buildx build 构建并推送镜像
actions/sonarqube-scan-actionv9从 Nexus 下载 SonarScanner 并执行扫描

6 写流水线(一):docker.yaml,从提交到镜像

6.1 概念铺垫:buildx / buildkit 与多阶段构建

  • buildx / buildkit:Docker 官方构建引擎的“增强版“。docker buildx 是客户端插件,buildkit 是真正干活的引擎,支持多阶段构建、多平台构建、分层缓存。buildx 有几种驱动,我们选 docker 驱动(直接用 Docker 守护进程内置的 buildkit,零额外镜像),因为内网镜像源里没有 moby/buildkit 镜像(这是一个“被迫“的取舍,见 9)。
  • 多阶段构建:Dockerfile 里写多个 FROM(阶段),构建时每个阶段只用自己和前一阶段的产物,最终镜像可以很小很干净。

6.2 触发条件(on: 段)

on:
  workflow_dispatch:        # 网页上手动点"运行",带输入
    inputs:
      build_server:
        description: 'Build server'
        default: false
        type: boolean       # 是否构建 server 服务
      version:
        description: 'Version tag'
        default: 'latest'   # 打的镜像 tag
      watchtower:
        description: '触发 Watchtower 更新'
        default: false
        type: boolean
  push:
    tags:
      - "v*"                # 打 v 开头的 git tag(如 v1.0.0)时自动触发
  repository_dispatch:      # 通过 API 发事件触发(外部系统联动用)

三种触发方式对应三种使用场景:网页手动(调试/临时发版)、打 tag 推送(正式发版)、API 触发(跟其它系统联动)。

以 dev-sec-ops/example 仓库为例,clone 地址走 SSH 公钥认证的 32222 端口(SSH 的 NodePort):

dev-sec-ops/example 仓库页面

图 7:dev-sec-ops/example 仓库页面,克隆地址使用 SSH 公钥认证的 32222 端口(SSH 的 NodePort)

6.3 Job 1:setup——“该打什么 tag“由一个轻 job 算出来

jobs:
  setup:
    runs-on: ubuntu-host          # 找带 ubuntu-host 标签的 Runner(就是我们的 Runner)
    outputs:
      version: ${{ steps.get-version.outputs.version }}
    steps:
      - name: Get version
        id: get-version
        run: |
          if [[ "${{ github.event_name }}" == "push" ]] && [[ "${{ github.ref }}" == refs/tags/v* ]]; then
            VERSION=${GITHUB_REF#refs/tags/}   # 打 tag → 版本号 = tag 名(去掉 refs/tags/ 前缀)
          else
            VERSION="${{ github.event.inputs.version }}"   # 手动触发 → 用输入框填的
          fi
          echo "version=${VERSION}" >> $GITHUB_OUTPUT   # 写进 $GITHUB_OUTPUT 才成为 step 输出
  • ${{ }} 是 Actions 的表达式;github.event_name / github.ref / github.event.inputs.* 是 2 说的“事件上下文“。
  • $GITHUB_OUTPUT 是 Runner 提供的一个文件,步骤往里写 key=value,后续步骤/ job 就能引用,git 版本管理、镜像 tag 这类跨步骤传递的“临时变量“都靠它。
  • outputs 段把 step 的输出“上提“成整个 job 的输出,供下一个 job 用。

6.4 Job 2:build-and-push——真正干活的环节

先有一个条件判断(if:),决定这条 job 什么时候跑:

  build-and-push:
    runs-on: ubuntu-host
    needs: setup                            # 等 setup 完成(依赖顺序)
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        include:
          - service: "server"
            build_input: "build_server"
    # 只有三种情况才构建 server:打 tag 推送、repository_dispatch、手动触发且 build_server=true
    if: |
      (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
      (github.event_name == 'repository_dispatch') ||
      (github.event_name == 'workflow_dispatch' && (github.event.inputs.build_server == 'true' || github.event.inputs.build_server == true))

然后是执行步骤,按顺序拆:

第 1 步 Init:把 Gitea 的证书装进系统信任库

echo -n | openssl s_client -showcerts -connect hub.kali-team.internal:443 2>/dev/null \
  | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' >> hub.kali-team.internal.crt
sudo cp hub.kali-team.internal.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates

用 openssl s_client 把 Gitea 的证书链拉下来,装进 update-ca-certificates 管的系统信任库。为什么?后面的 checkout 走 https 克隆,任务容器必须信任内网 CA 才能握手成功。

第 2 步 Checkout code:克隆代码

      - name: Checkout code
        uses: https://hub.kali-team.internal/actions/checkout@v4
        env:
          GIT_SSL_NO_VERIFY: true
        with:
          submodules: true
          fetch-depth: 0
          token: ${{ secrets.TOKEN }}   # 访问仓库的 Gitea 访问令牌(scopes=all)

用 5 节自建的 checkout action 克隆代码。token 是仓库 Secret 里存的访问令牌,克隆时认证用。fetch-depth: 0 拉完整历史(扫描/构建可能要看全量 commit)。

第 3 步 Verify Certificate:信任 dev-sec-ops 证书 + 注入 docker 客户端证书目录

sudo cp kali-team-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
sudo mkdir -p /etc/docker/certs.d/mirror.kali-team.internal
sudo cp kali-team-ca.crt /etc/docker/certs.d/mirror.kali-team.internal/ca.crt

最后两行是内网 Docker 的经典细节:Docker 客户端有个约定,/etc/docker/certs.d/<registry 域名>/ca.crt 会作为访问该 registry 的额外 CA。这样 docker login/push mirror.kali-team.internal 就算忘了全局信任,也能通过证书校验。

第 4 步 Login to Harbor:登录镜像仓库

      - name: Login to Harbor Registry 登录Harbor Registry
        uses: https://hub.kali-team.internal/actions/login-action@v2
        with:
          registry: https://mirror.kali-team.internal
          username: ${{ secrets.MIRROR_USER }}
          password: ${{ secrets.MIRROR_TOKEN }}

MIRROR_USER / MIRROR_TOKEN 是 Harbor 的 robot 账号(8 讲)。登录成功后凭据写进任务容器的 ~/.docker/config.json,后面的 push 自动带上。

第 5 步 Set up QEMU:注册 binfmt(多架构预留,本环境跳过)

      - name: Set up QEMU
        uses: https://hub.kali-team.internal/actions/setup-qemu-action@v3
        with:
          image: mirror.kali-team.internal/docker.io/tonistiigi/binfmt:latest
  • QEMU 是 CPU 模拟器(让 amd64 机器模拟 arm64 指令);binfmt_misc 是内核机制“看到某类文件就交给某解释器“。注册 qemu-aarch64 后,内核就能在 amd64 上“执行“ arm64 二进制,多架构构建才用得上。
  • 本流水线只构 linux/amd64(单架构),且注册 binfmt 需要特权容器,我们的 Runner 没开 privileged,所以 action 里容错成“注册失败只打 warning 不中断“。

第 6 步 Go Build Cache for Docker:缓存(占位)

      - name: Go Build Cache for Docker
        uses: https://hub.kali-team.internal/actions/cache@v4
        with:
          path: /tmp/.buildx-cache-${{ runner.os }}-buildx-${{ matrix.service }}
          key: ${{ runner.os }}-buildx-${{ matrix.service }}-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-buildx-${{ matrix.service }}-
            ${{ runner.os }}-buildx-

GitHub 上这步会把 path 目录按 key 存到中心缓存服务。本环境 Runner 没有中心缓存服务,且任务容器的 /tmp 不跨任务保留,所以 cache@v4 实现成“建目录 + 打日志“的占位。真正的跨任务缓存被“烤“进了镜像,见 6.6 的 gomod-cache。

第 7 步 Set up Docker Buildx

      - name: Set up Docker Buildx
        uses: https://hub.kali-team.internal/actions/setup-buildx-action@v5
        with:
          config-inline: |
            [registry."mirror.kali-team.internal"]
              insecure = true  # buildkitd 官方镜像不含内网 CA,对 mirror 跳过 TLS 校验
  • docker 驱动下 buildkit 跑在 Docker 守护进程里,所以这段内联配置实际上不生效(TLS 信任靠 Verify Certificate 步骤注入的 /etc/docker/certs.d/ CA)。保留它只是对齐上游 action 的接口。
  • v5 的行为是 driver=docker 时不新建 builder,直接 docker buildx use default,因为 docker 驱动是“单例“,守护进程自带的 default builder 删不掉、再建会报错(详见 9 坑 #5)。

第 8 步 Build and push:核心构建

      - name: Build and push Docker image for ${{ matrix.service }}
        uses: https://hub.kali-team.internal/actions/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: true
          build-args: |
            BUILDKIT_INLINE_CACHE=1
          target: ${{ matrix.service }}
          tags: |
            mirror.kali-team.internal/dev-sec-ops/example-${{ matrix.service }}:${{ needs.setup.outputs.version }}
            mirror.kali-team.internal/dev-sec-ops/example-${{ matrix.service }}:latest
          cache-from: type=local,src=/tmp/.buildx-cache-${{ runner.os }}-buildx-${{ matrix.service }}
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
  • target 指的是 Dockerfile 里某一个“阶段“(配合多阶段构建,只构建 server 这一个最终镜像);tags 打两个 tag(版本号 + latest);cache-from/to 是 buildkit 的本地目录缓存。
  • 这里有个实测要澄清的细节(9 坑 #7):docker 驱动下 buildkit 跑在宿主机,type=local 的路径指宿主机文件系统;而流水线的 Move cache 步骤跑在任务容器里,两边对不上,所以这套 cache-from/to 实际不跨任务生效(Move cache 带了 continue-on-error: true 不会挂)。真正跨任务缓存由 gomod-cache 镜像承担(6.6)。

第 9 步 Inspect image:确认推送成功

      - name: Inspect image
        run: |
          docker buildx imagetools inspect mirror.kali-team.internal/dev-sec-ops/example-${{ matrix.service }}:${{ needs.setup.outputs.version }}

imagetools inspect 不去拉镜像,只向 registry 要 manifest(镜像的“目录清单“),能打印出来就证明镜像确实在 Harbor 里。

最后两步 Move cache 与 Trigger Watchtower:前者是“搬缓存目录“(带 continue-on-error,作用见第 8 步的澄清);后者是可选“自动更新“。Watchtower 是一个“容器看门狗“镜像,发现 registry 有新镜像就自动拉新、替换、重启。这里给它一个 HTTP API,构建完可选地通知它“去更新“。本环境暂未部署 Watchtower,所以这步只在手动触发且勾选 watchtower 时才执行。

6.5 对应的 Dockerfile:多阶段

# 构建阶段:golang 镜像里编译 Go 程序;依赖走 Nexus goproxy(7 讲)
FROM --platform=$TARGETPLATFORM mirror.kali-team.internal/docker.io/golang:1.25-bookworm AS builder
WORKDIR /app
ENV GOPROXY="https://nexus.kali-team.internal/repository/goproxy/" \
    GOSUMDB="sum.golang.org https://nexus.kali-team.internal/repository/go-sumdb/" \
    CGO_ENABLED=0
COPY kali-team-ca.crt /usr/local/share/ca-certificates/kali-team-ca.crt
RUN update-ca-certificates
COPY go.mod go.sum ./
# buildkit(docker 驱动)禁止 RUN --network=host,构建容器又解析不了内网域名(见 9 坑 #6);
# 所以在能联网的环境预先把 go mod download 的产物烤进 gomod-cache 镜像(推到 Harbor),
# 这里用 COPY --from 把依赖缓存和编译缓存"搬"进来
COPY --from=mirror.kali-team.internal/dev-sec-ops/gitea/gomod-cache:go1.25-gin1.10 \
    /go/pkg/mod /go/pkg/mod
COPY --from=mirror.kali-team.internal/dev-sec-ops/gitea/gomod-cache:go1.25-gin1.10 \
    /root/.cache/go-build /root/.cache/go-build
RUN go mod download
COPY main.go .
RUN go build -ldflags '-s -w' -trimpath -o /app/example .

# 运行时基础:精简 debian + 手动信任内网 CA
# (bookworm 精简镜像没有 update-ca-certificates,用 openssl x509 -hash 预算 subject hash 建软链)
FROM --platform=$TARGETPLATFORM mirror.kali-team.internal/docker.io/library/debian:bookworm AS runtime-base
WORKDIR /app
COPY kali-team-ca.crt /etc/ssl/certs/kali-team-ca.crt
RUN ln -s kali-team-ca.crt /etc/ssl/certs/6ecaa1df.0 && \
    ln -s kali-team-ca.crt /etc/ssl/certs/15763dfa.0
# 非 root 运行用户(安全基线:容器内进程不跑 root)
RUN groupadd -g 10001 app && useradd -u 10001 -g 10001 -d /app -s /usr/sbin/nologin app
LABEL com.centurylinklabs.watchtower.enable="true"

# 应用阶段:只从 builder 拷一个静态二进制
FROM runtime-base AS server
WORKDIR /app
USER app
COPY --from=builder /app/example /app/example
EXPOSE 8080
ENTRYPOINT ["/app/example"]

6.6 为什么要有 gomod-cache 镜像:把“构建期网络“整个拿掉

  • 于是 go mod download 退化成“离线校验一下缓存是否齐全“的 no-op,构建 100% 不依赖构建期网络。
  • 代价:依赖变了要重新“烤“一次这个镜像(多维护一张缓存镜像)。好处:构建过程彻底离线、稳定可复现。这是内网 CI 的经典 trade-off(9 坑 #6 展开)。

7 实测验证:三条流水线跑起来

本节数据与截图来自真实运行(2026-10-15 起)。

docker.yaml 流水线运行成功

图 10:Gitea Actions 里 docker.yaml 的运行记录(run #6,手动触发,main 分支),状态「成功」,总耗时 1m25s,setup 与 build-and-push 两个 job 均为 success

第二个,Harbor 出现镜像:dev-sec-ops project 的 Repositories 页出现三行。example-server(本次流水线推的,tags 1.0.0 和 latest,指向同一 digest sha256:4cd55…e3513,约 49.6 MiB,2026-10-15 14:00 UTC 推送;digest 是镜像内容的“指纹“,内容相同指纹就相同,用来精确指认一个镜像)、gitea/gomod-cache(构建期缓存镜像)、gitea/runner-images(任务镜像):

Harbor dev-sec-ops 项目镜像

图 11:Harbor 的 dev-sec-ops 私有 project:流水线推送的 example-server 与自建的 runner-images、gomod-cache 都在

第三个,sonarqube.yaml(run #8,成功):sonarqube job 耗时 21s、状态 success。SonarQube example 项目(显示名「项目例子」)出现扫描记录:Quality Gate Passed,61 行代码,Security / Reliability / Maintainability 三个维度 open issues 为 0 / 0 / 1(评级 A),Coverage 0.0%(示例项目没写单测,符合预期),最后分析 2026-10-15 14:12 UTC:

SonarQube 项目扫描结果

图 12:SonarQube 上 example 项目(显示名「项目例子」)的 Overview:Quality Gate Passed,三个维度的未关闭问题数与评级

后续

  • 给 SonarQube 配 quality gate 卡流水线(扫描不过不许发)
  • 多架构(arm64,需特权 binfmt)
  • 制品发布(Nexus 存二进制/Helm chart)
  • 流水线日志归档与告警
  • 把 DT 的 SCA 真正接进每日发版(见《安全开发-DependencyTrack》篇)

参考