标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

给日志查询明确的问题与统计分母

有用的日志设计,应当从一个能够准确翻译为查询的问题开始。“找出错误记录”与“统计协调世界时十点到十一点之间最终失败的结账请求数”并不是同一个问题。前者用于定位诊断事件,后者需要明确哪些已完成请求属于总体、什么结果算失败,以及时间如何归属。单纯收集更多文本,并不会自动补齐这些语义;如果问题本身模糊,增加字段和存储量也可能只是让错误计算更快出现。 对于请求结果问题,可以定义每个逻辑请求只产生一次的终态事件。需要排查重试时,另外保留尝试事件,并为每个事件赋予稳定身份,以便识别重复投递。请求编号负责连接一次请求中的多个事件,事件编号负责区分这些事件本身。如果把请求编号用作所有事件的去重键,就会把合法的多次尝试和最终结果一起折叠掉,丢失原本需要解释的处理过程。 让查询约定可以被看见 首先决定窗口依据事件发生时间还是日志到达时间。采集器延迟时,两者会明显不同。采用包含起点、不包含终点的半开区间,可以让相邻窗口首尾连接,而不重复计算恰好落在边界上的事件。比较之前,还要把时间归到明确且无歧义的基准。只有本地时钟文字而没有偏移信息,不足以证明一条记录属于某个 UTC 故障窗口,不能由查询人员临时猜测其含义。 随后定义哪些终态属于失败。服务端错误、客户端取消和输入被拒绝,未必适合放进同一个可靠性指标。还应说明相关请求是否都会产生终态事件,以及采集链路是否可能丢失记录。查询对数据库里现存事件完全正确,也仍然可能无法完整描述服务行为。保留期限和筛选策略应写在结果解释旁边,不能只藏在采集配置里,让图表读者误把部分记录理解成全部业务事实。 坏掉的样子 一个请求第一次尝试失败、重试后成功,会留下真实错误事件,但它不是最终失败请求。把每条错误日志都计入失败请求数,会夸大问题;重复投递又可能把同一错误再计算一次。去重应建立在事件身份不可变的约定上。如果两个不同内容声称拥有相同事件编号,那是数据完整性异常,不能因为去重查询能顺利运行,就认为随便忽略其中一个也不会影响结果。 空窗口会带来另一种误判。零个失败除以零个请求,并不是已经测得零失败率。必须保留没有分母这一事实,否则日志采集停止时,可靠性图表反而可能显得十分健康。当排查需要解释某个具体请求时,应在总体口径确定后,读取该请求按时间排列的事件和依赖细节。单独一个汇总数量无法说明哪次重试恢复了,也无法解释另一个请求为什么最终失败。 快速验证 将代码保存为 log-questions.py,用 Python 执行。本次实际环境为 Python 3.14.4,使用标准库中的 SQLite 模块和内存数据库。输入的八条投递记录里有一条完全相同的重复事件;时间围绕一个固定 UTC 小时构造,并专门包含窗口开始之前的一条记录,以及恰好发生在窗口结束时的一条记录。这里没有连接真实采集器或外部数据库,目的是把查询规则及边界结果精确展示出来。 import json import sqlite3 from datetime import datetime def timestamp(value): parsed = datetime.fromisoformat(value) assert parsed.tzinfo is not None return int(parsed.timestamp()) start = timestamp("2026-09-01T10:00:00+00:00") end = timestamp("2026-09-01T11:00:00+00:00") rows = [ ("e0", "r0", "request.completed", start - 1, 503), ("e1", "r1", "attempt.failed", start + 600, 503), ("e2", "r1", "request.completed", start + 660, 200), ("e3", "r2", "attempt.failed", start + 900, 503), ("e4", "r2", "request.completed", start + 960, 503), ("e4", "r2", "request.completed", start + 960, 503), ("e5", "r3", "request.completed", start + 1080, 200), ("e6", "r4", "request.completed", end, 503), ] db = sqlite3.connect(":memory:") db.execute("""CREATE TABLE events ( event_id TEXT PRIMARY KEY, request_id TEXT, event_type TEXT, occurred_at INTEGER, status INTEGER)""") db.executemany("""INSERT INTO events VALUES (?, ?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING""", rows) stored = db.execute("SELECT count(*) FROM events").fetchone()[0] assert stored == 7 error_events = db.execute("""SELECT count(*) FROM events WHERE occurred_at >= ? AND occurred_at < ? AND status >= 500""", (start, end)).fetchone()[0] query = """SELECT count(*), coalesce(sum(status >= 500), 0) FROM events WHERE event_type = 'request.completed' AND occurred_at >= ? AND occurred_at < ?""" total, failed = db.execute(query, (start, end)).fetchone() completed = db.execute("""SELECT request_id, status FROM events WHERE event_type = 'request.completed' AND occurred_at >= ? AND occurred_at < ? ORDER BY request_id""", (start, end)).fetchall() assert completed == [("r1", 200), ("r2", 503), ("r3", 200)] assert (error_events, total, failed) == (3, 3, 1) empty_total, empty_failed = db.execute(query, (end + 1, end + 3600)).fetchone() empty_rate = empty_failed / empty_total if empty_total else None assert (empty_total, empty_failed, empty_rate) == (0, 0, None) print(json.dumps({"received_events": len(rows), "deduplicated_events": stored, "error_events_in_window": error_events, "completed_requests": total, "failed_requests": failed, "terminal_failure_rate": failed / total, "empty_window_rate": empty_rate}, indent=2)) db.close() 去重之后应剩下七个独立事件。在窗口内,按状态宽泛检索会找到三个错误事件:第一个请求的一次失败尝试、第二个请求的一次失败尝试,以及第二个请求的失败终态。但只统计终态时,结果是三个已完成请求,其中一个失败。第一个请求重试后成功,第三个请求直接成功,位于窗口外和结束边界的事件都被排除。因此请求最终失败比例是三分之一,而不是错误事件数除以某个未经定义的总量。 ...

