文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。

systemd 用户管理器代表一个账号运行服务。同一账号的多个登录会话通常共享这个管理器,而不是每个 shell 都拥有一套私有服务世界。用户单元可以放在 ~/.config/systemd/user,通过 systemctl --user 操作对应管理器。这提供的是以账号权限执行的生命周期管理,并不自动隔离所有共享系统资源。使用它之前,需要同时理解服务属于谁,以及管理器在什么条件下继续存在。
会话共享账号级管理器。图中最后会话结束后的停止,以未开启 linger 和配置的停止延迟为前提,并非立即停止的普遍规则。本轮绘图没有重新执行原文样例。 坏掉的样子 关闭一个终端,不一定停止用户服务,因为另一个登录会话可能仍然存在,管理器寿命也受到 linger 和停止延迟配置影响。即使最后一个会话结束,立即终止也不是普遍规则。必须跟随某一次图形会话结束的应用,需要明确的生命周期关系,不能假定任何用户单元天然具有这个范围。账号级后台任务与某个窗口的寿命,是不同需求。
选错管理器也是常见故障。普通 systemctl 操作系统管理器,systemctl --user 则需要连接正确用户的管理器及其运行时通信端点。通过另一身份执行命令,或者所在环境没有完成会话准备,都可能导致连接失败。仅仅设置一个环境变量,不会创建管理器,也不会授予控制其他账号的权限;诊断时应先确认执行身份与目标总线是否一致。
用户服务也不会自动继承发出控制命令的那个终端环境。所需路径、配置和图形会话变量,应通过有意选择的机制提供。使用普通账号会减少权限,但该账号仍可以按既有权限访问共享可写文件、使用网络套接字并消耗资源。因此“没有 root”并不等于“所有影响都绝对局限于自己的文件”,还要检查真实权限和资源限制。
快速验证 在具备 systemd、logind、PAM 集成、用户 D-Bus 会话与账号管理工具的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。恢复文本记载的环境为 Debian 12、systemd 252.39,但原始运行日志不可用。本轮没有执行此样例;以下结果说明其预期断言,不能视为本轮实测。脚本创建临时账号、两个真实 PAM 会话和一个用户服务,不修改任何已有人员的单元或登录状态。最后二十秒等待要求 UserStopDelaySec 有限且更短;合法取值 infinity 会阻止预期停止:
...

TCP 最大段大小选项 MSS,按照其定义的开销规则,通告接收端可接受的 TCP 数据大小。两个方向分别通告,数值不必相同。路由器转发 SYN 消息时可以减小这些值,使后续数据段适应受限路径。这改变的是相关连接的 TCP 行为,不会修改底层链路 MTU。把选项调整与网络本身修复区分开,才能准确说明它解决了什么。
限制只改变新连接的 TCP 通告,不改变链路 MTU 或缺失的 ICMP 反馈。应保留对端较小上限,并分别验证两个方向。 坏掉的样子 中间瓶颈可能比两个端点接口都小。如果经典路径 MTU 反馈被过滤,连接可以成功建立,却在较大数据上停滞。降低 MSS 能帮助该 TCP 流量避开超大报文,但缺失的反馈仍然缺失。若把这个变通措施当作完整网络修复,其他协议与流量模式仍可能暴露原有问题,健康检查也可能继续遗漏受影响场景。
...

