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

Docker HEALTHCHECK 在容器内运行配置的命令,并把结果记录为健康状态。它提供了主进程是否运行之外的另一项观察,但含义取决于命令检查什么:进程存在、HTTP 响应,还是应用特定的就绪条件。Docker 不会因为出现 HEALTHCHECK 指令,就自动推导出一份通用业务就绪契约,检查者必须明确这个信号能证明哪些行为。
展示原文历史样例,本次未重跑。健康状态间距不代表测得时长。原文用 CMD 启动服务,此图不声称 ENTRYPOINT 或信号转发行为。审核仍为已修订、执行受阻。 坏掉的样子 总是返回零的命令,即使应用无法提供有用工作,也会产生看起来正常的健康报告。shell 命令序列还可能因为最后一条命令成功,而掩盖前面的失败。反过来,如果探测依赖一个非必需外部服务,就可能把仍可工作的应用标成不健康,并促使消费该状态的系统执行不必要恢复。探测含义必须符合实际消费者需求。
独立 Docker 的重启策略响应容器终止,不是只要健康状态变成 unhealthy 就触发。加入 restart=always,不会让 Docker 因探测失败而重启仍然存活的应用。同样,健康检查也不会自动从所有代理的路由表中移除容器。必须有控制器或代理明确读取该状态,并实现预期动作,不能把观察配置当成恢复配置。
探测时序也容易被误解。重试阈值统计连续失败,启动宽限则改变早期哪些失败会被计数。启动期的一次成功可以结束初始宽限行为。检测耗时取决于命令持续时间、间隔、超时和配置,不能简单承诺一次请求失败就立刻停止流量。解释延迟时,应沿实际探测时间线检查,而不是只看一个 retries 数字。
快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。它只创建一个临时镜像和容器,关闭容器外部网络,使用自身回环 HTTP 服务。被改变的状态仅是实验内部一个无害就绪文件,结束后清理自己的容器和标签:
...

404 响应有两类读者:软件需要准确的状态码,访客需要一条能继续前进的路径。错误页即使设计精美,也不能用正文弥补错误的 200;状态码正确,也不代表页面可以难以阅读或没有导航。应当同时保证协议和内容诚实,而不是把一个缺失地址误写成成功,或者把正常的缺失当成整个业务故障。
在渲染正文前确定状态 RFC 9110 对 404 的定义是:源站没有找到目标资源的当前表示,或者不愿透露它确实存在。这并不说明缺失是暂时还是永久。如果服务端知道资源很可能已永久删除,可以使用 410;如果地址确实迁移,就把它重定向到对应的新资源。不要把所有未知地址都送往首页,因为这样既掩盖坏链,也会把访客带到与原意无关的地方。
依赖故障和资源缺失不是一回事。数据库连接失败,导致应用无法判断记录是否存在时,应保留对应的服务端错误语义,不能把所有异常统一变成 404。另一方面,权限策略有时会故意用 404 隐藏资源存在性;这种策略要在契约中说明,而不能在错误正文里泄露对象编号、账号归属或权限细节。相同状态码的选择,必须能由业务事实和安全策略解释。
Google 可能把带错误内容的成功响应识别为 soft 404。返回 200 不保证页面被收录,返回 404 也不保证搜索结果立刻消失。工程上可以承诺的是准确状态、明确内容和正确链接,不能把搜索引擎的后续抓取与索引处理说成即时、确定的操作。修复坏链以后,还需要观察实际抓取和访问结果,而不是仅凭模板名称宣布问题消失。
让错误正文独立且安全 页面用一句简短说明、一个真实首页或目录链接帮助读者恢复方向即可。静态站没有必要为了错误页新增搜索表单、第三方组件或数据库查询;本地静态资源和简单模板更容易在故障时继续工作。API 则应返回已经约定的机器可读错误格式,并保留 404,不能返回一份状态为成功的 HTML 让调用方误判。
状态必须先于正文写入。在 Go 中,如果还没有调用 WriteHeader,第一次写正文会隐式选择 200,后面再试图设置 404 就已经太晚。HEAD 应保持与 GET 相同的状态和适用的表示元数据,但不能传输正文。直接打开某个错误模板文件,只证明那个文件能显示,并不证明真实的缺失请求会进入正确处理器。
坏掉的样子 把所有路径重写到首页,会让缺失文章、脚本和样式表都得到假成功;反向代理也可能把源站的 404 改成自己的 200 错误页。另一个常见问题是处理器先输出页面框架,随后才发现文件不存在。排查时应在实际入口请求一个确定不存在的地址,先禁止自动跟随重定向,再对照正常地址的第一份响应,而不是只看浏览器最终显示了什么。
...

