系列前四篇都在仓里打转:目录怎么摆、契约怎么生成、schema 怎么演进、生成物怎么防手改。这篇走到仓外,讲这套报名工具怎么上线:一台云服务器,systemd 拉起后端,nginx 站在最前面,staging 和 prod 两个环境挤在同一台机器上。配套还有一条纪律:真实域名、IP、ssh 账号,一个字节不进仓。沿用系列的匿名口径,文中域名一律占位,路径、服务名、env 键改写成 /opt/app、APP_ENV 这类通用形态,机制原样。
单机形态
整个 SaaS 跑在一台云服务器上,没有集群,没有容器编排。组件就这几样:
| 组件 | 形态 |
|---|---|
| 接入层 | 系统 nginx:80/443,TLS 终止,三套前端静态托管,/api 反向代理到回环端口 |
| backend | systemd 服务,release 二进制,只绑 127.0.0.1:59141 |
| 数据库 | 系统 PostgreSQL 18 (127.0.0.1:5432),应用角色 NOSUPERUSER NOBYPASSRLS |
| secrets | systemd EnvironmentFile:/etc/app/prod.env,600 权限 root-only |
| 媒体 | 本地盘落盘,信封加密 |
backend 只绑回环地址,南北向流量全部走 nginx;机器对公网只开 80、443、22 三个口,入站收敛的口径在云安全组,本机 ufw 是关的。status 一条命令拉六项探针:backend、health、cert、https、saas、guide,全绿即健康。
一个人维护的 SaaS,单机不是将就,是选型。用户量还远没到要横向扩展的程度,而单机把运维表面积压到一个人维护得动的规模:一台机器、两份 systemd unit、几个 nginx 站点文件。真上 Kubernetes,光维护控制面就比业务还贵。
单机的代价也说清楚:宿主机是单点,盘损同亡。备份有每日 cron,数据库、env、媒体三型快照按天龄删,但快照只存本机盘,异地投递还没接。这是我知道并欠着的债,不是没意识到。
补救的底线机制倒是一直在:关键变更动作前自动留底。改 env 前备 env 型,migrate 前备全库,install 前备产物和 nginx 配置,弃库前再备一次库,五型注册表各自滚动保留。备份失败就拒绝继续变更,fail-closed:宁可发版中断,不留一个没有退路的变更。
证书是 acme.sh 签的泛域名,DNS-01 验证走解析商 API,90 天自动续期后自动 reload nginx。首装时解析商凭据未必就位,install 会先放一张自签占位证书保住链路,第一天就能把 nginx、TLS、健康探针整条链验完,不用等证书;凭据填好后 cert issue 原位替换,全程不停机。
双域隔离
SaaS 有两套 Web 后台:平台自己用的管理后台,租户用的运营后台。放同一个域的不同路径最省事,我最后选了分两个域。主域挂运营后台 (/console/)、用户手册、字体和 /api 反代;独立的 saas 域挂管理后台 (/saas/) 和它自己的 /api 反代,nginx 模板各渲染一个 server 块:
1 | # 主域站点(模板节选,域名是占位符) |
分域的第一收益是会话天然隔离:管理后台的 bm_admin cookie 只落 saas 域,运营后台的 bm_console 只落主域,两套后台不存在混读入口。第二收益是每域各自反代 /api,SPA 里请求走相对路径,同域同源,CORS 配置直接省掉。
第二个域的成本是零。DNS 上 * 泛解析早就把所有子域指向这台机器,泛域名证书覆盖一切子域,加一个域就是多渲染一个站点文件。
白名单仍显式登记两个域,外加 https://servicewechat.com:后端的 CSRF 防线拿 Referer origin 对照这份白名单,真机小程序的 wx.request 必带 https://servicewechat.com/<appid>/... 的 Referer,漏登记的结果是小程序所有 POST 一律 403,报错面上看不出病根在这行配置。
模板里那个 __DOMAIN__ 顺带预告了下半篇:连 nginx 配置文件本身都不知道真实域名是什么,install 时才由 sed 注入。
staging 与 prod 同宿主机
staging 不在另一台机器上,和 prod 挤同一台。它的前身是本机 Linux 容器预演档,10 月 20 日立起来,只活了六天。容器预演的死因是想仿真又仿不真:systemd、nginx 站点、系统 PostgreSQL 在容器里都是另一套形态,预演通过不等于线上能过。换成同宿主机档之后,预演跑的就是要发的那条路:同一个 nginx、同一个 PostgreSQL 实例、同一张泛域名证书,差异只剩资源名。
档位由一个环境变量决定,脚本常量区十几行:
1 | APP_ENV="${APP_ENV:-prod}" |
缺省值全在档位表里,每个维度仍可用同名环境变量覆写:
| 维度 | prod | staging |
|---|---|---|
| 域名 | <prod 主域> + <prod saas 域> |
<staging 主域> + <staging saas 域> |
| 部署目录 | /opt/app |
/opt/app-staging |
| env 文件 | /etc/app/prod.env |
/etc/app/staging.env |
| systemd 服务 | backend |
backend-staging |
| backend 端口 | 127.0.0.1:59141 |
127.0.0.1:59142 |
| 库 @ 角色 | app @ app_user |
app_staging @ app_staging_user |
| nginx 站点 | app.conf + app-saas.conf |
staging 后缀同款双站点 |
| 备份目录 | /var/backups/app |
/var/backups/app-staging |
共享面:PostgreSQL 实例、泛域名证书、运行用户、nginx,还有宿主机上的 checkout 和 target/ 目录。发版序是硬规矩:先把宿主机 checkout 更新到目标 commit(脚本不管代码同步,这步必须手工做),staging deploy 验绿(status 六项探针加真机预览走查),然后才轮到 prod deploy。两档部署的是同一次构建产出的同一个二进制文件,预演绿的代码和上线的就是同一份字节。
同宿主机双档有一个特有的事故面:误档。特权子命令 self-sudo 提权重跑时,要白名单转发档位变量,漏转发一个,APP_ENV=staging 就静默落回 prod 档缺省。这不叫预演失败,这叫直接往生产上发了一版没验过的东西。所以 staging 的入口单独做了一份薄 wrapper:
1 |
|
exec env 强制注入,参数原样透传。想误档,得先绕开这个入口才行。
破坏性命令的防呆也按档位设计:reset-db 双确认的第一道确认词就是目标档的库名,想弃库先证明你知道自己在弃哪个库;非交互场景注入的确认值同样是库名,档位进错了连确认都过不去。
staging 装 schema 时还有一道替换管道,把角色名、库名换成 staging 侧的,替换范围有禁触清单:库内 schema 名被行级安全(RLS)策略和 Rust 侧 set_config 引用,盲替换的话 RLS 谓词会整体失效,查询静默返回零行。这套管道 schema 演进篇讲过,不重复。
部署信息永不入仓
项目早期,部署文档里写着真实域名、服务器 IP、ssh 账号,脚本要用,写在仓里最顺手。11 月初我把这批信息整体清出仓库,规矩定成一句话:staging 与 prod 的部署信息永不入仓。防线有三层。
第一层占位词。全仓文档一律写 <prod 主域>、<服务器 IP>、<server-user> 这类占位符,部署文档里有一张占位词表,每行标着实际值从哪来。你在这篇看到的 __DOMAIN__ 也是同一家族。
第二层 env 注入。脚本要用真值的地方全部改为读环境变量,域名两个键必填、无缺省,缺失直接拒跑,只有 build 子命令豁免:纯构建不碰部署面。解析序是同名环境变量优先于 env 文件,首装 bootstrap 传一次:
1 | APP_DOMAIN=<域名> APP_SAAS_DOMAIN=<域名> bash scripts/prod.sh ensure-env |
ensure-env 幂等持久化进服务器上的 env 文件,此后这份文件是唯一真相源,systemd 的 EnvironmentFile 同源。env 目录 700、文件 600,root 之外谁都读不了。这份文件的键面刻意收得很窄,十来个自举键:两把信封加密主密钥、数据库密码族、域名二键,外加少量进程开关。业务参数和各类凭据不入 env,全量进库,参数在平台设置页热改,凭据经信封加密落库、管理页只回脱敏值。env 只负责把进程拉起来;两把主密钥丢了,加密媒体和凭据行永久不可解,这是比域名更不能丢的值,必须异地备份。小程序端的发布基址同样走 gitignored 本地文件,模板进仓,release 档读到空值启动即报错。端上也不给真值留进仓的通道。
直觉写法 APP_DOMAIN=x sudo bash prod.sh deploy 是错的:sudo 的 env_reset 会剥掉命令前缀的环境变量,提权后脚本什么都看不到。可靠路径是让脚本自己 self-sudo,白名单转发这几个键。
另一个坑的报错更有迷惑性:非 root 跑脚本,死于「域名未提供」,实际是 env 文件 root-only 不可读,读取函数把权限错误吞了,空手而归还报了个语义错的错。修法是读取前先判目录可穿越、文件可读,不可读就直接报「请用 sudo 重跑」。报错文案指向哪,排障就往哪走,指错了就是带人绕路。
第三层静态扫描。git grep 对一份禁面正则清单跑全仓,命中任何一条 commit 就红:
1 | PATTERNS=( |
真仓里这个数组就是那九个字面量本体,新增出过库的值要同步登记。它挂在 check:commit 快速道里(monorepo 篇提过那条门禁),改仓里任何文件,提交前都会被这份清单扫一遍。
这份清单有个一眼看去很别扭的事实:扫描器必须持有敏感值才能识别它们,于是这份脚本成了全仓唯一集中存放敏感值的地方,再靠 :(exclude) 把自己排除在扫描外。我一开始觉得这是自欺,后来想通了:这些值在 git 历史对象里本来就还在,历史不可改,清单不新增任何暴露面;它防的是「再次写进别处」这个增量。要不要 filter-branch 重写历史把旧值彻底洗掉,我记在待办里一直没动:全量改写历史、所有 clone 失配,代价和收益不成比例。
边界有三条,不拦不该拦的:gitignored 的本地真值不在禁面,它们本来就不进 git;第三方文档镜像里的官方域名不是本方部署信息;微信 AppID 是客户端公开标识,拦了等于自欺。
四个判例
catch-all:nginx 的隐式兜底
给站点改过一次名,旧配置删掉之后,拿旧域名访问居然还有响应。查下来是 nginx 的隐式行为:Host 没匹配上任何 server_name 时,请求落在该端口字母序第一个 vhost 上。当时恰好有个站点当了隐式兜底,别人拿错的 Host 也在被它服务。
收口是一个全机共享的 catch-all vhost:80 和 443 双端口 default_server,return 444,一切未匹配的 Host 静默断连;装配时同步摘掉发行版自带的 default 站点(同端口两个 default_server,nginx -t 直接拒绝)。443 的兜底还得挂证书,SNI 路由先于 Host 判定,TLS 握手躲不开;挂的还是那张泛域名证书,握手面暴露的只有公开证书信息,握完就断。
listen 参数同构
listen ... ssl http2 里的协议标志是 socket 级选项,同端口所有 server 块必须同构。有一块写了 listen 443 ssl、另一块写 listen 443 ssl http2,nginx -t 报 protocol options redefined,而且警告落在哪个块上不一定。第一次撞上时照着报错里的站点名查了半天,问题在另一个无辜的块里。
reload 的异步窗
systemctl reload nginx 信号送达不等于新配置接管。部署负载下 master 解析新配置要两三秒,旧 worker 还要等存量 keepalive 连接排空才退出。没设上限时,旧配置可以残留服务分钟级。两道补丁:nginx 主配置加 worker_shutdown_timeout 10s,给残留面封顶;status 的静态探针自带约 10 秒有界重试,全窗口不中才判异常。对真故障没有掩盖,真宕就是整窗全败,代价只是多等 10 秒。
systemd 加固的取舍
unit 模板的加固段:
1 | NoNewPrivileges=true |
ProtectHome 为什么是 read-only 而不是 true:二进制经编译期烧入的 manifest dir 去读 /home 下的 resources/app.toml,全禁就断了配置发现。加固的火候要对着运行时事实调,每条指令都得问一句这个服务还要访问什么。狠过头的服务起不来,起不来的加固等于没有。
小结
这套部署的形状一句话:把运维表面积压到一个人维护得动的规模。systemd 管进程生命周期和加固,nginx 管入网、静态与 TLS,一张档位表管双环境,一条占位词纪律管住敏感信息。仓里只有机制,真值活在服务器 env 文件里。
判例四则里我最看重 catch-all 那条:站点配置一个字都没写错,病根在 nginx 的隐式行为,不收口就一直在。这个味道前几篇反复出现过:门禁、账本、双零 diff,都是把「靠人记得」换成「结构上防住」。
单机的债再说一遍:快照还只在本机盘,异地投递没接。系列下一篇进数据库,讲多租户数据隔离:PostgreSQL 行级安全的工程化,三层防线让漏挂 policy 的提交跑不出仓。