← Linux 入门

12 综合实战 · 03 自动交付

配上 CI,推送后自动检查与部署

把上一页手动完成的部署,变成有检查、有版本记录的一条交付链:PR 先验证,合并到 main 后才更新服务器。

约 15 分钟 · 截至 2026-11-06,Ubuntu 26.04 LTS · Docker 与远程部署未实测

本地 · 工作区改代码 → commitpush 到代码平台第 1–5、7 章CI · 临时环境PR:构建 + 测试main:检查后才部署第 6、7、10 章云服务器 · 持久环境SSH 收到已测镜像Compose 更新容器第 8、9、11 章提交SSH 传送第 12 章串联:提交 SHA → 镜像标签 → 正在运行的版本 本地 · 工作区改代码 → commit → push第 1–5、7 章提交CI · 临时环境PR:构建 + 测试main:检查后部署 · 第 6、7、10 章SSH 传送镜像云服务器 · 持久环境Compose 更新已测镜像第 8、9、11 章第 12 章:把整条交付链路接起来
  • 代码与提交决定要交付哪一版
  • CI 临时环境构建并验证
  • 服务器运行环境持续提供服务
图 1 提交、已测镜像和运行容器沿同一条链路对应起来,第 12 章把前面各章连成一次交付。

学完你能

触发边界

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,公钥确认连接两端

部署步骤需要回答两件事:“服务器是否接受这个部署身份?”和“连接的是否是预期服务器?”部署私钥证明调用者身份,服务器主机公钥确认连接目的地。把它们分开放,连接双方的权限与身份才清楚。

调用者的身份

runner:secrets → 私钥SSH 验证签名服务器:authorized_keys → 公钥

目的地的身份

服务器:出示主机公钥与预存记录比对runner:known_hosts → 可信记录
图 2 部署密钥验证“谁来连接”,主机公钥验证“连到哪里”。
  1. 沿用实战一的 SSH 方法,为自动部署单独生成一对密钥。把公钥加入专用部署用户的 ~/.ssh/authorized_keys,目录权限为 700、文件为 600;保留个人登录密钥用于维护。
  2. 在仓库的 Actions secrets 中填写 DEPLOY_KEY(完整私钥)、DEPLOY_HOST(域名或 IP)和 DEPLOY_USER(已有部署用户)。本例按 SSH 端口 22 写;自定义端口需同步改配置。
  3. 用你已信任的服务器控制台或 SSH 会话核对主机公钥指纹,再把对应的 known_hosts 完整记录放进 DEPLOY_KNOWN_HOSTS。记录必须匹配 DEPLOY_HOST;只运行 ssh-keyscan 收集记录不等于核实身份。
  4. 确认部署用户能写自己的家目录、能非交互运行 Docker 与 Compose,并且服务器有 bash 和 flock(文件锁工具)。Docker daemon 的控制权限很大,请在练习云主机上配置;安装软件、调整用户权限等操作建议在虚拟机或容器里试。

把这四份资料放到各自应该在的位置。

  • 部署私钥 DEPLOY_KEY
  • 部署公钥 authorized_keys
  • 已核实的 DEPLOY_KNOWN_HOSTS
  • workflow 中的 secrets 名称引用
平台 secrets
服务器部署账号
Git 仓库

仓库保存可审查的流程,平台保存任务凭据,服务器保存允许登录的公钥。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 装载。这样练习时不必先配置镜像仓库。服务器只接收部署文件和镜像,不需要拿着仓库密码去拉源码。