数据库的 schema 不是一次性的设计;它随使用它的软件一起演进。与其临时编辑 schema,不如用迁移应用一组命名、有序的变更,让任何环境都能重放。
把 schema 当代码 把 schema 当源代码一样对待:每一条迁移都是一个带顺序、带编号、被命名的小步骤,把库从一个状态推进到下一个状态,整个序列都收进仓库。全新的库从空库开始重放这些步骤直到当前版本,这正是测试建库、临时环境克隆、安装新机器的统一口径;没有人再对着线上实例随手敲一条 ALTER TABLE。schema 也因此从「某个实例才知道的事实」变成有历史、可 review、有 diff 的正式产物。
顺序就是契约 在起始结构相同、引擎兼容、所需扩展齐备且步骤确定的前提下,有序重放才能复现预期结构。只有顺序相同,不能消除环境差异或依赖已有数据的变化。这种可预测性就是契约:它让测试能创建全新数据库、让暂存能克隆,让运维升级时不用靠猜。随之而来的铁律是——凡是已经被任何环境执行过的迁移,都不得重写或重排,因为执行器依靠已记录的身份决定哪些步骤已经执行;这种身份不一定包含内容校验和。篡改历史可能造成环境分叉,需要检查实际状态,再通过明确的前向修复恢复一致。
危险在不可逆 删除列或表的迁移一旦提交,就会丢失所存数据;只重建结构不能找回原值,必须谨慎对待。给它们配一张清单:先备份,把 drop 拆成单独的后续迁移,这样能先回滚前面的 schema 改动,最后再面对不可逆的破坏。真正的纪律是「只能向前、但留有退路」——绝不要写一条把维护者锁在门外的迁移。上线前一定要在 staging 上跑一遍这类破坏性迁移,让失败模式在真正投入生产前就变成一件平淡无奇的事。
坏掉的样子 最贵的一种坏法,是把不可逆迁移直接送上生产。在 PostgreSQL 里,删除列不需要重写表,但仍需取得 ACCESS EXCLUSIVE 锁。许多类型转换或新增带易变默认值的列需要重写;符合条件的二进制兼容转换可以免去重写,却不会因此免去所需的锁及等待时间。大型重写可能长期等待或运行。PostgreSQL 的事务性 DDL 失败后可以回滚;是否留下部分结果,取决于先前提交、非事务步骤与执行器事务边界,不能把超时一概理解为半张表已经改好。而 down 迁移并不能撤销数据丢失:删掉邮箱列再加回来,得到的是空列,而不是被删掉的地址。破坏性步骤应当拆成独立的后续迁移,上线前先备份,优先使用扩张再收缩的顺序,先加新结构,再回填数据,最后删除旧结构,让每一次发布都平淡无奇。换句话说,只要迁移涉及删数据,就默认它回不来。
另外两类经典故障是并发双跑和篡改历史。如果两个发布副本在没有任何锁的情况下同时执行迁移,它们可能重复应用同一版本,也可能把版本顺序搅乱。生产级的 runner 因此只允许单个执行者,或者借助 PostgreSQL 咨询锁之类的机制加锁,而 golang-migrate 在失败后会把库标记为 dirty,而不是假装一切正常。重写已经被应用过的迁移,或者合并了两个各自新增版本 7 的分支,都会让历史分叉:环境可能分叉。还要检查执行器到底保存什么:golang-migrate 的 PostgreSQL 账本保存版本与 dirty 状态,并不为每份文件保存内容校验和。先核对实际状态,再设计前向修复,不能假定工具自动发现所有历史编辑。strong_migrations 这类工具会在执行迁移时检查危险操作;应先在开发环境运行检查,再安排部署,不能把它当成自动覆盖所有风险的静态审查。
...

CORS 预检用于询问浏览器能否发送某种具体形态的跨源请求。它是一条 OPTIONS 请求,携带计划采用的方法及相关请求头名称,浏览器检查服务端答复后才决定是否发送实际请求。这与实际响应到达之后,检查应用脚本能否读取响应,是两个不同阶段。它们都可能失败,但对服务端是否执行过业务操作的影响不同,诊断时必须先确定错误发生在哪个边界。
触发预检的条件并不是简单的“带有凭据”或“会产生副作用”。在其他条件同时满足时,安全列表中的 GET、HEAD 或 POST 可以不预检。PUT、应用自己设置的 Authorization 等请求头通常需要预检,JSON 内容类型也不属于安全列表里的内容类型。因此,即使目标地址和业务操作没变,只改变请求形态,也可能新增一次 OPTIONS 交互。不能把这种变化误认为服务端突然多执行了一次业务请求。
将服务端收到请求的证据,与脚本能否读取响应分开核对。“允许来源”指 Access-Control-Allow-Origin,“凭据许可”指 Access-Control-Allow-Credentials: true。五个案例均使用本地合成响应,不设置 Cookie,也不实现账号认证。 把每项权限放到对应阶段理解 预检携带计划使用的方法,并在需要时携带计划设置的请求头名称。对于下面的 PUT 和 X-Lab 请求,预检响应必须同时允许该方法和自定义请求头。这个阶段成功之后,实际响应仍然要满足自己的 CORS 暴露条件。当获准的前端需要读取错误状态和正文时,错误响应也可能需要恰当的暴露头。后端处理函数可以被普通客户端成功调用,并不足以证明浏览器中的真实前端能够访问它。
...

