Sweet·Tang返回文章书架

从0到1,CI/CD工作流搭建部署

Sweet·Tang /

这是一个由项目负责人独立推进、借助 Agent 协作完成的个人博客项目。网站由 Next.js、两个 Go 服务、Nginx、MySQL 和发布控制组件组成。本文记录从提交代码到生产上线的方案及实测结果,并区分设计时预判的风险与部署时实际发生的问题。这里的 CI/CD,是指自动检查和打包代码,再把通过检查的版本部署到服务器。

1. 技术方案调研与选型

生产服务器只有 2 核 2 GB。发布既要更新多个服务,也可能改变数据库结构,因此选型的约束是:避免在线服务器编译、确保同一批组件来自同一次提交,并能判断一次发布是否真正成功。

方案取舍
服务器拉源码并构建步骤直接,但会把 Next.js 和 Go 的构建负载放到生产机;这台机器的资源余量不适合承担该负载。
GitHub Actions 验证、构建,GitHub 镜像仓库分发最终采用。代码已在 GitHub;先验证,再发布与提交绑定的镜像。生产机按镜像内容标识拉取,不重新编译。
购买独立镜像仓库或通过工作流附件传镜像当前规模下增加费用或交付负担,没有采用。

发布单位选择整站统一版本:认证、博客、页面、网关、迁移器、通知接收器和部署执行器共七个镜像对应同一次提交。MySQL 使用独立持久卷,数据库引擎不随业务镜像升级。逐服务独立发布,或同时运行新旧两套应用再切换流量,会增加个人项目的运维复杂度和资源需求,因此当前流程不承诺零停机。

项目还增设了本地 Docker test 环境,用来提前检查容器启动、备份、迁移和健康状态。它与生产共用主要服务关系和关键部署步骤,但不是同一条发布链路:test 从当前工作区在本机构建;生产只拉取 main 通过自动检查后发布的镜像。本地原生开发服务与 Docker test 共用测试数据库,test 与生产数据库则分开。这个边界决定了 test 能提前发现部署问题,却不能代替生产镜像拉取和公网 HTTPS 验收。

2. 架构设计

2.1 把网站访问和版本发布分开

架构分成两条链路。访问链路服务于访客和管理员:Nginx 统一接收 HTTPS 请求,Next.js 负责页面,两个 Go 服务分别处理登录与文章,MySQL 保存业务数据,阿里云对象存储 OSS 保存文章图片。域名、证书和请求转发集中在网关,页面与业务服务各自承担明确职责。

发布链路负责更新网站:GitHub Actions 检查和构建代码,GitHub 镜像仓库(GHCR)保存构建结果,Webhook 接收工作流完成通知,部署执行器负责核验版本和更新容器。Webhook 容器还在启动时及每 60 秒核对当前 main,漏送通知时仍可发现新版本;GitHub 查询失败时会延后重试。它不参与正常阅读请求,但容器停止后,通知接收和定时核对都会停止。宿主机的维护定时任务只负责清理过期发布,并不负责发现新版本。

正在生成图表…

代码
flowchart LR
    subgraph ACCESS["网站访问"]
        U["访客 / 管理员"] -->|HTTPS| N["Nginx 网关"]
        N -->|页面| W["Next.js"]
        N -->|登录接口| A["Go 认证服务"]
        N -->|文章接口| B["Go 博客服务"]
        A --> D[(MySQL)]
        B --> D
        B --> O["OSS:文章图片"]
    end
    subgraph RELEASE["版本发布"]
        G["GitHub Actions"] -->|上传镜像和发布包| C["GHCR"]
        H["Webhook:接收通知与定时核对"] --> R["部署执行器"]
        H -->|查询当前 main 与工作流| G
        R -->|核对代码与工作流| G
        C -->|提供核验后的发布物| R
    end
    G -->|工作流完成通知| N
    N -->|/webhook| H
    R -->|备份与迁移| D
    R -->|更新应用容器| W
    R -->|更新应用容器| A
    R -->|更新应用容器| B
    R -->|更新网关与通知接收器| N
    R -->|更新网关与通知接收器| H

2.2 CI 构建成品,生产机只下载和运行