修改 ConfigMap 首先改变的是 API 对象。运行中的应用是否改变行为,还取决于 Kubernetes 如何交付数据,以及应用如何读取它。环境变量注入、普通卷投射、subPath 挂载和已经打开的文件,分别拥有不同生命周期。排查时应找到真正影响业务的消费者,而不是把所有配置传递方式都看成同一种机制。
原文保留示例中的五类交付生命周期。文件刷新不证明应用已经采用配置。本轮集成验证仍受阻,未重跑示例,也未建立通用传播时限。 区分交付与消费 容器环境中的 ConfigMap 引用会在容器启动时解析。更新对象不会重写已有进程的环境,也不会改写同一容器后续 exec 进程所使用的配置环境。替换 Pod 并引用所需版本,可以建立新的环境快照,但这属于生命周期动作,不是修改 data 字段之后自然发生的结果。配置变更记录与进程替换记录应能够对应。
普通 ConfigMap 卷则不同,kubelet 可以在之后把新数据投射进去。延迟受到同步周期和变更发现策略影响,因此 API 修改成功与文件发生变化不是同时发生的事件。投射更新也不会自动调用应用的重新加载函数。只在启动时解析一次配置的程序,即使文件已经变化,也可能一直使用先前读入的值。
打开文件描述引用的是已经打开的文件,而不是每次都重新解析原路径。路径背后的文件被替换,不会迫使旧描述符自动跟随新文件。因此,关闭并重新打开投射路径的进程可能读取新内容,另一个保留的描述符却仍读取旧文件。即使文件系统视图已经刷新,应用自己的缓存还可能再增加一层滞后。
把投射内容复制到可写位置,又会创建一个独立快照。副本不是指向 ConfigMap 的实时链接。如果启动脚本采用这种复制方式,更新这个副本也需要单独实现,不能期待源文件变化自动传播到另一份普通文件。
...

Sitemap 为爬虫提供一份明确的候选网址清单,适合帮助发现新内容或普通导航中较深的文档。这份列表不会绕过登录、授予抓取权限、保证搜索收录,也不能承诺某天一定重新访问。应把它视为计划公开文档的发布清单,并让清单与规范网址及实际发布状态保持一致。文件成功上传,只能说明某一步交付发生了,不能替代后续抓取和收录证据。
从发布清单生成 使用与公开页面相同的权威内容筛选规则,排除草稿、私有文档和多余的跟踪参数变体。源码目录里存在一个文件,不代表它已经对应公开 URL;反过来,分页、翻译文档或生成路由,也未必与源文件逐一对应。因此需要查看生成器实际输出,而不是仅凭目录列表猜测发布范围。内容筛选与 sitemap 生成相互独立维护,容易出现遗漏或提前暴露未发布入口。
XML sitemap 使用声明的命名空间,以及包含网址条目的 urlset,每个 loc 提供完整网址。XML 转义与 URL 编码解决不同问题:分隔查询参数的与号,需要在 XML 文本里转义,而网址本身的字符也必须符合相应 URI 表示方式。生成后应重新解析文件,确认经过两层处理,仍然得到原本希望发布的网址,不能因为 XML 看起来能打开就省略这一检查。
还要遵守格式的范围与大小条件。通常应列出相应主机及允许位置范围内的网址,受支持的跨站提交安排有额外规则。每份 sitemap 最多五万个网址,未压缩内容不得超过 52,428,800 字节。gzip 可以减少传输量,却不能取消解压后的上限。更大的清单应拆分并使用适当索引,不能为了让文件通过检查而悄悄截断后面的页面。
这份清单本身也是公开数据。私有文档没有出现在导航里,并不意味着它的网址可以放心写入公开 sitemap。发布前应检查生成 XML 是否暴露了不该公开的入口,同时在资源处独立执行访问控制。爬虫发现偏好与权限控制是不同机制,清单筛选不能替代真正的授权检查。
坏掉的样子 XML 合法的文件,仍可能列出跳转、缺失页面或不应进入索引的内容。应同时验证目标与语法,并让目标上的 canonical 声明符合预期清单。某个地址返回二百,却只有登录界面或通用错误页,不能证明列出的文档真正可用。Sitemap 自身成功交付,与其描述的页面是否有用,需要分别检查,不能在一个状态码上完成所有判断。
修改时间也可能失真。提供 lastmod 时,应依据页面有意义的变化,而不是每次构建都刷新所有条目的时间。Google 明确说明,它会使用持续准确的 lastmod,并忽略 priority 和 changefreq。这些字段不是调度接口,把所有页面都标成最高优先级,也不能命令爬虫增加访问。没有可信历史依据时,省略可选字段比编造精确时间更诚实。
...