Caddy 可以自动获取并续签受管理的证书,不需要运维人员手工实现 ACME 协议。自动化省去了重复操作,却没有取消签发前提,也不能代替对失败的监控。今天证书有效,不证明下次续期仍能完成,更不证明整条链路中的每一跳都使用同一张证书。应先明确服务名称、验证路径、持久存储和 TLS 终止位置,再判断自动化覆盖到哪里。
先让自动 HTTPS 知道服务名称 当配置让 Caddy 知道符合条件的主机名或地址时,自动 HTTPS 才会按规则启用,同时还要考虑文档列出的例外与显式覆盖。Caddyfile 中的站点地址包含主机名,是常见触发方式。file_server 指令负责提供文件,本身并不等于为某个尚未指定的域名申请公开证书,不能把文件服务功能与证书管理的启用条件混在一起。
命令行区别很明确:不带参数的 caddy file-server 默认监听八十端口;提供 --domain 后,才会尝试为指定主机名启用 HTTPS,并将默认监听端口改为四百四十三。无论命令怎样拼写,都不能单独证明 DNS、端口转发或证书授权已经正确。显式 HTTP 配置、手动加载的证书等条件,也可能改变自动行为,应该检查实际生效配置。
本地开发还有不同的信任边界。对于内部名称,Caddy 可以使用自己的本地证书机构。客户端需要通过适当流程信任该机构;某台机器能够正常访问,不代表这张证书已经获得公网客户端的普遍信任。更不能为了修复客户端信任问题,把私有 CA 的签名密钥发给所有客户端。信任根分发与私钥保管是两项不同工作。
让挑战方式与真实路径匹配 Caddy 默认同时启用 HTTP-01 与 TLS-ALPN-01。前者通过公网八十端口验证,后者通过四百四十三端口上的特殊 TLS 握手验证;Caddy 可以在可用方式之间选择。某次 HTTP 挑战失败,并不说明 DNS 验证就是唯一替代方案。应先从日志确认实际失败的是哪种挑战,再检查该方式的入口、转发和防火墙规则。
...

Redis 流水线允许客户端连续发送多条命令,不必等前一条回应以后再发下一条。它减少了请求与响应之间的等待,也能分摊套接字输入输出开销。但服务端仍需要执行每条命令并生成回复;批处理不代表这些命令并行运行,也不自动形成事务边界。
原有实验把参数已知的 SET counter 4、INCR 和 GET 排队,依次得到 True、5 和字节串 5。普通流水线不添加 MULTI 或 EXEC,命令报错也不会取消已提交的后续命令。 仅为原有实验示意,本轮未重跑。 少等几次,而不是少执行几条 同步循环不断重复发送一条、等待一条。流水线则先发送一个序列,再按对应顺序收取回复。把一批近似理解为一次往返,可以帮助估算延迟,但不能据此承诺只有一个 TCP 数据包、一次系统调用,或所有回应在同一时刻到达。实际传输仍受缓冲区、数据量和协议处理影响。
收益也不只出现在远程链路。把套接字读写集中起来,可以减少系统调用和调度开销,所以即使本地连接,也可能在不分片的情况下提高实际吞吐。反过来,流水线消除不了昂贵命令本身的工作和带宽上限。应先定位真实瓶颈,而不是看到单节点就断言下一步必须加分片。
普通流水线没有“不被插队”的保证。其它客户端可能在批次的命令之间执行操作,但具体某一次运行也可能碰巧全部连续完成。观察到一次连续执行,并不能把它上升为事务隔离承诺。正确性不能依赖一次测试里没有出现的调度交错。
...