2 核 2 GB 的生产机需要把资源留给网站和数据库,所以编译放在 GitHub Actions。认证、博客、页面、网关、迁移器、通知接收器和部署执行器共七个镜像,统一对应一次代码提交。其中五个应用常驻,迁移器和执行器按任务运行;MySQL 独立管理,不随业务镜像一起升级。

发布包把这批镜像与代码提交、工作流运行、部署文件和配置快照关联起来。七个镜像按内容标识下载,避免同名标签被替换后拿到不同内容。实现中,这个标识叫 digest。发布包则按工作流运行编号和重试次数定位,下载后核验身份并锁定本机镜像。配置随成功版本保存,回滚时恢复目标版本的配置。当前发布包仍含生产密钥,凭据管理尚待后续改进。

首次安装使用已核验的新执行器启动部署;后续发布由当前成功版本记录中的执行器运行。新版本成功后,记录才切换到新的执行器镜像。因此执行器代码修复也需要完成发布才能在服务器生效,不能把工作区中的改动视为线上已更新。

2.3 通知只触发核对,成功记录最后写入

服务器收到通知后,仍会查询当前 main 和该提交最新一次工作流运行,核验发布包与镜像是否属于同一批构建。最新运行失败或尚未结束时,不会改用旧的成功记录;镜像下载完成后、改动数据库前还会再次核验,避免发布过时版本。部署锁用于阻止多个发布同时操作站点,数据库锁协调备份和迁移。

MySQL 使用独立持久卷,更换应用容器不会重建数据。更新顺序是:先核验和下载,再备份、迁移、更新认证、博客、页面和网关;服务及网关检查通过后,再更新 Webhook 并等待健康检查,最后写入成功版本记录。这份记录在实现中叫 manifest,保存当前及上一成功版本的信息。将通知接收器放在后面更新,让独立部署任务能够在接收器重启后继续完成并供新接收器观察。

整站统一版本便于追踪,但容器是逐步更新的,流程不保证同时切换或零停机。中途失败时,数据库、部分容器和成功记录可能处于不同版本,因此排查必须同时看这三类状态。数据库版本也决定应用能否回滚,具体流程见下一节。

3. CI/CD 流程

新机器发布要先准备运行环境;已有机器发布复用现有环境;回滚则从服务器保留的成功版本中选择目标。三种流程共享版本记录和健康检查,但回滚不会执行数据库降级。

3.1 新机器发布:先完成首次安装

准备 Ubuntu amd64 服务器、/ssl/cert.pem 与 /ssl/cert.key、80/443 入口,以及依赖安装和镜像下载所需网络。首次安装脚本目前固定仓库、站点域名和 /srv/sweet-site 目录,面向本项目使用。填写根目录与 next/ 的两份 .env.production 后,将目标代码推送到 main,等待自动检查与镜像发布全部成功;脚本从该提交和发布包读取配置,不是在服务器上另填一套配置。

把 first-install.sh 放到服务器,以 root 权限在交互终端运行。脚本安装 Docker、Compose、Task 等依赖,接入仓库与镜像仓库,核验发布物,初始化 MySQL,再执行备份、迁移和应用部署。低于 3 GiB 内存且没有交换空间时,脚本会创建 2 GiB swap,以缓解小内存机器初始化数据库的压力;发布还要求相关文件系统至少有 8 GiB 空闲空间及足够的可用文件节点。首次部署成功后启用备份和清理定时任务,并检查本机 HTTPS。

正在生成图表…

代码
flowchart TD
    A["准备机器、网络、证书和生产配置"] --> B["推送 main,等待检查与镜像发布成功"]
    B --> C["执行首次安装脚本,安装运行依赖"]
    C --> D["接入仓库,核验发布物并拉取镜像"]
    D --> E["初始化 MySQL,再由执行器备份与迁移"]
    E --> F["更新四个应用并检查,再更新 Webhook"]
    F --> G{"部署通过?"}
    G -->|否| H["保留数据库卷与失败状态,排查后重试"]
    G -->|是| I["记录成功版本,启用备份与清理任务"]
    I --> J["配置 GitHub 发布通知"]
    J --> K["核对公网 HTTPS 与业务访问"]