本地runner服务器PR → 检查main pushimage.tar + SSHSSH 返回成功linux-demo:<SHA>镜像内测试通过load → up运行已测镜像时间 ↓current = SHA
  1. PR 提交触发检查;此时只验证,不连服务器。猜一猜:合并后能直接复用这个检查结果吗?
  2. 合并到 main 会产生 push 事件,平台为实际要交付的代码重新运行任务。
  3. 构建出的镜像在容器里通过测试,并带上 main 提交的完整 SHA。下一步该传源码还是传这个镜像?
  4. 部署步骤才读取 secrets;SCP 把原镜像与 Compose 配置送到按 SHA 命名的发布目录。
  5. 服务器持有文件锁后装载镜像,Compose 使用稳定项目名替换 web 容器。
  6. 等待应用健康后才写入 current 版本记录。检查失败会令任务失败,不会自动回滚。
图 3 一次发布保留提交标识、已测镜像与健康结果,三个证据共同说明交付到了哪里。

在仓库根目录新增 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 响应应是这次修改后的内容。

cat "$HOME/ci-releases/current"
REV=替换成刚部署的完整SHA
export IMAGE="linux-demo:$REV"
cd "$HOME/ci-releases/$REV"
docker compose -p linux-demo -f compose.ci.yaml ps
docker compose -p linux-demo -f compose.ci.yaml images
curl --fail http://127.0.0.1:8080/

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

  1. 01
    Linux 的结构

    谁执行命令、退出码为何能中断 CI

  2. 02
    目录与工作目录

    构建上下文与配置文件都从项目根目录找

  3. 03
    环境与 PATH

    步骤环境与远程 Shell 环境分别配置

  4. 04
    网络、代理与镜像源

    拉源码、装依赖与下载镜像各走各的网络

  5. 05
    软件安装与版本

    把依赖写进 Dockerfile,记录版本选择

平台检查环境

接收提交 → 构建 → 测试

  1. 06
    Docker

    同一份镜像从 CI 送到服务器

  2. 07
    Git 与协作

    PR 审查、workflow 和提交 SHA 对齐

  3. 10
    虚拟机与沙箱

    runner 的临时环境与服务环境各有边界

服务器运行环境

SSH 传送 → 更新 → 观察

  1. 08
    远程与云端

    部署账号、地址、SSH 与云实例

  2. 09
    加密与凭据

    私钥证明调用者,主机公钥确认目的地

  3. 11
    系统管理速查

    健康检查、容器日志与主机资源

12 综合实战把三处环境接起来:准备服务器 → Compose 跑应用 → CI 自动交付
图 4 本地准备、平台检查与服务器运行各有环境,第 12 章把 1–11 章的知识接成可观察的交付流程。

agent 帮你修改了代码,PR 通过了检查,但服务器内容没变。用“事件、镜像、运行环境”解释你会先查哪三件事。

先查 PR 是否已合并并触发 main 的 push;再查 main 的镜像构建、测试和 SSH 部署步骤是否成功;最后到服务器核对镜像 SHA、健康状态与实际响应。只看到 PR 通过,还不能推断服务器已经更新。

  • 区分 PR 检查与 main 的部署事件
  • 把已测镜像对应到 main 的提交 SHA
  • 到服务器验证运行版本,而不只看平台状态

回到实战二对照手动步骤,你已经能判断哪些步骤适合交给 CI,也能和 agent 一起从某个具体环境继续排障。

本页流程与边界依据官方文档

  1. Forgejo Actions Reference:触发器、上下文、步骤条件与 concurrency。
  2. Forgejo Actions Security:fork PR、runner 与 secrets 边界;Utilizing Docker within Actions:构建任务如何取得 Docker 访问能力。
  3. actions/checkout v4.2.2 官方发布记录:固定 action 的提交标识。
  4. Docker image save 与 image load:通过归档传递原镜像。
  5. Docker Compose up 与 Compose services:image、端口、healthcheck 与等待健康。
  6. Ubuntu Server:OpenSSH 与 ssh_config(5):密钥认证、IdentityFile、known_hosts 与 StrictHostKeyChecking。

文档核对日期:2026-11-06。Forgejo 文档为当日 latest,实例界面和可用 runner 由管理员配置;应用与部署示例按 Ubuntu 26.04 LTS 设计,完整链路未实测。