九月 6, 2026 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现时间来源质量检查。

服务器时间校准需要可观察的依据

时间守护进程只有在实际状态满足应用要求时才真正有用。进程正在运行,仍可能没有可用来源;已经选择某个来源,也可能尚未充分测量,或者来源本身就不正确。时间服务健康应当是一条可观察的链路:来源配置、成功采样、选择状态、不确定性以及实际允许的时钟控制。进程显示绿色,只能覆盖链路最前面的一部分。 读取守护进程实际报告的内容 Chrony 的 tracking 输出分别给出参考来源、层级、最近更新时间、尚未消除的系统时间修正量、估计频率、频率偏斜不确定性、根延迟和根离散度。它们回答不同问题:offset 关注时间差,frequency 关注时钟走速,skew 反映频率估计的不确定性。根延迟与离散度则参与描述参考路径带来的时间不确定性。只挑一个很小的偏移数值,不能概括整个时钟质量。 sources 输出用于观察候选来源及实际选择。常见显示中的 ^* 表示选中的 NTP 服务器。Reach 是八进制历史位图,377 表示最近八次轮询机会都有成功响应,并不是百分比,也不保证下一次仍然可达。LastRx 有助于查看最近收到的样本已经过去多久。应把来源状态与 tracking 一起读,而不是把进程持续运行时长误认为同步证据。 上游短暂中断后,守护进程可能继续依据已有模型估计时间,但不确定性会随条件变化而扩大。需要根据应用容忍度监控更新时间和误差估计,区分短期维持与无限期可信。多个服务器名称也不必然意味着独立参考,如果它们共享上游或基础设施,仍然可能一起出错。来源选择降低部分故障风险,却不能证明所有上游声明都正确。 今天能够使用的来源,不能证明长时间断联之后启动服务仍然安全。应事先决定业务能否在维持估计期间启动,以及哪项实际测量超过边界就必须阻止启动。还要把这道检查与耗时测量分开:民用时间可能被校正,而超时逻辑需要合适的单调时钟,不能只因同步状态正常就混用两者。 坏掉的样子 容器通常共享宿主的内核时钟,在容器内运行时间服务并不会自然得到一块独立可调的民用时钟。Chronyd 的 -x 模式明确关闭系统时钟控制,但仍保留估计和提供时间服务的能力。因此,客户端可以报告已经同步,系统时钟却并没有被这个进程修正。排查时必须确认实际运行模式,以及哪一层负责宿主时间。 local 指令也容易制造错误信心。它允许 chronyd 在没有外部参考的情况下表现为已同步,可以用于有明确设计的隔离网络安排。但如果只是为了消除告警而启用,就会把缺失参考这一问题隐藏起来。解释 Normal 之前,要核对参考标识以及预期来源政策,不能把某种协议状态直接解释成 UTC 已经准确。 运行检查还需要有意义的限制。Chronyc 的 waitsync 可以等待同步,并可选地限制剩余时间修正量和频率估计偏斜;相应限制为零时,额外约束会被关闭。两个限制都关闭的成功结果,比执行应用专属容忍边界的检查弱得多。即使配置了边界,它也不能替任意上游时钟证明真实性,来源信任仍是另一项要求。 快速验证 使用新的临时 Debian 12 Docker 容器,运行 apt-get update 和 apt-get install -y python3 chrony 安装依赖,将下面代码保存为 chrony-status.py,以 root 执行 python3 chrony-status.py。创建容器时保持独立网络,不使用宿主网络,并移除 SYS_TIME 能力。实测版本为 chrony 4.3 与 Python 3.11.2,代码只在回环地址通信。 ...

