触发边界
PR 先检查,main 才更新服务器
持续集成(Continuous Integration,CI)是在提交变化时自动做检查;自动部署则把检查过的版本送进服务环境。我们需要它们,是为了让每次修改都走相同的流程,也让你和 agent 能从日志判断交付停在哪一步。
第 7 章的 CI 页已经介绍工作流(workflow,一份描述自动任务的配置文件)。这里用 Forgejo Actions:平台接收事件,runner(实际执行任务的机器或容器)运行 job(一组顺序执行的步骤)。平台保存代码,runner 临时执行命令,服务器长期运行应用,三者各有自己的环境。
你刚向一个以 main 为目标的 PR 推送修改,测试已通过。此时服务器应该发生什么?
服务器保持旧版本。PR 检查为审查提供证据;合并会触发 main 的 push,新一轮检查成功后才进入部署步骤。这样部署对象总能对应一个 main 上的提交。
检查环境
CI 在镜像里测试,交付对象才明确
本例把提交 SHA(Git 给提交的完整标识)作为镜像标签。runner 先构建,再在这个镜像里运行单元测试;只要退出码非零,当前步骤就失败,后面的部署不会执行。服务器收到的是这一份已测镜像,因此不需要重新安装应用依赖。
准备一个你能管理的练习仓库,启用 Actions,并确认 runner 与云服务器的 CPU 架构相同,例如都为 amd64。runner 需要下载源码、checkout action 和 Ubuntu 基础镜像,也要能访问软件源;它的网络与代理设置不会自动继承你的电脑。
应用接口对齐:已有项目怎么接?没有示例时怎么补齐?
以下最小应用用于独立复现:仓库根目录有 app.py、test_app.py、Dockerfile。应用监听容器内的 8000,测试命令是 python3 -m unittest -v。已有应用可保留源码与数据卷,只需按自己的测试命令、服务名和端口修改本页配置。
app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
def message():
return b"Hello from Linux\n"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(message())
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
test_app.py
import unittest
from app import message
class MessageTest(unittest.TestCase):
def test_message(self):
self.assertEqual(message(), b"Hello from Linux\n")
Dockerfile
FROM ubuntu:26.04
RUN apt-get update && apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app.py test_app.py ./
EXPOSE 8000
CMD ["python3", "app.py"]
这里用 Ubuntu 26.04 基础镜像安装 Python,不声明具体 Python 版本。标签与软件源会更新,所以以后重新构建同一提交可能得到不同镜像;本轮部署通过传送镜像避免服务器再次构建。
在项目根目录创建 .forgejo/workflows/deploy.yaml,先写下面这一段。on 描述触发事件,runs-on 选择执行环境,steps 按顺序运行。它同时接收 PR 和 main 的 push,部署条件稍后接在同一个 job 里。
name: check-and-deploy
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: linux-demo-${{ forgejo.ref }}
cancel-in-progress: false
jobs:
delivery:
runs-on: ci-docker
steps:
- name: 取出本次代码
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: 构建并测试镜像
env:
REV: ${{ forgejo.sha }}
run: |
set -eu
IMAGE="linux-demo:$REV"
docker build -t "$IMAGE" .
docker run --rm --entrypoint python3 "$IMAGE" -m unittest -v
concurrency 给同一分支的任务分组,cancel-in-progress: false 让正在部署的任务完成。checkout 固定到官方 v4.2.2 的提交,避免 action 标签移动;它负责取出事件对应的代码,不是在 runner 上执行一次普通的“拉取最新 main”。
PR 已经通过测试,合并 main 后为什么还要构建和测试?
服务器运行镜像;分支名只决定事件是否进入部署。
本例的 PR 会真实运行容器里的单元测试。
合并可能组合了其他提交。重新检查 main 事件对应的提交,并以它的 SHA 标记镜像,才能把检查结果与部署对象对应起来。
身份边界
私钥进 secrets,公钥确认连接两端
部署步骤需要回答两件事:“服务器是否接受这个部署身份?”和“连接的是否是预期服务器?”部署私钥证明调用者身份,服务器主机公钥确认连接目的地。把它们分开放,连接双方的权限与身份才清楚。
调用者的身份
目的地的身份
- 沿用实战一的 SSH 方法,为自动部署单独生成一对密钥。把公钥加入专用部署用户的
~/.ssh/authorized_keys,目录权限为700、文件为600;保留个人登录密钥用于维护。 - 在仓库的 Actions secrets 中填写
DEPLOY_KEY(完整私钥)、DEPLOY_HOST(域名或 IP)和DEPLOY_USER(已有部署用户)。本例按 SSH 端口 22 写;自定义端口需同步改配置。 - 用你已信任的服务器控制台或 SSH 会话核对主机公钥指纹,再把对应的
known_hosts完整记录放进DEPLOY_KNOWN_HOSTS。记录必须匹配DEPLOY_HOST;只运行ssh-keyscan收集记录不等于核实身份。 - 确认部署用户能写自己的家目录、能非交互运行 Docker 与 Compose,并且服务器有
bash和flock(文件锁工具)。Docker daemon 的控制权限很大,请在练习云主机上配置;安装软件、调整用户权限等操作建议在虚拟机或容器里试。
把这四份资料放到各自应该在的位置。
- 部署私钥 DEPLOY_KEY
- 部署公钥 authorized_keys
- 已核实的 DEPLOY_KNOWN_HOSTS
- workflow 中的 secrets 名称引用
仓库保存可审查的流程,平台保存任务凭据,服务器保存允许登录的公钥。known_hosts 记录本身可以公开,但其来源必须可信。
多人协作时,runner 与 secrets 的边界怎么定?
本例适用于自己管理、信任协作者的练习仓库。Fork PR 默认取不到 secrets;同仓库写入者可能修改 workflow,所以“部署步骤有 if”不能替代权限管理。启用 main 分支保护,要求审查与 delivery 检查通过后合并。不要用 pull_request_target 执行 PR 代码。
构建任务使用独立于生产服务器的 runner 与 Docker daemon;每个任务的临时目录也要隔开。把构建 Docker socket 接到生产主机,会把构建权限和服务管理权限放在同一边界内。更大团队可拆成独立检查与部署工作流,配置人工批准和更细的凭据权限。
交付机制
SSH 交付已测镜像,Compose 更新服务
这里选择 docker image save 把镜像装进 tar 归档,用 SCP(通过 SSH 传文件)发送,再由服务器 docker image load 装载。这样练习时不必先配置镜像仓库。服务器只接收部署文件和镜像,不需要拿着仓库密码去拉源码。
- PR 提交触发检查;此时只验证,不连服务器。猜一猜:合并后能直接复用这个检查结果吗?
- 合并到 main 会产生 push 事件,平台为实际要交付的代码重新运行任务。
- 构建出的镜像在容器里通过测试,并带上 main 提交的完整 SHA。下一步该传源码还是传这个镜像?
- 部署步骤才读取 secrets;SCP 把原镜像与 Compose 配置送到按 SHA 命名的发布目录。
- 服务器持有文件锁后装载镜像,Compose 使用稳定项目名替换 web 容器。
- 等待应用健康后才写入 current 版本记录。检查失败会令任务失败,不会自动回滚。
在仓库根目录新增 compose.ci.yaml。IMAGE 是 Compose 读取的环境变量;它选镜像,不会自动进入应用进程。健康检查是容器内定期发出的 HTTP 请求,能确认服务接受请求;单元测试则检查应用逻辑,两者解决不同问题。
services:
web:
image: ${IMAGE:?请设置已测试的镜像标签}
ports:
- "127.0.0.1:8080:8000"
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2).read()"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
本例只把端口发布到服务器的 127.0.0.1:8080,你可以沿用SSH 端口转发从电脑访问;已有反向代理也可连接它。改造上一页的应用时保留原来的项目名、服务名、端口与数据卷映射,先停掉占用该端口的练习容器,再切换管理方式;不要直接让两个 Compose 项目争用同一端口。
把下面这一段接在同一个 workflow 的 steps 末尾,与上面的“构建并测试镜像”同级。本例把检查和部署放在一个 job,镜像自然留在同一个 Docker daemon;若拆成多个 job,就必须额外传递镜像产物。
下面是待你在练习环境验证的完整部署步骤,不是本机执行记录。它会更新服务器容器;建议先在练习虚拟机或容器环境中验证。当前编写环境没有 Docker 和云服务器,因此不提供虚构的成功输出。
- name: 仅 main 检查成功后部署
if: ${{ success() && forgejo.event_name == 'push' && forgejo.ref == 'refs/heads/main' }}
env:
REV: ${{ forgejo.sha }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -eu
# 只接受提交散列;后面会把它作为远程目录名。
case "$REV" in ''|*[!0-9a-f]*) exit 1 ;; esac
IMAGE="linux-demo:$REV"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
umask 077
printf '%s\n' "$DEPLOY_KEY" > "$tmp/key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
cat > "$tmp/config" <<EOF
Host production
HostName $DEPLOY_HOST
User $DEPLOY_USER
Port 22
IdentityFile $tmp/key
IdentitiesOnly yes
BatchMode yes
StrictHostKeyChecking yes
UserKnownHostsFile $tmp/known_hosts
EOF
docker image save -o "$tmp/image.tar" "$IMAGE"
ssh -F "$tmp/config" production "mkdir -p ci-releases/$REV"
scp -F "$tmp/config" "$tmp/image.tar" compose.ci.yaml \
"production:ci-releases/$REV/"
ssh -F "$tmp/config" production "bash -se -- $REV" <<'REMOTE'
set -eu
REV=$1
exec 9>"$HOME/.linux-demo.lock"
flock -x 9
cd "$HOME/ci-releases/$REV"
docker image load -i image.tar
export IMAGE="linux-demo:$REV"
docker compose -p linux-demo -f compose.ci.yaml up \
-d --no-build --pull never --wait --wait-timeout 60
docker compose -p linux-demo -f compose.ci.yaml ps
printf '%s\n' "$REV" > "$HOME/ci-releases/current"
rm image.tar
REMOTE
success() 加事件与分支条件,让测试失败、PR、其他分支都不能进入这一步。密钥临时文件用 umask 077 限制访问,任务退出时清理;不要开启会打印命令参数的 set -x。主机身份严格核对,记录不匹配时先人工查明原因。
为何目录每次不同,Compose 项目名却固定?
ci-releases/<SHA> 保存每版配置;-p linux-demo 固定服务归属,让下一次 up 更新同一个项目。省略项目名可能让不同发布目录变成不同项目。--no-build --pull never 则要求使用刚装载的镜像。
平台 concurrency 尽力让同分支任务串行;服务器文件锁进一步避免两个部署同时替换容器。但锁不保证“最新提交一定最后运行”,手动重跑旧任务仍可能发布旧版。这里是单服务器练习,不承诺零停机、自动回滚或数据库迁移;保留旧镜像与发布目录供人工恢复。
验收与排障
任务成功后,还要核对运行版本与健康
先新建分支,修改应用并同步测试中的预期值,commit、push、开 PR。你应看到构建与测试通过而部署步骤被跳过;故意只改测试的预期值再推一次,应看到测试失败,服务器仍不更新。修好并合并后,main 的检查与部署才会完整执行。
在 Actions 页面记下 main 的完整 SHA,再从已有 SSH 会话进入服务器。下面的 REV 要换成你刚记下的值;它应与 current 和容器的镜像标签相同。健康状态应为 healthy,HTTP 响应应是这次修改后的内容。
CI 构建了 linux-demo:<本次 SHA>。要让 Compose 使用这个标签,应该设置哪个变量?
= linux-demo:<本次 SHA>
本页 Compose 的 image 字段引用 IMAGE。工作流给它赋值后,服务器选中已测试的镜像;它与平台 secrets、应用自己的环境变量是不同用途。
| 停在哪里 | 优先核对什么 | 服务器可能处于什么状态 |
|---|---|---|
| 任务排队或 docker 不可用 | runner 标签、Docker daemon 与网络 | 旧版仍运行 |
| 构建或单元测试失败 | 首次非零退出的步骤、依赖和测试日志 | 部署未执行 |
| SSH 身份核对失败 | DEPLOY_HOST、可信主机公钥、部署公钥 | 尚未更新 |
| up 或健康检查失败 | Compose ps 与 logs、端口、磁盘与内存 | 可能已被替换,必须检查 |
如果最后一行失败,在该发布目录运行 docker compose -p linux-demo -f compose.ci.yaml logs --tail 50 web。需要恢复时,从已验收记录选择旧 SHA,在旧发布目录设置对应的 IMAGE,重跑相同的 up 命令并验收,再修正 current;前提是旧镜像仍在。它只能恢复应用版本,不能撤销数据库或数据卷里的改动。
main 的测试通过,但 SSH 之后的健康检查失败。你能确定什么?
up 可能已经替换容器,之后才等到健康检查失败。
本例没有自动回滚步骤,失败不会自动恢复旧版。
测试证明镜像通过了指定逻辑检查;健康检查证明它在服务器上能接受请求。后者失败时发布不能算成功,current 也不会被写成新版本,但运行中的容器仍需人工核实。
全书回顾
12 章一起回答“这份代码在哪个环境里运行”
从最初的 Shell 到现在的自动交付,贯穿全书的是环境:文件在哪里,进程继承什么,网络通向哪里,以谁的身份执行。下面把 12 章放回真实链路;章节编号保留学习顺序,位置表示它们在交付中的职责。
本地准备环境
改代码 → commit → push
平台检查环境
接收提交 → 构建 → 测试
agent 帮你修改了代码,PR 通过了检查,但服务器内容没变。用“事件、镜像、运行环境”解释你会先查哪三件事。
先查 PR 是否已合并并触发 main 的 push;再查 main 的镜像构建、测试和 SSH 部署步骤是否成功;最后到服务器核对镜像 SHA、健康状态与实际响应。只看到 PR 通过,还不能推断服务器已经更新。
- 区分 PR 检查与 main 的部署事件
- 把已测镜像对应到 main 的提交 SHA
- 到服务器验证运行版本,而不只看平台状态
- workflow 描述流程,runner 执行检查,服务器持续运行服务。
- 把部署绑定到 main 的成功检查,并传送同一份已测镜像。
- 凭据按身份分别放置,环境与权限边界要清楚。
- 发布要验证版本和健康;失败后的运行状态要实际观察。
回到实战二对照手动步骤,你已经能判断哪些步骤适合交给 CI,也能和 agent 一起从某个具体环境继续排障。
本页流程与边界依据官方文档
- Forgejo Actions Reference:触发器、上下文、步骤条件与 concurrency。
- Forgejo Actions Security:fork PR、runner 与 secrets 边界;Utilizing Docker within Actions:构建任务如何取得 Docker 访问能力。
- actions/checkout v4.2.2 官方发布记录:固定 action 的提交标识。
- Docker image save 与 image load:通过归档传递原镜像。
- Docker Compose up 与 Compose services:image、端口、healthcheck 与等待健康。
- Ubuntu Server:OpenSSH 与 ssh_config(5):密钥认证、IdentityFile、known_hosts 与 StrictHostKeyChecking。