Published on

部署本身不该做判断:从"门禁"到"破例"看一套自托管基础设施的设计取舍

Authors
  • avatar
    Name
    Jack Qin
    Twitter

CI/CD 流水线里有一个很容易被写错的角色分配:让"部署"这一步自己去判断"现在该不该部署"。听起来很自然——部署脚本里加几个 if,检查测试过没过、构建好没好,过了就上。但这样写,部署步骤就同时承担了两个本该分开的职责:判断质量是否达标、执行上线动作。一旦合并,"测试挂了但部署脚本的判断逻辑有 bug,于是带病上线"就成了一个真实存在的失败模式。

正确的拆法是:部署本身不做任何判断,它只是一个在所有质量门通过后才被允许执行的纯动作。 判断的职责上移给一个编排器,部署只负责"被批准之后干活"。这一节是本文的引子,但它代表了整套基础设施反复出现的一种思路——把"判断"和"执行"分开、把"理想"和"现实的破例"分开、把"平时不用"和"用时救命"分开。

本文以某环境监测平台为样本,拆解这套自托管部署体系的几个关键取舍:拓扑怎么划网络、多阶段 Dockerfile 怎么榨干镜像、Alpine 统一为什么必须留破例口子、CI/CD 的门禁怎么编排、以及备份/回滚/告警这些"平时不显眼、出事决定生死"的设计。


约束如何塑造拓扑

这是一套小团队自托管系统,约束直接塑造选择:

  • 团队 1–2 人,托管成本要可控,不能堆需要专人运维的云托管服务;
  • 数据库是已有的单台 PostgreSQL 15.8,自托管,不迁移;
  • 镜像要小——跑在云服务器上,Alpine 减小攻击面和体积;
  • 部署不能"测试没过也能上"——必须有门禁。

部署拓扑分两半:静态前端走 CDN,动态后端跑在云服务器 A 的 Docker Compose 上。

flowchart TD
    Net[Internet]
    Net --> CDN[CDN 静态托管<br/>React SPA 静态文件<br/>CI 构建后自动部署]
    Net --> SrvA[云服务器 A 自托管]
    SrvA --> Nginx[Nginx 反向代理 :443]
    Nginx --> ApiC[Docker API 容器 :8080]
    SrvA --> Compose[Docker Compose]
    Compose --> ApiC
    Compose --> WorkerC[worker 容器]
    Compose --> RMQ[rabbitmq :5672 / :15672]
    Compose -.->|PostgreSQL 15 自托管,不在 compose 里| PG[(PostgreSQL)]

注意 PostgreSQL 不在 Compose 里——它是独立自托管的已有实例,Compose 只管 api / worker / rabbitmq 三个容器。

第一个取舍很朴素但正确:前端 CDN、后端容器——各用各的最优载体。 静态 SPA 没必要占服务器资源,扔 CDN 享受全球边缘缓存和自动 HTTPS;后端有状态会话和后台进程,跑容器。这背后的判据是:静态资源和动态服务的特性根本不同(一个不可变、可无限边缘复制,一个有状态、需进程常驻),所以不该硬塞进同一个载体。


多阶段构建:把"构建期需要"和"运行期需要"彻底切开

API 镜像是 4 阶段构建,核心思路一句话:构建期需要的一大堆工具,绝不进最终运行镜像。

flowchart TD
    S1[Stage 1: docs-build<br/>Node Alpine → 文档静态产物]
    S2[Stage 2: csproj<br/>dotnet SDK Alpine<br/>只抽取 .csproj/.sln/Directory.*props]
    S3[Stage 3: build<br/>dotnet SDK Alpine<br/>restore + publish → /app/publish]
    S4[Stage 4: final<br/>dotnet ASP.NET Alpine<br/>只复制 publish 产物 + 文档<br/>非 root 用户 appuser, ENTRYPOINT]
    S1 --> S4
    S2 --> S3 --> S4

几个值得学的细节:

  • Stage 2 单独抽 csproj 是为了利用 Docker 层缓存——只要项目依赖没变,dotnet restore 这一层就命中缓存,不用每次重跑(restore 很慢)。这是把"不常变的(依赖清单)"和"常变的(业务代码)"分进不同层,让缓存能精确命中——又一次"把变化频率不同的东西分开";
  • 最终镜像是 Alpine 基础(约 5 MB),最小攻击面;
  • 跑非 root 用户 appuser,安全最佳实践;
  • 装了 tzdata——这个系统重度依赖时区计算(数据存 UTC、展示用站点本地时区),运行时必须有 IANA 时区库;
  • 文档静态文件打进 API 镜像,由后端在 /docs/ 鉴权后提供。