九月 2, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明备份遵循 3-2-1 规则。

备份遵循 3-2-1 原则

经典的三二一备份规则要求三份数据,分布在两类存储介质上,其中一份位于异地。正在使用的数据本身算一份,因此还需要两份备份。规则的作用是开始拆分共同故障影响,并不保证任意三个文件名都能抵抗硬件故障、误删、管理凭据被盗,或者被同步传播到所有副本的错误。 坏掉的样子 副本之间共享的可能不仅是内容。两个目录可能在同一块盘上,两个虚拟卷可能依赖同一个存储阵列,若干远端备份也可能都能被已经失陷的生产管理员删除。因此,设备、地理位置和访问权限边界需要分别检查。异地降低了本地灾害的共同影响,却不会自动使数据变成离线或不可修改状态。 硬链接是“只数名字”的直接反例。它给同一个底层文件增加目录入口,原地修改文件时,两个名字所看到的内容都会变化。单独复制出来的文件可以抵抗这一种修改,保留较早字节,但仍然可能与原文件共享设备和管理权限风险。因此,“不是同一个文件”和“具备独立灾难恢复能力”之间还有很大距离。 同步也可能造成误判。一个迅速传播删除的镜像,会把恢复所需的旧版本一起移除。保留时间与版本选择,和目标数量一样重要。校验和可以发现内容与可信预期值不符,但若攻击者可以同时替换文件和校验值,同一权限范围内保存的校验和本身并不能证明没有发生恶意替换。 快速验证 把以下内容保存为 copies_lab.py,在支持硬链接的文件系统上用普通 Python 运行,并保持断言开启。脚本只使用标准库和临时文件。两个目录名模拟位置,实际上仍在同一个本地文件系统中。这是一次修改隔离与恢复演练,不是这台机器已经拥有真正异地备份介质的证明。 import hashlib import json import os import shutil import tempfile from pathlib import Path def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() with tempfile.TemporaryDirectory(prefix="xbcatm-copies-") as directory: root = Path(directory) primary_site = root / "site-a" other_site = root / "site-b-simulation" primary_site.mkdir() other_site.mkdir() primary = primary_site / "account.json" primary.write_text('{"account":"example","balance_cents":2500}\n') expected = digest(primary) alias = primary_site / "hardlink.json" os.link(primary, alias) independent = other_site / "snapshot.json" shutil.copy2(primary, independent) assert os.path.samefile(primary, alias) assert not os.path.samefile(primary, independent) primary.write_text('{"account":"example","balance_cents":0}\n') assert digest(alias) != expected assert digest(independent) == expected print("hardlink_changed_with_primary") print("independent_copy_kept_original_bytes") primary_site.rename(root / "site-a-unavailable") assert not primary.exists() restored = root / "restored.json" shutil.copy2(independent, restored) assert digest(restored) == expected assert json.loads(restored.read_text()) == {"account": "example", "balance_cents": 2500} print("restored_without_original_path", 2500) assert independent.stat().st_dev == restored.stat().st_dev print("same_filesystem_lab_not_real_offsite_protection") 夹具最初保存一个余额为两千五百分的账户。随后创建硬链接名字和单独复制的快照,并在修改源文件之前记录预期摘要。文件身份检查明确哪一个路径仍指向同一文件。一次原地写入把源文件改为零,硬链接看到的值也随之变化;独立复制的文件则继续保持预期字节。 ...

八月 30, 2026 · 1 分钟 · XB Field Notes
浅色网格上的标题为‘健康检查需要明确问题’,旁侧是观察、判断、行动三个步骤。