GitHub 仓库的通知地址使用 HTTPS /webhook,内容类型选择 application/json,事件选择 Workflow runs,通知密钥与服务器配置一致。接收器先校验通知签名和仓库身份,再核对 GitHub 运行结果;返回 202 只表示通知已接收。即使尚未配置仓库通知,运行中的接收器也可通过定时核对发现版本,通知用于更及时地触发检查。

首次安装中断后要先核对现场,再决定如何继续。脚本会保留已有数据库卷;重跑时若发现 MySQL 容器不健康,会停止并要求排查。已有成功版本记录的机器不能重跑首装,即使后续定时任务或本机 HTTPS 检查失败,也要按现有站点处理。

脚本的本机 HTTPS 检查跳过证书校验,部署执行器内的网关检查也是如此。它们证明网关能响应,不证明域名解析、证书链或公网入口正常,仍需从服务器外另行验收。

3.2 已有机器发布:由提交触发,服务器独立核验

通常只需推送 main。当前 Verify 工作流只接受 main 推送,没有 PR 或手动运行入口。它先检查代码、运行测试、构建应用,并在隔离数据库中验证迁移;通过后才构建七个镜像,最后上传发布包。服务器要求整个工作流成功,不能只看前面的检查任务或部分镜像已上传。工作流完成通知触发核验,通知漏送时由接收器定时核对补上,两者遵守同样的发布条件。

正在生成图表…

代码
sequenceDiagram
    actor U as 开发者
    participant CI as GitHub Actions
    participant CR as GHCR
    participant WH as Webhook
    participant R as 部署执行器
    participant DB as MySQL
    U->>CI: 推送 main
    CI->>CI: 检查、测试、构建和迁移验证
    alt 检查与镜像发布成功
        CI->>CR: 上传七个镜像,最后上传发布包
        CI-->>WH: 工作流完成通知
        WH->>CI: 校验通知签名后查询当前 main 与最新运行
        WH->>R: 启动独立部署任务
        R->>R: 取得部署锁,核对现有状态
        R->>CI: 核验当前 main 和最新运行结果
        R->>CR: 拉取并核验镜像与发布包
        R->>R: 检查容量和配置
        R->>CI: 改动数据库前再次核验
        R->>DB: 备份后执行迁移
        R->>R: 更新认证、博客、页面、网关并检查
        R->>WH: 更新通知接收器,等待健康检查
        R->>R: 写入成功版本记录
        U->>R: 核对部署状态和目标提交
        U->>U: 检查公网 HTTPS 与业务访问
    else 检查或镜像发布失败
        CI-->>U: 报告失败,不进入服务器更新
    end

备份、迁移或健康检查失败时,服务器会停止更新并保留失败阶段,不写入新的成功记录。此时先核对实际容器与数据库,不应直接认定旧版本仍完整运行。同一提交的同一次失败发布不会被轮询无限重试;排障后可重新投递成功通知、手动重试,或由新的成功工作流重试触发。应用更新留下未决状态时,普通发布仍会拒绝,须先处理现场。

服务器只接受当前 main。手动指定旧提交不能绕过此规则;要部署旧逻辑,应在 main 新建撤销改动的提交并重新通过 CI,或者走符合数据库条件的回滚。

3.3 回滚:先确认数据库条件,再恢复应用

回滚适用于已发布版本出现问题、需要恢复到已知成功版本的情况。它是显式操作,不会在健康检查失败后自动发生。先查看部署状态和日志,确认当前数据库与实际容器,再选取目标成功版本;目标必须保留成功标记、GHCR 发布收据、可识别的部署协议、镜像、部署文件和配置快照。仓库里仍有旧镜像并不够,当前回滚不会自动从 GHCR 补拉缺失镜像。

当前实现先取得数据库锁,确认没有仍在运行的迁移工具,再要求数据库没有未完成迁移,且数据库版本号与目标成功版本记录完全一致。例如,旧版本记录是 v5,而本次发布已把数据库推进到 v6,即使旧镜像还在,也会拒绝回滚。这里是条件示例,不是当前生产版本声明。此时需要评估兼容 v6 的修复版本,或单独安排受控数据恢复。

正在生成图表…