破例口子:理想让位现实

有一处务实破例:Worker 镜像的运行时阶段不用 Alpine 而用基于 glibc 的 ASP.NET 基础镜像。原因是 Worker 用到的某个浏览器自动化驱动(Node 实现)在 musl libc 上跑不起来。

这个破例值得单独说,因为它代表一种成熟的工程态度:"统一 Alpine"是理想,"依赖不兼容 musl"是现实,遇到冲突时,让理想给现实让路,而不是为了"统一"硬扛。 硬扛的代价(自己去修一个第三方驱动的 musl 兼容性)远大于破例的代价(一个镜像用 glibc)。能识别"这里该破例"本身就是判断力——一致性是手段不是目的,当维持一致性的成本超过它带来的收益时,破例才是对的。 把这个破例显式记录下来(而不是偷偷换基础镜像),让下一个人知道它是有意为之、不是疏忽。


双网络隔离:从网络层面让内部组件不可达

networks:
  edge: # 对外(只有 API 容器)
  backend: # 内部(API + Worker + RabbitMQ)

只有 API 容器在 edge 网络上对外,Worker 和 RabbitMQ 只在 backend 内部网络,外部够不到。端口映射全部绑 127.0.0.1,外部 TLS 由 Nginx 终结。

这是纵深防御,但它的关键在于防御发生的层次:内部组件(队列、Worker)不是"被防火墙规则挡在外面",而是从网络拓扑上就不可达。这两者有本质区别——防火墙规则是"加了一道可能配错、可能被绕过的闸",网络隔离是"这条路根本不存在"。最可靠的安全边界,是让攻击面在结构上不存在,而不是在它存在之后再去封堵。 这和鉴权篇里"HttpOnly Cookie 让 token 在机制上对 JS 不可见"是同一种思路:消除攻击面优于守卫攻击面。


CI/CD 门禁:判断与执行分离

平台是 GitHub Actions + 自托管 runner(跑在云服务器 B,4 台 runner)。工作流按职责拆分,路径过滤只触发相关的:

工作流触发职责
web-lintapps/web/**ESLint + 类型检查
web-testapps/web/**单元测试 + 覆盖率
web-buildapps/web/**类型检查 + 构建 + 包体积追踪
web-contractpushOpenAPI schema 漂移检测
secret-scanpush全仓密钥扫描
backend-testapps/api/**4 层 .NET 测试套件(架构/契约/模块/宿主)
backend-buildapps/api/**.NET 构建 + Docker 镜像验证
main-deploypush to main编排器:门禁所有测试+构建
semgreppush安全扫描(SAST)

部署流是个明确的"门禁"结构:

flowchart TD
    Push[push to main]
    Push --> WL[web-lint + web-test + web-build 并行]
    Push --> BT[backend-test + backend-build 并行]
    WL --> Gate[main-deploy 门禁: 以上全部必须通过]
    BT --> Gate
    Gate --> BD[backend-deploy 可复用工作流]
    BD --> SSH[SSH 到云服务器 A → docker compose pull + up -d]
    Gate --> CDNDeploy[CDN 从 main 自动部署]

回到开篇:main-deploy 是个纯编排器,它 gate 在 test + build 成功之上,部署本身不做判断、只在所有质量门通过后才被允许执行。 "测试挂了还硬部署"从流程上就不可能发生——因为做判断的是编排器(它的唯一职责就是 gate),执行部署的是另一个被编排器调用的可复用工作流(它假设自己被调用时一切已经就绪)。职责一分开,带病上线的失败模式就没有藏身处。


实现要点:那些"平时不用、用时救命"的设计

健康检查分两种

端点用途检查内容
GET /health/live存活探针——进程还活着无(运行中就返 200)
GET /health/ready就绪探针——能接流量了PostgreSQL + RabbitMQ
{
  "status": "Healthy",
  "checks": { "database": "Healthy", "masstransit-bus": "Healthy" }
}

区分存活和就绪很重要:进程活着不代表能干活(可能连不上库)。Docker Compose 的 healthcheck/health/ready 决定容器是否标记健康——依赖没就绪就不放流量进来。把"活着"和"能干活"当成两个独立信号,是为了不把流量打给一个起来了但还连不上数据库的容器。

配置全走环境变量,密钥绝不入库

配置分层:appsettings.jsonappsettings.{Env}.json → 环境变量(嵌套 key 用双下划线)。所有密钥只从环境变量注入,绝不提交到源码,Docker Compose 通过 env_file + environment 块注入。仓库里还有 secret-scan 工作流全仓扫密钥泄漏——配置纪律(不入库)和自动检查(扫描)双保险,因为纪律会被偶尔违反,自动检查才是兜底。

备份与恢复:不演练的备份等于没有备份

PostgreSQL 备份:

  • 云服务器 A 上 cron 每日 pg_dump
  • 保留 7 份每日 + 4 份每周快照;
  • 存到与应用数据分离的对象存储(避免"数据和备份同归于尽");
  • 每月做一次恢复演练到 staging 实例;
  • RTO < 4 小时,RPO < 24 小时

两条值得拆开看。第一,备份必须和应用数据物理分离——如果备份和数据放同一块盘,一次磁盘故障两者同归于尽,备份的全部意义(应对数据丢失)当场归零。第二,也是最容易被忽视的:只 dump 不恢复演练,等于没有备份。 一个从没被恢复过的备份,你根本不知道它能不能恢复——它可能因为某个静默错误早就损坏了,而你要到真出事、急着恢复时才发现。每月恢复演练是把"备份可用"这个假设变成"备份已验证"的事实。这是"用时救命"类设施的通病:它们平时不被执行,所以缺陷不会暴露,必须主动定期演练才能保证它真的能用。

RabbitMQ 从业务数据角度是无状态的:崩溃时在途消息可能丢,但 MassTransit 事务性 Outbox 已把消息持久化到 PostgreSQL,未投递的可从 outbox 表重放。队列/交换机定义由 MassTransit 启动时重新声明,无需手动备份。

回滚按影响范围分级,破坏性迁移留人工闸

范围操作数据丢失?
单容器用上一个镜像 SHA docker compose up -d --no-deps api
应用回滚镜像 tag 改回上一个 git SHA,up -d
迁移回滚恢复 PostgreSQL 备份;EF Core 迁移默认只向前可能——破坏性迁移需预审

一条硬规则:破坏性迁移(DROP COLUMN、表重命名)合并前必须 CI 里人工审批。

这条规则和前面"部署不该做判断、应当全自动"看似矛盾,其实是同一个判断框架的两面:自动化的边界,画在"可逆"和"不可逆"之间。 代码部署可逆(回滚换镜像,零数据损失),所以全自动;破坏性迁移不可逆(回滚要靠恢复备份、可能丢数据),所以在自动化流水线里唯一该保留人工卡点的地方就是它。不是所有人工闸都是低效——在不可逆操作前的那一道,是用一点人工延迟换"不可逆错误不会自动发生"。

告警阈值

信号阈值动作
/health/ready 失败连续 3 次叫 on-call;Docker 靠 restart: unless-stopped 自动重启
RabbitMQ _error 队列深度> 10 条叫 on-call,查消费者失败
磁盘使用率> 80%告警,归档旧报表
PostgreSQL 活跃连接> 80告警,查连接泄漏
Worker 最后心跳> 10 分钟无更新告警,容器可能静默崩了

最后一条专门针对 Worker 的静默崩溃。这暴露了一个监控盲区:HTTP 服务崩了立刻有人发现(请求开始失败),后台进程崩了没人会立刻注意到——它不响应请求,所以没有"请求失败"这个信号。后台 Worker 的死亡是无声的。所以要给它一个主动的"我还活着"心跳,用"心跳停了"来检测一个本来不会发出任何动静的故障。没有自然故障信号的组件,必须人为制造一个心跳信号来监控。


适用边界:自托管单机的甜区与天花板

换来的东西很清楚:载体各得其所、镜像精简且安全、部署有门禁、救命设施齐备。但这套自托管 + CDN + Compose 有明确的天花板:

  • 需要高可用/多副本:当前是单台服务器单实例,要 HA 得引入编排(Kubernetes)和多副本,复杂度跳一个量级;
  • 流量大到单机扛不住:自托管单机上限明确,超过就得上托管数据库 + 水平扩展;
  • 合规要求异地多活:单云服务器的灾备只能靠备份恢复(RTO < 4h),强合规场景需要真正的多区域冗余。

甜区是:小团队、成本敏感、流量可控、能接受 RTO 数小时。 对这个监测平台恰到好处——没有为了"将来可能的规模"提前背上 K8s 的运维债。

可迁移的那一层:本文几乎每个决策都落在同一个判断框架的某条轴上——判断 vs 执行要分开(门禁)、理想 vs 现实要允许破例(Alpine/glibc)、可逆 vs 不可逆决定自动化边界(破坏性迁移留人工闸)、自然有信号 vs 无信号决定监控方式(Worker 心跳)、假设 vs 验证决定救命设施可不可信(备份演练)。 基础设施设计的大量"该不该自动、该不该统一、该不该信任"的问题,套进这几条轴,答案往往就清楚了。