健康检查端点是在回答一个问题

健康检查端点是运维决策的一项输入,不是证明服务一切正常的通用证书。一个只返回 200 的小端点并非天然没有价值;如果消费者只需要确认这条处理路径能够回答,它就能提供有限但明确的证据。问题出在把“这个处理器回答了”扩大解释为“每一类用户请求都一定成功”。 写清问题,也写清消费者 存活检查关注应用是否进入了适合通过重启恢复的状态;就绪检查关注当前实例是否应该接收其契约覆盖的流量。诊断面板可以汇总更广泛的问题,但显示一项异常,不等于应该授权调度器立即重启实例。这些信号可以相关,却不应该默认触发同一种动作。 消费者决定信号是否真正生效。一个从不访问该端点的路由器,不会因为它开始返回 503 就自动摘除实例;就绪变更也不会撤销已经到达的请求。应写明谁来轮询、什么结果算失败、哪些流量受影响,以及判断传播需要多久,避免只有端点而没有行动链路。 必需依赖不等于所有依赖 严格必需的依赖可以合理地影响就绪:如果缺少必需输入,实例确实可能只能返回错误。但是,一份允许省略附加说明的报表,不应仅因可选注释服务暂时失效就完全退出服务。判断依据应是产品仍然支持什么结果,而不是依赖清单上列了多少组件。 同一个共享依赖故障,可能让全部副本同时不就绪。摘除所有副本不会修复这个依赖,还可能一并停止本来仍可工作的其他路由。把每个后端检查串到同一个流量闸口之前,应确认服务边界和降级响应契约,而不是认为“检查越深就一定越安全”。 远程依赖故障通常不应直接进入存活判断,除非有证据表明重启本进程能修复当前状态。数据库不可用时连续重启所有应用实例,往往只是增加初始化开销,并不能让数据库恢复。存活失败对应的恢复措施,也需要作为检查设计的一部分接受验证。 信号既要廉价,也要有时效 频繁执行的存活与就绪检查,不应反复运行昂贵的业务工作。反复生成报表、扫描数据表或占用紧张的连接,可能把检查对象进一步压垮,也会让探针自身制造的负载混入观察结果。检查耗时和并发开销应有边界,不能无限借用正常请求的资源。 端点可以暴露最近观察到的依赖状态,而不必每轮都同步访问所有后端,但这会改变契约:观察结果能保存多久、过期后如何判断,都必须明确。永久缓存的绿色标记可能掩盖故障,过于激进的过期策略又可能错误地移除健康容量,二者都需要现实证据来校准。 响应应小而明确,不向外暴露凭据、内部拓扑或完整异常栈。Cache-Control: no-store 告诉遵守 HTTP 缓存规则的中间层不要保存响应,但它既不是认证机制,也不能证明端点内部使用的健康观察足够新。响应缓存时效与观测数据时效是两个不同的问题。 坏掉的样子 独立的轻量 HTTP 处理器可能继续回答,而业务工作线程早已死锁;反过来,过深的存活检查也可能把一次外部波动变成重启风暴。无论路径叫 health、live 还是 ready,名字和绿色状态都不能证明所测条件与预期恢复动作真正对应。 路径写错、认证重定向、中间层缓存和探针网络不可达,都可能产生误导结果。因此,应测试消费者实际访问的路径,而不只是在进程内部调用一次函数。过紧的超时和失败阈值,也会让只是暂时繁忙的健康实例反复被摘除,进一步增加剩余实例压力。 Google SRE 区分内部观测与面向用户行为的黑盒检查。可以合理组合两者:从外部请求一份报表,能够发现内部就绪标记遗漏的用户路径故障,但这个外部失败仍不应不经判断就变成重启命令。发现症状、判断原因和决定动作,需要保持清楚的联系。 快速验证 保存为 health_check.py,用 Python 3.10 以上版本执行 python3 health_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。真实 HTTP 服务绑定系统分配的回环端口;依赖只是注入的状态标记,并非真实数据库。实验明确约定:报表可以在缺少可选注释时继续返回基础内容。 ...

八月 19, 2026 · 2 分钟 · XB Field Notes
一行 HTTP 状态与响应头的终端输出,旁边是一个变暗的状态仪表盘。

可读的证据优先于仪表盘

当论断必须经受审计时,选择可核验的句子而不是彩色面板。

八月 18, 2026 · 1 分钟 · XB Field Notes