Redis 过期机制回答的是一个键什么时候应当停止可见,而不是整个服务最多使用多少内存。截止时间属于键,后续命令可能保留、替换或者移除它。因此,可靠地使用 TTL,既要检查首次写入,也要覆盖更新路径。看到写命令成功,不能证明之前设置的截止时间仍然存在,更不能直接推断数据最终会按原计划消失。
部分更新保留键期限,普通 SET 则清除期限。原文实验属于历史记录;可见性、清理和 RSS 必须分开。 截止时间不是自动滑动计时器 EXPIRE 使用秒,PEXPIRE 使用毫秒。Redis 将过期信息保存为绝对 Unix 时间戳,所以服务器停止运行期间,时间仍然继续流逝。系统时钟必须稳定,跨机器恢复数据时尤其需要注意时钟差异。它不是独立于系统时间变化的单调时长承诺。下面的实验只使用正常流逝的时间,不调整本机系统时钟。
GET 不会刷新过期时间。INCR 这类原地修改保留键已有的超时;普通成功的 SET 则替换值,并清除原来的超时。SET KEEPTTL 可以保留原期限,再次显式设置过期命令则会改变截止时间。需要滑动会话的应用必须主动实现续期策略,不能以为用户每次读取数据都会自然延长访问窗口。
当值和初始寿命属于同一次操作时,应使用 SET EX 或 SET PX 一起写入。分成 SET、EXPIRE 两条命令,会留下一个值已经存在、期限却尚未写入的失败窗口。客户端如果恰好在两条命令之间崩溃,就可能留下持久条目。这是写入协议缺陷,不是后台过期周期执行得太慢,调整清理频率也修复不了它。
...

HTTPS 延迟由多个阶段组成,重复访问可以跳过其中一些阶段,却不一定是加密运算本身变快了。DNS、获取连接、TCP 建连、TLS 认证、服务端处理和响应传输,各自有不同的计时边界。分析前应先确认哪些阶段真的执行过;单看一次请求总耗时,无法区分复用既有连接与新连接上的 TLS 会话恢复。
消息会按批次传输 TLS 握手虽然包含多条消息,但并不是每条消息都需要单独一次往返,多条消息可以在同一批次中发送。通常的 TLS 1.3 完整握手在普通客户端应用数据之前需要一次 TLS 往返,HelloRetryRequest 会再增加一次。TCP 建连和之后的应用请求、响应,还属于另外的成本。
不使用 early data 的普通 TLS 1.3 会话恢复,通常也需要一次 TLS 往返。它能减少证书传输与认证工作,却仍可能执行新的临时密钥交换;恢复时派生的是新流量密钥,不是原封不动继续使用旧加密密钥。可选零往返数据有额外的重放安全限制,不能把一次快速重复请求直接叫作零往返。
HTTP 连接复用则跳过了建立另一条连接的步骤,因此该请求没有新的 TLS 握手。HTTP/2 多路复用也可以让多个流共享已建立连接。相反,关闭空闲连接再重新连接,即使缓存票据允许 TLS 恢复,仍然需要重新完成传输层建立,不能把两种节省混成同一个动作。
坏掉的样子 正常请求若每次创建新的 transport,就会丢掉连接池。响应正文没有读完或关闭,也可能妨碍复用。空闲超时、入口变化或并发需求都可能增加新连接数量,因此在把延迟变化归因于证书算法之前,应先按真实复用情况和握手事件分类,确认自己比较的是同一种路径。
尝试恢复后仍发生完整握手,并不必然意味着服务故障。票据可能过期或被拒绝,客户端也可能没有可用缓存。应保留普通证书验证,并观察最终选择的行为;为赢得性能数字而减少认证检查,改变的是安全约定,不是在同一个操作下获得了免费优化。
计时名称也要有定义。首字节时间包含的不只有 TLS,总时间还包括消费响应内容。跟踪回调可能来自不同协程,收集器必须正确同步。应比较有代表性的分布及连接类别,不能拿几次回环请求的微秒数去预测公网加速,也不能把偶然更快的一次执行写成稳定性能承诺。
快速验证 保存为 latency_probe.go,使用 Go 1.23 或更新版本运行 go run latency_probe.go。实验固定使用经过验证的 TLS 1.3 和 HTTP/1.1,服务端为接受的 TCP 连接编号。第一个 transport 同时保留 HTTP 连接池和 TLS 会话缓存,另一个独立 transport 刻意不配置会话缓存。
...