代码
flowchart TD
    A["发现发布故障或上线后的业务问题"] --> B["查看状态与日志,核对数据库和实际容器"]
    B --> C["选定服务器保留的成功版本"]
    C --> D{"目标记录、镜像、部署文件和配置齐全?"}
    D -->|否| E["停止回滚,评估修复版本或受控恢复"]
    D -->|是| F["执行回滚入口,取得部署锁"]
    F --> G{"数据库状态正常且版本与目标一致?"}
    G -->|否| E
    G -->|是| H["使用目标镜像与配置更新应用,不降级数据库"]
    H --> I["检查服务与网关,再更新 Webhook 并检查健康"]
    I --> J{"检查通过?"}
    J -->|否| K["保留失败状态,继续排查"]
    J -->|是| L["更新成功版本记录与配置指向"]
    L --> M["核对公网 HTTPS 及受影响业务"]

在服务器当前生产目录中执行下面的命令,把占位符替换为已核对的发布版本标识;RELEASE 使用服务器成功记录中的版本标识,不能填代码提交 SHA。

task status 输出的成功记录中,current.release 表示当前成功版本,previous.release 表示上一成功版本。已有部分更新失败时,恢复目标可能是 current.release;当前版本已成功上线但需要退回时,通常考察 previous.release。两者都要核对数据库条件,首装后也可能没有上一成功版本。这里显式指定目标,避免依赖默认选择。

代码
cd /srv/sweet-site/current/production
export DEPLOY_ROOT=/srv/sweet-site
task status ENV=production
task rollback ENV=production RELEASE="<目标成功版本标识>"

回滚命令启动后台执行任务,返回不代表回滚完成。通过 docker logs -f sweet-site-deploy-production 查看过程,结束后再次执行 task status ENV=production,核对本次任务成功状态、成功记录中的目标版本、容器健康及公网访问。状态输出还包含清理和观察结果,不能只看到旧的成功记录就判断本次任务成功。

回滚复用目标版本的配置与本机保留的镜像,不重新构建、不执行数据库向下迁移,也不会自动恢复 SQL 备份。只有应用检查通过才更新成功记录;失败时保留现场,不能删除未决标记、改写迁移版本表或删除数据卷来绕过限制。

接收器观察到回滚成功或失败后,会抑制当前 main 的自动重发,避免马上覆盖回滚结果。新的 main 提交或显式重新投递通知可以继续触发核验;回滚期间到达的后续通知仍可能在结束后处理,因此不能把回滚理解成永久暂停自动发布。

4. 实际操作与验证

下面区分可用操作入口与已留存的历史实测。本轮文章校验没有执行首装、生产发布、回滚或数据库恢复。

在本地,先用 Docker test 验证当前工作区。task deploy ENV=test 构建五个镜像、启动 MySQL、备份测试库、执行迁移,再启动四个应用并检查公开文章接口;task status ENV=test 查看容器状态,不输出生产那样的成功版本记录。test 不依赖 Git 分支,不启用 Webhook,网关只开放本机 HTTP 入口。后台登录使用安全 Cookie,默认 HTTP test 入口不能完整验证登录流程。

代码
task deploy ENV=test
task status ENV=test

首次安装的准备项见上一节,脚本放到服务器后在交互终端执行,仓库和镜像仓库的只读凭据按提示输入:

代码
sudo bash /tmp/first-install.sh

工作流 .github/workflows/verify.yml 先执行 task verify DB=1,通过后才调用 task release:publish 构建并上传镜像。在已有服务器上手动核对或重试时,使用下面的命令,提交标识替换为目标 main 提交的完整 40 位 SHA:

代码
cd /srv/sweet-site/current/production
export DEPLOY_ROOT=/srv/sweet-site
task status ENV=production
task deploy ENV=production SHA="<完整40位main提交SHA>"

生产部署命令也只是启动后台任务,应等待日志中的最终结果,再核对状态和成功版本记录,不能把命令返回当作部署结束。

验收时分别看三类证据:GitHub 检查与镜像发布是否成功,服务器的 task status ENV=production 和成功版本记录是否指向同一提交,以及公网 HTTPS 页面和文章接口是否可访问。CI 绿色、镜像上传成功、生产上线成功是三个不同的结论。 对应实现可从 .github/workflows/verify.yml、Taskfile.yml、infrastructure/deployment/release.sh、infrastructure/deployment/engine.sh 和 contracts/release.md 核对。