幂等键命名的是一次预期行动,不是一次网络投递尝试。服务支持这项契约时,客户端在结果不明确后重试同一个行动,应该继续发送同一个键。每次重试都生成新键,实际是在告诉服务器“这是另一次行动”,因此完全可能合理地产生第二份业务效果。客户端必须先把行动身份与尝试次数分开。
创建一次,在后续尝试中保留 在第一次发送之前生成键,并把它与操作参数、目标一起保存。如果后台任务可能在客户端重启后恢复,任务记录也需要保留这个身份。仅把键放在临时网络连接或一次调用栈中,无法可靠地把后续尝试关联回原来的意图,恢复程序也不应临时猜一个替代值。
真正的新行动应该使用新键,即使参数与之前的购买完全一样。仅用正文哈希无法区分“再买一次同样的东西”和“重试第一次购买”。键与请求指纹承担不同职责:键标记行动,指纹检查是否发生了不兼容的复用。把二者合并,既可能漏去重,也可能错误阻止合理的新操作。
键的格式和作用域应遵循服务提供方的契约。Stripe 建议使用 UUIDv4 等随机标识,并提醒不要把敏感数据放进键中。键不是认证凭据,拥有某个键也不能代替访问已存结果所需的授权。不要把 bearer 凭据、邮箱或个人信息塞进用于关联请求的标识。
接收方必须真正实现这份承诺 只加一个 Idempotency-Key 请求头,不会自动实现去重。服务端必须在明确契约下识别它,说明比较哪些参数、保留哪些结果、执行尚未完成时如何处理重复请求,以及记录会保存多久。这些都是服务行为,不能从客户端看见一个字段名就推断已经具备。
Stripe API v1 记录的是首次进入执行阶段的请求状态码与正文,包括 500 响应。如果参数验证失败,或与正在执行的请求发生冲突,而端点执行尚未开始,则不会保存对应幂等结果。因此,“重复请求必须等待第一份结果”并不是所有 API 都必须采用的通用规则,调用方要处理实际的在途冲突语义。
Stripe API v2 则可能继续尝试失败或部分失败的工作,并返回更新后的结果;不能把 v1 的原响应保存规则直接套到 v2。不同提供方也不保证逐字重放响应。EC2 文档说明,成功重试不会再次执行行动,但返回结果可以包含更新后的创建状态等信息。RFC 9110 定义的是预期效果;具体服务是否复用原始响应字节,是叠加在其上的另一层契约,不能把两者当成同一要求。
让键的含义保持稳定 同一个键被配上不兼容的参数时,应按照服务明确的错误规则处理,而不是静默创建另一项操作,或覆盖原先保存的结果。JSON 字段顺序、默认值、数字表示和省略字段,都可能影响比较。一个小例子能规范化某种输入,不代表已经解决任意正文的语义等价问题。
下面的实验只接受一个商品字符串和一个正整数量。将这两个字段排序,定义了它自己的比较规则,并不是通用 JSON 等价算法。示例用 409 表示参数冲突,也是测试接口的政策,不是每一家幂等 API 都保证返回的状态码。
...

同一个资源可以同时提供面向读者的 HTML 视图和面向程序的 JSON 表示。共用 URL 的前提是源站与中间层始终保留这种区别。端点需要选择可用格式、正确序列化、说明缓存变化维度,并验证本次选中的表示,而不是拿同一资源的另一种版本来代替它。单一地址不会自动完成这些工作。
先选择,再序列化与验证 对于 GET 响应,请求中的 Accept 表达希望接收哪些媒体类型,请求中的 Content-Type 则描述请求内容本身。把后者写成 JSON,并不等于要求服务器返回 JSON。质量值与通配符匹配应交给框架已有的媒体范围解析器。应用还需要分别规定:没有 Accept 时默认返回什么,以及字段存在却匹配不到任何可用表示时如何处理。
下面的端点只在 Accept 缺失时默认选择 HTML。如果客户端明确表达的偏好没有可接受候选,就返回 406,并列出可用类型。这是一项刻意选择的接口策略,因为 HTTP 也允许忽略偏好。不要把它换成 415,后者针对的是请求内容格式不受支持。分清错误方向,客户端才能知道应该修改希望接收的类型,还是自己发送的正文及其声明。
完成选择后,Content-Type 应由实际使用的序列化方式确定。HTML 中插入数据需要适当转义,JSON 则应通过 JSON 序列化器生成,而不是塞进 HTML 模板。该路由的各类表示,包括默认表示,都需要 Vary: Accept。但 Vary 不授予存储资格,也不保护个人信息;实验中的公开缓存指令只适用于这份对所有调用者完全相同、没有敏感信息的固定状态记录,不能直接复制到账户接口。
坏掉的样子 只测源站,可能看不出缓存正在把之前的 HTML 发给现在的 JSON 请求。需要通过完整交付路径检查响应头和解析后的正文,并交换请求顺序。新响应补上正确的 Vary,不会自动修复此前已经缓存、却缺失选择元数据的条目。应使用受控测试地址或已获批准的失效流程,不要为了验证一个端点而随意清空共享缓存。
...