同一文档可能由多个地址提供:跟踪参数、别名主机或末尾斜杠变化,都可能不改变有意义的内容。canonical 链接用于指出合适重复表示中的首选版本,帮助接收方理解它们的关系,但不会自动重定向 HTTP 请求,也不能强制搜索引擎采用声明的网址。只有目标与周围信号符合真实内容关系,这份声明才有价值;标签存在本身并不等于规范化已经完成。
先确定身份,再写标签 先决定哪个公开网址代表这份文档,再让站内链接和相关 sitemap 条目与这个选择一致。不能机械删除所有查询参数:跟踪参数可能多余,语言、页码或商品选择却可能实质改变表示。如果没有比较含义就把它们全部折叠到一个地址,会把不同文档指向错误的首选页面。规范化应当反映资源关系,而不是只让网址看起来更短。
HTML 中应把带有 rel="canonical" 的 link 元素放入有效 head,并提供清晰目标。对于 PDF 等其他表示,也可以用 HTTP Link 响应头表达这一关系。优先采用完整公开网址,减少 base 地址或部署前缀造成的歧义。生成时应依据站点预期的公开配置,而不是意外把代理内部主机名写进页面。实际发布结果需要再次检查,不能仅查看模板参数。
canonical 链接关系本身不要求目标必须位于同一个源站。跨域声明需要仔细审查内容与控制关系,声明一个值并不能证明拥有目标。对翻译页面,如果同语言的合适规范页面存在,通常应选择该语言目标。语言替代与内容重复是不同关系,把所有中文页面都指向英文首页,会抹掉有意义的文档区别,不能称为准确的去重处理。
坏掉的样子 声明目标可能报错、继续重定向,或者又指向另一个 canonical。模板、插件和响应头各自生成声明,也可能产生相互冲突的值。应检查完整响应,而不是只搜索有没有一个标签。首选目标需要能够使用,声明也要与实际表示一致。多个位置各自维护相同事实时,更应明确权威来源,避免一次修改只更新了其中一处。
普通链接关系不会改变原始地址的可访问性,除非路由另外配置了行为。如果旧路径需要对所有客户端迁移,就应设置合适的 HTTP 重定向,并验证它对方法的影响;如果变体仍有用途,也可以继续成功响应并提供 canonical。不能把元数据提示等同于跳转、访问授权,或者爬虫必须执行的命令。用户与搜索系统可能观察到不同层面的结果。
搜索引擎会综合多种信号,也可能选择另一份规范网址。因此,标签正确不等于收录已经收敛。应区分源站实际提供的声明与搜索服务报告的选择,排查站内链接、sitemap、跳转或语言元数据是否互相矛盾。发生分歧时,不应只是在更多位置重复添加同一个标签,而应先确认它表达的内容关系是否真实且一致。
重复表示汇合,语言保持区分。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 保存为 canonical.py 并执行 python3 canonical.py。脚本创建临时回环 HTTP 源站,用 Python HTML 解析器读取实际响应,提供英文文档、跟踪参数变体、中文文档和两个故意不一致的元数据案例。本地网址只是演示身份,并不会提交给搜索引擎,也没有检查任何真实搜索账号或收录记录。
...

时间点恢复从一份有效的物理基础备份开始,重放到指定恢复目标所需的 WAL。目标必须位于这份备份及其可用时间线能够覆盖的历史中。开启归档并不会自动获得恢复到任意更早日期的能力,也不会修复基础备份与期望终点之间已经丢失的一段日志。恢复范围由真正保存并可读取的材料决定。
坏掉的样子 复制出来的数据目录不会自动成为有效的在线备份。应采用受支持的备份方式,保存必要日志与时间线历史,并检查备份清单。清单校验程序能发现许多完整性问题,但它运行成功,不能证明更晚的归档日志存在,也不能证明业务期望的恢复目标对应正确状态。后两者需要实际启动恢复,再检查恢复出来的数据。
选择目标也需要明确边界。时间、事务标识、日志位置以及命名恢复点表达的含义不同;对适用的目标类型,是否包含目标边界由对应参数控制。本实验使用命名恢复点,避免依赖人工判断某个墙上时钟时间,但这个标记必须由管理操作事先写入日志。复用同一个名字可能使恢复停在首个同名位置,而不是操作人员心中较新的那个位置。
有效物理基础备份与必要 WAL 到达错误修改前的命名目标。应核对实际暂停与业务行;移除必需段会使目标不可达。这里属于历史本地演练。 快速验证 在 Linux 环境中使用非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端、客户端以及支持保留属性复制、临时文件、硬链接和按文件同步的工具。本次在 PostgreSQL 18.6 上实际执行。集群位于私有临时目录,使用独立 Unix 套接字并关闭网络监听。本地归档用于演练恢复机制,不代表能够抵抗整台宿主机丢失。
#!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-pitr.XXXXXX) mkdir "$lab/socket" "$lab/archive" primary=$lab/primary restored=$lab/restored missing=$lab/missing cleanup() { for cluster in "$missing" "$restored" "$primary"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$primary/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica archive_mode = on archive_command = '$lab/archive-copy.sh "%p" "%f" "$lab/archive"' CONF cat > "$lab/archive-copy.sh" <<'ARCHIVE_HELPER' #!/bin/sh set -eu wal_source=$1 wal_name=$2 archive_directory=$3 archive_target=$archive_directory/$wal_name if test -e "$archive_target"; then cmp -s "$wal_source" "$archive_target" sync "$archive_target" "$archive_directory" exit 0 fi archive_temporary=$(mktemp "$archive_directory/.incoming.XXXXXX") trap 'rm -f "$archive_temporary"' EXIT HUP INT TERM cp "$wal_source" "$archive_temporary" sync "$archive_temporary" ln "$archive_temporary" "$archive_target" || cmp -s "$wal_source" "$archive_target" sync "$archive_target" "$archive_directory" ARCHIVE_HELPER chmod 700 "$lab/archive-copy.sh" pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE marker (id integer PRIMARY KEY,value text); INSERT INTO marker VALUES (1,'base'); SQL pg_basebackup -D "$lab/base" -X stream -c fast -w pg_verifybackup -q "$lab/base" psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' INSERT INTO marker VALUES (2,'keep'); SELECT pg_create_restore_point('before_bad_change'); UPDATE marker SET value='bad' WHERE id=1; DELETE FROM marker WHERE id=2; SQL segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_walfile_name(pg_current_wal_lsn())') psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null attempt=0 while ! test -f "$lab/archive/$segment"; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'archive segment did not arrive' >&2; exit 1; } sleep 0.1 done printf 'primary_now ' psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker" mkdir "$restored" cp -a "$lab/base/." "$restored/" cat >> "$restored/postgresql.conf" <<CONF port = 56433 archive_mode = off restore_command = 'cp "$lab/archive/%f" "%p"' recovery_target_name = 'before_bad_change' recovery_target_action = 'pause' CONF touch "$restored/recovery.signal" pg_ctl -D "$restored" -l "$lab/restored.log" -w -t 15 start >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'restore target was not reached' >&2; exit 1; } sleep 0.1 done PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT pg_is_in_recovery(),pg_get_wal_replay_pause_state(); SELECT id,value FROM marker ORDER BY id; DO $$ BEGIN IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker) IS DISTINCT FROM '1:base,2:keep' THEN RAISE EXCEPTION 'point-in-time result mismatch'; END IF; END $$; SQL test ! -e "$lab/base/pg_wal/$segment" mkdir "$lab/archive-missing" "$missing" for archived_file in "$lab/archive/"*; do if test "${archived_file##*/}" != "$segment"; then cp "$archived_file" "$lab/archive-missing/" fi done cp -a "$lab/base/." "$missing/" cat >> "$missing/postgresql.conf" <<CONF port = 56434 archive_mode = off restore_command = 'cp "$lab/archive-missing/%f" "%p"' recovery_target_name = 'before_bad_change' recovery_target_action = 'pause' CONF touch "$missing/recovery.signal" if pg_ctl -D "$missing" -l "$lab/missing.log" -w -t 15 start >/dev/null 2>&1; then echo 'missing-segment server was briefly available' fi attempt=0 while pg_ctl -D "$missing" status >/dev/null 2>&1; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'missing-segment server did not stop' >&2; exit 1; } sleep 0.1 done grep -F 'recovery ended before configured recovery target was reached' "$lab/missing.log" test -f "$lab/archive/$segment" echo missing_segment_target_unreachable printf 'lab_directory %s\nrequired_segment %s\n' "$lab" "$segment" 主库最初只有第一行,值为 base。取得并验证基础备份后,脚本切换日志段,再插入值为 keep 的第二行,并创建错误修改之前的命名恢复点。只有在标记之后,才把第一行改成 bad、删除第二行。输出中的主库当前状态应只剩 1:bad,证明错误修改确实已经发生,不能把没有真正写入的错误当作成功排除的对象。
...