accept 会移出监听队列,backlog 不是应用全部打开连接的数量上限。

listen backlog 保存等待 accept 的连接

对于 Linux TCP 套接字,listen() 的 backlog 关注已经建立、等待应用接受的连接;未完成握手的队列是另一种概念。调用 accept() 会移出一条待处理连接,并返回供应用使用的已连接套接字。即使这个套接字之后长时间保持打开或忙碌,它也不再占用监听器的 accept 队列。因此,等待接受与已经接受后的活动状态需要分别计数。 坏掉的样子 如果连接到达速度超过应用接受速度,待处理队列会填满。根据内核行为和设置,新客户端可能遇到重传延迟或超时,而已经接受的请求仍可能正常。进程活着,或者部分请求成功,都不能证明监听器正足够快地接受新连接。问题发生在接受之前时,仅查看业务处理成功率可能看不到完整失败路径。 请求的 backlog 还受到 net.core.somaxconn 限制;只把应用值调得更大,不能超过这个上限。但可观察的精确队列边界与实现有关,因此不能把整数直接当成跨平台承诺,认为必定恰好有那么多同时 connect 成功。应测量实际部署内核,而不是把一个很小的实验结果推广成通用的加一或减一规则。 队列计数也需要上下文。ListenOverflows 和 ListenDrops 是网络命名空间计数,不会自动解释到某个具体服务。其他监听器也能贡献计数,客户端超时也可能来自 accept 队列之外。将生产失败归因于某个 backlog 设置,或者宣称扩大队列能修复问题之前,应结合队列占用、接受进度和计数共同判断。 快速验证 把脚本保存为 check-listen-backlog.py,准备宿主 Python、Docker 和固定 Python 镜像后运行。真实套接字操作发生在可丢弃 Linux 容器内,网络只保留回环。记录内核为 6.12.76-linuxkit。脚本读取相关设置但不修改它们,并要求 tcp_abort_on_overflow=0,使本次超时观察对应一个明确、可以检查的前提: import json,subprocess image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' program=r''' from contextlib import ExitStack from pathlib import Path import json,platform,socket def counters(): lines=Path('/proc/net/netstat').read_text().splitlines() for index,line in enumerate(lines): if line.startswith('TcpExt:'): return dict(zip(line.split()[1:],map(int,lines[index+1].split()[1:]))) def queued(port): for line in Path('/proc/net/tcp').read_text().splitlines()[1:]: fields=line.split() if fields[3]=='0A' and int(fields[1].split(':')[1],16)==port: return int(fields[4].split(':')[1],16) raise AssertionError('listener missing') with ExitStack() as stack: def fresh(): s=stack.enter_context(socket.socket());s.settimeout(.25);return s def listener(backlog): s=fresh();s.bind(('127.0.0.1',0));s.listen(backlog);return s somaxconn=int(Path('/proc/sys/net/core/somaxconn').read_text()) abort=int(Path('/proc/sys/net/ipv4/tcp_abort_on_overflow').read_text());assert abort==0 small=listener(1);address=small.getsockname();before=counters();clients=[];timed_out=False for _ in range(8): client=fresh() try:client.connect(address) except TimeoutError:timed_out=True;client.close();break clients.append(client) depth=queued(address[1]);after=counters() assert timed_out and depth==len(clients) and 1<=depth<8,(depth,len(clients)) changes={name:after[name]-before[name] for name in ['ListenOverflows','ListenDrops']} assert changes['ListenOverflows']>0 and changes['ListenDrops']>0,changes accepted=[] for client in clients: server,_=small.accept();stack.enter_context(server);accepted.append(server);server.sendall(b'ready') reader=stack.enter_context(client.makefile('rb'));assert reader.read(5)==b'ready' assert queued(address[1])==0 for _ in range(4): client=fresh();client.connect(address);server,_=small.accept();stack.enter_context(server) server.sendall(b'ready');reader=stack.enter_context(client.makefile('rb'));assert reader.read(5)==b'ready' accepted.append(server);clients.append(client) assert len(accepted)>1 and all(s.fileno()>=0 for s in accepted) larger=listener(16) for _ in range(6):client=fresh();client.connect(larger.getsockname()) assert queued(larger.getsockname()[1])==6 print(json.dumps({'kernel':platform.release(),'somaxconn':somaxconn,'tcp_abort_on_overflow':abort, 'requested_small_backlog':1,'queued_before_accept':depth,'next_connect_timed_out':timed_out, 'counter_changes':changes,'accepted_connections_still_open':len(accepted),'larger_backlog_queued':6},sort_keys=True)) ''' result=subprocess.run(['docker','run','--rm','-i','--network','none',image,'python','-'], input=program,capture_output=True,text=True,timeout=30) assert result.returncode==0,result.stderr record=json.loads(result.stdout);print(json.dumps(record,sort_keys=True)) 第一个监听器请求 backlog 为一,并故意不接受连接。在记录环境里,两次客户端 connect 完成,/proc/net/tcp 也报告两条排队连接;下一次 connect 超过客户端设定的二百五十毫秒超时。脚本记录实际深度,而不是断言每个 Linux 版本在这个参数下都必须接受恰好两条。配置参数、实际队列和客户端结果分别被观察。 ...

十月 25, 2027 · 2 分钟 · XB Field Notes
先执行启动检查,成功后才开始就绪与存活检查。

启动探针成功后,其他探针才开始执行

Startup 探针为容器提供单独的初始化契约。在已经配置的启动探针成功之前,Kubernetes 不会执行这个容器的 liveness 或 readiness 探针。这比“宽限期内忽略一些存活失败”更具体:另外两种检查根本还没有开始。启动检查成功之后,后续健康和就绪判断才按各自规则接管,不能把三个名称理解成同一个接口的不同标签。 为每个阶段定义可观察条件 应用进程必须先运行,启动探针才可能成功。因此,容器已经 Running 时,container status 中的 started 仍可能为 false,所在 Pod 也可能尚未 Ready。运行状态描述进程正在执行,启动成功则描述配置的检查条件已经满足。提前开放的 HTTP 监听端可以报告进度,但不应该因此自动声称全部准备已经完成。 启动条件应代表必要的本地准备,例如加载索引或验证初始状态。只有打开端口确实就是所需前置条件时,单纯检查端口才足够。如果监听器先出现,昂贵的初始化稍后才结束,过早成功的检查就会提前解除存活保护,再次造成原本想避免的重启循环。接口名称不会替应用定义初始化完成的真实含义。 启动检查一旦成功,kubelet 就停止当前容器实例的 startup 探针,并按独立配置运行存活与就绪检查。启动成功不是永久健康证明:应用稍后仍可能变成未就绪,或者需要重启。新的容器实例还要重新完成自己的启动检查,不能继承上一实例已经成功的结论。把实例身份和状态变化一起记录,才能解释重复启动的原因。 坏掉的样子 启动探针如果一直不成功,最终会达到失败阈值。Kubelet 随后终止容器,并根据对应重启策略处理。因此,启动保护不是允许初始化无限等待。周期、超时和失败阈值应依据真实初始化耗时设置,同时考虑环境中可能发生的资源争用与可以接受的恢复时间,避免只按开发机器的一次快速启动选择参数。 failureThreshold 与 periodSeconds 的乘积可用于规划启动预算,但不能把它写成所有实际重启都严格遵守的秒表。初始延迟、探针执行与调度、终止宽限和重启退避也影响完整时间线。排查慢发布时,应记录真实尝试和状态变化,而不是仅根据两个配置数字推断容器应该已经重启,或断定平台行为错误。 除非通过 startup 明确建立依赖,否则不应认为 liveness 会等 readiness 成功才开始。就绪与存活原本回答两个持续运行阶段的问题。反过来,启动成功也不会强迫就绪成功:后续 readiness 可能因为另一个条件拒绝流量资格,而进程仍然正常存活。把这些结果分开,才能选择正确恢复动作,避免用反复重启处理暂时不可服务。 ...

八月 22, 2027 · 3 分钟 · XB Field Notes
日历上标出低流量窗口,也是围绕谁有时间真正读结果来选。

变更窗口为人设计,不是为服务器

一次发版既是技术时刻,也是人与协作的时刻。选在什么时候,在于谁会在场看结果。 流量只是算式的一半 选在流量最低的时段发布,用户受影响的面确实最小,但它与人撞了个满怀:凌晨运维最可能不在场、或正在处理别的告警。如果发布在半夜半途失败而无人留意,静默故障可以持续数小时才被人类读取。低流量是好的起点,但它回答的是"流量"而不是"风险"。要在减少用户波及与"是否有人来得及反应"之间权衡,优先选择你确实能观察结果的那个窗口,而不是把少一千个用户看得比回滚能力更重。 尊重人的时钟 窗口要服从的不是服务器,而是人。想一想出了问题谁要行动:值班的 SRE、业务负责人、开发团队,把他们的注意力安排进时段。再想想最近还有哪些变更正在凑热闹——新发布的版本、定时任务、自动迁移——别让它们在同一窗口和你绞成一团,把一次发布变成连环事故。与其选一个"并发更小但没人在场"的时段,不如选一个相关的人清醒、可联系、且尚未超载的时段,这才是真正划算的发布时机。 让窗口成为习惯 让发布变成有计划、一遍遍彩排过的仪式,而不是被动救火。固定节奏——同样的星期几、同样的钟点,支持人员在场、回滚预案就绪——让团队学会预期它、并在它不对劲时迅速反应。围绕这个节奏把能自动化的部分(冒烟、金丝雀、回滚按钮)做扎实。久而久之,固定的例行让服务与团队一起变得可预期:人人都知道变更长什么样、如何观察它的结果,部署就不再是一场手忙脚乱的火警演习,而是每天照常练习的熟练活。 坏掉的样子 第一种坏法,是窗口之外的紧急变更没有通道。线上正在着火,值班却被告知等下个窗口,于是有人绕过流程直接推生产,无人盯盘,也无回滚说明。窗口本意是门禁,可如果没有破窗程序——谁来批准、谁来盯着、事后补什么记录——每一次例外都会变成暗箱发布。解法不是把窗口开得更大,而是把正常窗口收窄,同时把紧急通道写清楚,演练到和正常发布一样熟。 第二种坏法,是窗口之内的批量堆积踩踏。四五个服务挤进同一两个小时,仪表盘信号混在一起,错误率一抬头,谁也说不清到底是谁引起的。回滚从一键按钮变成多方谈判,因为回滚其中一个,就要猜另外几个的状态。窗口必须限流:一次只放一个高风险变更,大版本错周排队,每个条目进窗口前写清负责人、健康信号和回退路径,否则不许进。排队是为了让归因清晰,值班才敢果断回滚。 第三种坏法,是跨时区窗口错位。作者写周三上午十点,心里是柏林时间,洛杉矶的同事按本地时间理解,结果在半数响应人熟睡时发布。夏令时切换还会悄悄再挪一小时。窗口必须连时区一起存、按读者本地时间展示,排班时逐一确认真有人在场,才能把这个时段算作有人值守。凡是只写钟点不写时区的窗口,迟早会在某个深夜兑现为误操作。 快速验证 把下面程序保存为 change_window_check.py,运行 python3 change_window_check.py。它只用标准库,把每个窗口都当作时区感知的时间段,结论只有两种:allow 直接放行,needs-approval 需要审批。演示表里放了两个周三上午窗口——柏林一个、纽约一个,再加一个冻结日。坏样本是故意留下的反例,仅作拦截演示。 from datetime import datetime, time from zoneinfo import ZoneInfo # 演示窗口表:周三上午,柏林一个、纽约一个,各自按本地时区解释。 WINDOWS = [ {"name": "berlin-wed", "weekday": 2, "start": "09:00", "end": "11:00", "tz": "Europe/Berlin"}, {"name": "newyork-wed", "weekday": 2, "start": "09:00", "end": "11:00", "tz": "America/New_York"}, ] FREEZE_DATES = frozenset({"2027-12-24"}) def _hhmm(value): hour, minute = map(int, value.split(":")) return time(hour, minute) def decide(moment, windows=WINDOWS, freeze=FREEZE_DATES): # 无时区的时间直接判为需审批,避免把本机时间误当成窗口时间。 if moment.tzinfo is None or moment.utcoffset() is None: return "needs-approval", "naive datetime carries no zone; attach a ZoneInfo first" if moment.date().isoformat() in freeze: return "needs-approval", f"freeze date {moment.date()}; emergency path only" for window in windows: local = moment.astimezone(ZoneInfo(window["tz"])) if local.weekday() == window["weekday"] and _hhmm(window["start"]) <= local.time() < _hhmm(window["end"]): return "allow", f"inside {window['name']} ({local:%Y-%m-%d %H:%M %Z})" return "needs-approval", f"outside all windows ({moment:%Y-%m-%d %H:%M %Z})" def check(actual, expected, label): status, reason = actual assert status == expected, f"{label}: got {status} ({reason})" print(f"{label}: {status} -- {reason}") def main(): berlin = ZoneInfo("Europe/Berlin") newyork = ZoneInfo("America/New_York") good_berlin = datetime(2027, 8, 4, 9, 30, tzinfo=berlin) good_ny = datetime(2027, 8, 4, 9, 30, tzinfo=newyork) check(decide(good_berlin), "allow", "good/berlin-wed-morning") check(decide(good_ny), "allow", "good/newyork-wed-morning") # 同一 UTC 时刻在不同时区的读法不同:07:30 UTC 是柏林 09:30,却是纽约 03:30。 same_instant = datetime(2027, 8, 4, 7, 30, tzinfo=ZoneInfo("UTC")) check(decide(same_instant), "allow", "good/same-instant-via-berlin") evening = datetime(2027, 8, 4, 22, 10, tzinfo=berlin) check(decide(evening), "needs-approval", "bad/wednesday-night") wrong_day = datetime(2027, 8, 5, 9, 30, tzinfo=berlin) check(decide(wrong_day), "needs-approval", "bad/thursday-morning") frozen = datetime(2027, 12, 24, 9, 30, tzinfo=berlin) check(decide(frozen), "needs-approval", "bad/freeze-date") naive = datetime(2027, 8, 4, 9, 30) check(decide(naive), "needs-approval", "bad/naive-datetime") misread = datetime(2027, 8, 4, 9, 30, tzinfo=ZoneInfo("America/Los_Angeles")) check(decide(misread), "needs-approval", "bad/timezone-misread") print("PASS: 3 allows and 5 approvals; zones and freeze list behave as documented") if __name__ == "__main__": main() 运行后应看到三行放行、五行需审批,最后打印 PASS。柏林与纽约的周三上午各自命中自己的窗口;同一个 UTC 时刻经柏林换算落在窗口内也会放行,尽管它在纽约还是深夜。周三深夜、周四上午、冻结日、无时区时间,以及把柏林九点半误读成洛杉矶九点半的样本,都会被判为需审批,并给出原因。 ...

七月 29, 2027 · 2 分钟 · XB Field Notes
告警分组与静默主题封面,展示观察、判断与行动三个步骤。

Alertmanager 分组组织告警,静默抑制通知

一个事故可能产生很多告警,但一组告警并不能证明它们具有同一个原因。Alertmanager 根据配置的标签与路由上下文组织告警,便于一起处理通知,组内仍然保留各条告警的独立身份。静默则是另一种操作:在指定时间窗口内抑制匹配通知,并不会替应用解决故障。理解这些差异,才能准确解释告警页面与通知数量的变化。 分组键决定哪些细节放在一起 配置 group_by: [alertname, service] 时,两条属于结算服务的 QueueHigh 告警可以进入同组,即使实例标签不同。搜索服务的 QueueHigh 属于另一组,结算服务的 DiskFull 也因为告警名不同而单独成组。分组依据被选择的标签值,不要求所有成员具有完全相同的指纹。通知被合并后,实例级细节仍然可以从成员列表查看。 把实例加入分组键会增加组数,也可能抵消跨副本合并的目的;省略服务又可能把不同负责人或工作负载混在一起。应围绕排查方式和责任归属选择分组键,同时保留查看成员的入口。特殊值 ... 表示使用所有标签分组,实际效果接近关闭更大范围的聚合,不能把它当作自动寻找合理分组方式的通配符。 路由上下文同样重要。发往不同接收器的告警,即使所选分组标签相同,也不一定组成同一份通知。应验证路由与分组的组合行为。负责整个服务的接收器可能适合合并多个实例,另一个集成则可能需要不同分组契约。任何一种设置都不会自动证明这些告警具有相同根因,因果关系仍然需要从事件与运行证据判断。 初次等待、组内变化和重复提醒使用不同计时 group_wait 延迟新组的第一份通知,让相近时间到来的告警可以加入。某条告警若在初次等待结束前已经恢复,可能完全不产生通知。group_interval 决定之后检查组内更新的节奏,包括新增触发成员或恢复成员;它不是承诺每隔这么久无条件重发所有尚未结束的组。 在没有其他通知内容变化时,repeat_interval 控制重复提醒。是否到达重复时机是在组周期上检查的,因此应选择兼容的整数倍,而不是把它理解为完全独立且精确的闹钟。发送处理与失败还可能增加延迟。应依据响应目标设置这些参数,并将规则等待时长与通知调度分别考虑,避免只看其中一个参数就承诺发现到送达的时间。 坏掉的样子 分组太细,会让一组副本故障产生很多通知;分组太宽,又可能看不出具体服务需要处理。页面只显示一个组,不意味着只剩一条告警。判断事故是否改善前,应展开成员并核对分组键,不能把通知变少直接当成故障减少的证据。通知数量也可能只是由策略变更或抑制行为造成。 静默条件过宽,可能抑制计划维护范围之外的告警。应限制时间窗口,并检查哪些活动告警实际匹配。静默到期只是恢复通知资格,不会修复应用,也不保证立即发出一条消息。抑制规则则是独立的配置关系,由活动的源告警抑制匹配目标通知,与人工设置有时限静默不同,不能把两者的原因和结束条件混为一谈。 快速验证 把程序保存为 grouping.py,在 Docker 可用时执行 python3 grouping.py。使用 Alertmanager 0.28.1,必要时获取镜像。真实接口接收四条合成告警。接收器没有发送集成,初次通知等待也故意设置得很长,因此实验只观察分组和抑制状态,不会向外部发送消息,也不接触已有告警实例。 import http.client import io import json import subprocess import time import uuid name = "xbcatm-monitoring-" + uuid.uuid4().hex[:12] class ResponseSocket: def __init__(self, data): self.data = data def makefile(self, mode): return io.BytesIO(self.data) def request(method, path, body="", content_type="application/json"): body = body.encode() headers = (f"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" f"Connection: close\r\nContent-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n\r\n").encode() result = subprocess.run( ["docker", "exec", "-i", name, "/bin/nc", "-w", "3", "127.0.0.1", str(port)], input=headers + body, capture_output=True, timeout=5, ) if result.returncode: raise RuntimeError(result.stderr.decode()) response = http.client.HTTPResponse(ResponseSocket(result.stdout)) response.begin() return response.status, response.read().decode() def get_json(path): status, body = request("GET", path) assert status == 200, (status, body) return json.loads(body) def eventually(check): for attempt in range(150): try: value = check() if value: return value except (RuntimeError, AssertionError, http.client.HTTPException): pass time.sleep(0.1) raise AssertionError("Expected local server state did not appear") from datetime import datetime, timedelta, timezone port = 9093 script = r"""set -eu cat > /tmp/alertmanager.yml <<'ALERT_CONFIG' route: receiver: preview group_by: [alertname, service] group_wait: 1h group_interval: 5m repeat_interval: 4h receivers: - name: preview ALERT_CONFIG exec /bin/alertmanager --config.file=/tmp/alertmanager.yml --storage.path=/tmp/data \ --web.listen-address=127.0.0.1:9093 --cluster.listen-address= """ try: subprocess.run( ["docker", "run", "-d", "--rm", "--name", name, "--network", "none", "--entrypoint", "/bin/sh", "prom/alertmanager:v0.28.1", "-c", script], check=True, capture_output=True, text=True, timeout=20, ) eventually(lambda: request("GET", "/-/ready")[0] == 200) labels = [ {"alertname": "QueueHigh", "service": "checkout", "instance": "api-a"}, {"alertname": "QueueHigh", "service": "checkout", "instance": "api-b"}, {"alertname": "QueueHigh", "service": "search", "instance": "api-c"}, {"alertname": "DiskFull", "service": "checkout", "instance": "api-a"}, ] assert request("POST", "/api/v2/alerts", json.dumps([{"labels": x} for x in labels]))[0] == 200 groups = eventually(lambda: (groups if len(groups := get_json("/api/v2/alerts/groups")) == 3 else False)) sizes = {g["labels"]["service"] + "/" + g["labels"]["alertname"]: len(g["alerts"]) for g in groups} assert sizes == {"checkout/QueueHigh": 2, "checkout/DiskFull": 1, "search/QueueHigh": 1} now = datetime.now(timezone.utc) silence = {"matchers": [{"name": "service", "value": "checkout", "isRegex": False, "isEqual": True}], "startsAt": (now - timedelta(seconds=1)).isoformat(), "endsAt": (now + timedelta(minutes=10)).isoformat(), "createdBy": "local-fixture", "comment": "Disposable grouping test"} status, body = request("POST", "/api/v2/silences", json.dumps(silence)) assert status == 200 silence_id = json.loads(body)["silenceID"] def silenced_state(): alerts = get_json("/api/v2/alerts") count = sum(silence_id in x["status"]["silencedBy"] for x in alerts) return alerts if count == 3 else None alerts = eventually(silenced_state) assert len(alerts) == 4 assert all((silence_id in x["status"]["silencedBy"]) == (x["labels"]["service"] == "checkout") for x in alerts) assert request("DELETE", "/api/v2/silence/" + silence_id)[0] == 200 def unsilenced_state(): alerts = get_json("/api/v2/alerts") return len(alerts) == 4 and all(silence_id not in x["status"]["silencedBy"] for x in alerts) eventually(unsilenced_state) print(json.dumps({"group_sizes": sizes, "silenced_alerts": 3, "alerts_remain_visible": 4, "silence_expired": True}, indent=2)) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 输出应显示三个组:结算服务的 QueueHigh 有两个成员,结算服务的 DiskFull 有一个,搜索服务的 QueueHigh 也有一个。随后,临时静默匹配结算服务,抑制三条告警,但四条告警仍然都能通过接口看到;搜索服务告警不在静默范围内。程序再主动结束静默,并确认各告警的静默标识列表中不再包含该标识。 ...

七月 27, 2027 · 3 分钟 · XB Field Notes
一张排班表,多位工程师轮换共享值班。

值班轮换把负担分得均匀

合理的值班是设计,而不是某个人的宿命。轮换、交接与演练,让被深夜叫醒成为一种共同、可持续的日常。 一份被共同承担的职责 值班是团队的事,不是某一位英雄的。轮班把「谁上次没睡好就谁来」变成一张明确的排班表、一套流程,让被打断分担到每个人头上,而不是让一个人独自背完一整条告警队列。而具体排班好不好,就在于「换人不换上下文」这六个字——交接若只换名字不换现状,同一个故障会在每次轮换时被重新发现一遍。所以轮班的灵魂不是「谁那天晚上在」,而是「责任与上下文如何平稳移交」。 交接是工作的一半 一次值班最容易断的,恰是交接这个环节。当接手的人从零开始、一片黑地接过来,本来该是交接的事就变成了重新破案。堵住这个缝只需要两样:一份书面交接(当前坏在哪、试过哪些、下一棒该试什么),以及一段重叠时间——让接班的人跟着旧的人一起盯岗,旧的人在正式放下前把现场讲完。一张只用来排名字的表,会一次次把同一个事故变成新事故。做出这套的难点,不在排满班,而在排完后还留得住上下文。 在铃声响起前演练 排上一次必须在铃响之前,而不是等它响。低峰期把 runbook 平铺地走一遍,练的是「恢复路径」而不是症状集;每一条 pager 都应映射到一份写好的计划,让雪夜里的铃声到来时,它是 checklist 而不是谜。你提前练熟的那部分是肾上腺素上来后团队唯一不会搞砸的部分;平静时候一遍遍过的常规动作,正是把「到时候再想」变成「按部就班照做」的地方。 坏掉的样子 单人长期值班看似省事,实则先压垮的是人。一个人连续几个月拿着寻呼机,即使没有告警也睡不踏实,看到通知就心慌,最后要么倦怠请辞,要么在一次深夜故障中彻底失手。谷歌站点可靠性工程把这种无边界值班明确称为人员配置缺陷,而不是奉献。解法是设定单次值班上限,并保证下线周真正下线,上周的主值班不再兼任隐形备援。 升级链断裂是第二种常见死法。备援从不确认、联系方式过期,或者所谓升级到经理只是发进一个无人查看的邮箱,深夜三点主值班只能独自拆弹。升级链要像恢复流程一样演练:在工作时间实际寻呼一次备援,确认有人能在几分钟内应答,而不是只在通讯录里写个名字。 告警疲劳是第三种慢性失效。几百条噪声大、无法行动的告警教会团队寻呼机在说谎,于是唯一真实的那条也被顺手划掉。每条告警都应有负责人、有可执行的 runbook 链接、有经过上次误报调优后的阈值,无法行动的告警宁可降级为工单,也不要继续消耗信任。 交接缺失则让轮换只换名字不换上下文。周一换人时没有书面状态,周二就把周一的排查从零再做一遍。必须要求简短交接记录:当前症状、已尝试步骤、下一步动作,并在旧主值班真正下线前保留十五分钟重叠时间,把现场讲完再走。 快速验证 在让任何人牺牲周末之前,先在纸面上验证公平性。下面脚本用五人按周轮换演示:遇到节假日所在周自动跳过且不消耗轮次,最后打印人均负担统计,让管理者一眼看出是否有人被多排。 把代码保存为空目录下的 rota_probe.py。 用 Python 3.9 及以上版本运行 python3 rota_probe.py,仅用标准库,不访问网络,不读写文件。 确认输出逐周排班表、人均次数统计和末尾 PASS 行;若出现断言失败,说明轮换逻辑已经退化,需要先修排班再排人。 from collections import Counter from datetime import date, timedelta TEAM = ["ana", "ben", "cara", "dev", "elin"] START = date(2027, 8, 2) # a Monday WEEKS = 22 HOLIDAYS = {date(2027, 8, 9), date(2027, 8, 30)} # Monday public holidays rota = [] # list of (monday, person or None) cursor = 0 for week in range(WEEKS): monday = START + timedelta(weeks=week) if monday in HOLIDAYS: rota.append((monday, None)) # holiday week: nobody on call, pointer stays continue person = TEAM[cursor % len(TEAM)] rota.append((monday, person)) cursor += 1 assigned = [person for _, person in rota if person is not None] counts = Counter(assigned) for monday, person in rota: label = person if person else "holiday-skip" print(f"{monday.isoformat()} -> {label}") print("counts:", dict(sorted(counts.items()))) # Load-balance statistics: 20 assigned weeks / 5 people = 4 each. assert len(assigned) == WEEKS - len(HOLIDAYS) == 20, len(assigned) assert set(counts) == set(TEAM), counts assert all(count == 4 for count in counts.values()), dict(counts) assert max(counts.values()) - min(counts.values()) == 0, dict(counts) # Holiday weeks consume no turn and hold nobody. assert all(monday not in HOLIDAYS for monday, person in rota if person is not None) assert sum(1 for _, person in rota if person is None) == len(HOLIDAYS) # Nobody works two calendar turns in a row (pointer only advances on assigned weeks). for prev, current in zip(assigned, assigned[1:]): assert prev != current, (prev, current) print("PASS: 20 on-call weeks across 5 people, 4 each, holidays skipped, no back-to-back") 脚本共分配二十个值班周,两个节假日周空出且不推进指针,随后断言三件事:每人恰好四周、前后两周不是同一人、节假日当天无人值班。打印出的计数让不均一目了然,负责人每季度复盘时看的正是这个信号。该断言已在 macOS、Python 3.12 下运行通过。模型只覆盖按周轮换,不含昼夜拆分、跨时区接力与节假日补偿,仍需团队另行约定。 ...

七月 16, 2027 · 2 分钟 · XB Field Notes
JSON 转义保护字符串内容,可选字段加引号则在值缺失时仍能保留有效记录。

结构化 nginx 日志需要同时约定转义和字段类型

看起来像 JSON 的 log_format,不一定能可靠地产生结构化事件。nginx 把变量值代入文本,而变量可能包含引号、反斜杠、控制字符,也可能完全没有数据。正确字符串转义与正确字段类型是两个独立要求。用真实解析器检查输出,能发现肉眼查看配置或 nginx -t 成功无法证明的问题,避免上线后才发现部分日志无法进入查询系统。 坏掉的样子 把 User-Agent 原样插入带引号的 JSON 字段,遇到请求头中的引号就会失败。关闭转义不会更安全,只会让请求字符改变外层记录语法。nginx 默认日志转义则有另一种问题:它可能使用十六进制 \xXX 序列,而这不是合法 JSON 字符串转义。日志适合人阅读,不代表它符合另一种数据格式的语法。 加入 escape=json 可以处理 JSON 字符串转义,但不会自动添加引号、提供 null,或把变量转换成数字。对于本地生成的响应,上游耗时可能根本不存在。如果没有定义可选值的表示方式,就直接不加引号输出,字段可能在语法上缺值;即使实际存在的字符全部转义正确,整个 JSON 仍然无效。 缺失值表示也取决于实测版本的转义路径。nginx 1.28.0 默认转义会为找不到的变量写入短横线,JSON 转义则不写变量文本,所以外面的双引号最终成为空字符串。解析器还必须把这种情况与变量本身确实包含短横线、或包含多次上游尝试列表的情况区分开,不能把所有形式都硬当成同一个数字。 快速验证 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建包含四种日志格式的临时配置,验证语法,发送两次真实 HTTP 请求,解析生成文件,最后清理自己的容器、镜像标签和网络。请求头都是公开测试字符串,不读取真实凭据或业务日志: ...

七月 15, 2027 · 2 分钟 · XB Field Notes
探测可以改变健康状态,却不会自动重启应用进程。

Docker 健康检查报告状态,恢复动作需要明确的消费者

Docker HEALTHCHECK 在容器内运行配置的命令,并把结果记录为健康状态。它提供了主进程是否运行之外的另一项观察,但含义取决于命令检查什么:进程存在、HTTP 响应,还是应用特定的就绪条件。Docker 不会因为出现 HEALTHCHECK 指令,就自动推导出一份通用业务就绪契约,检查者必须明确这个信号能证明哪些行为。 坏掉的样子 总是返回零的命令,即使应用无法提供有用工作,也会产生看起来正常的健康报告。shell 命令序列还可能因为最后一条命令成功,而掩盖前面的失败。反过来,如果探测依赖一个非必需外部服务,就可能把仍可工作的应用标成不健康,并促使消费该状态的系统执行不必要恢复。探测含义必须符合实际消费者需求。 独立 Docker 的重启策略响应容器终止,不是只要健康状态变成 unhealthy 就触发。加入 restart=always,不会让 Docker 因探测失败而重启仍然存活的应用。同样,健康检查也不会自动从所有代理的路由表中移除容器。必须有控制器或代理明确读取该状态,并实现预期动作,不能把观察配置当成恢复配置。 探测时序也容易被误解。重试阈值统计连续失败,启动宽限则改变早期哪些失败会被计数。启动期的一次成功可以结束初始宽限行为。检测耗时取决于命令持续时间、间隔、超时和配置,不能简单承诺一次请求失败就立刻停止流量。解释延迟时,应沿实际探测时间线检查,而不是只看一个 retries 数字。 快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。它只创建一个临时镜像和容器,关闭容器外部网络,使用自身回环 HTTP 服务。被改变的状态仅是实验内部一个无害就绪文件,结束后清理自己的容器和标签: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def inspect(container):return json.loads(docker('inspect',container).stdout)[0] def wait_for(container,wanted): deadline=time.monotonic()+25 while time.monotonic()<deadline: state=inspect(container) if state['State']['Health']['Status']==wanted:return state time.sleep(.1) raise AssertionError('health did not reach '+wanted) name='xbcatm-health-'+uuid.uuid4().hex[:10];tag=name+':probe' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'server.py').write_text('from http.server import BaseHTTPRequestHandler,HTTPServer\nfrom pathlib import Path\nclass Handler(BaseHTTPRequestHandler):\n def do_GET(self):\n code=200 if Path("/ready").exists() else 503\n self.send_response(code);self.end_headers();self.wfile.write(str(code).encode())\n def log_message(self,*args):pass\nHTTPServer(("127.0.0.1",8080),Handler).serve_forever()\n') probe='import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080/health",timeout=.5).read()' (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY server.py /server.py\nHEALTHCHECK --interval=1s --timeout=1s --retries=2 --start-period=5s CMD ["python", "-c", {json.dumps(probe)}]\nCMD ["python", "/server.py"]\n') docker('build','--network','none','-t',tag,str(root)) began=time.monotonic() docker('run','-d','--name',name,'--network','none','--restart','always',tag) initial=inspect(name);assert initial['State']['Health']['Status']=='starting' first=wait_for(name,'unhealthy');assert first['State']['Running'] and first['RestartCount']==0 assert first['State']['StartedAt']==initial['State']['StartedAt'] assert first['State']['Health']['FailingStreak']>=2 assert any(log['ExitCode']!=0 and '503' in log['Output'] for log in first['State']['Health']['Log']) time.sleep(max(0,12-(time.monotonic()-began))) stable=inspect(name);assert stable['State']['Running'] and stable['RestartCount']==0 assert stable['State']['StartedAt']==initial['State']['StartedAt'] and stable['State']['Health']['Status']=='unhealthy' docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").touch()') healthy=wait_for(name,'healthy');assert healthy['RestartCount']==0 and healthy['State']['Health']['FailingStreak']==0 docker('exec',name,'python','-c','from pathlib import Path;Path("/ready").unlink()') unhealthy=wait_for(name,'unhealthy');assert unhealthy['RestartCount']==0 and unhealthy['State']['Running'] assert unhealthy['State']['StartedAt']==initial['State']['StartedAt'] print('starting -> unhealthy -> healthy -> unhealthy follows the real HTTP probe; failure logs contain HTTP 503') print('restart=always does not restart a still-running process solely because its health is unhealthy') finally: docker('rm','-f',name,check=False);docker('image','rm',tag,check=False) /ready 不存在时,服务返回 HTTP 503。Dockerfile 健康检查使用 Python HTTP 客户端,遇到该状态会抛出错误并以失败状态结束。配置为一秒间隔、一秒超时、两次重试、五秒启动期。容器采用 restart=always 启动,初始检查记录健康状态为 starting,随后观察实际探测结果。 ...

七月 5, 2027 · 2 分钟 · XB Field Notes
声明创建缺少的账号,同时保留已经存在的身份记录。

systemd-sysusers 创建缺少的服务账号

服务包经常需要先准备专用账号,才能拥有文件或避免以 root 身份运行。systemd-sysusers 用一份小配置声明账号及其组,核心操作是创建缺少的身份,并建立要求的成员关系。它不是通用账号迁移引擎,不负责创建所有目录,也不保证自动分配的数字标识在每台机器上都相同。把这些职责分清楚,才能判断声明已经执行究竟证明了什么。 坏掉的样子 u 条目创建缺少的系统用户,通常也创建同名主组;g 创建组,m 添加附加组关系。ID 写成 - 表示自动分配,r 条目可以限制分配池,但已有账号占用了某个数字,就会改变剩余可用范围。因此相同的分配池并不能建立跨机器身份一致性,尤其不能因为两边使用同一份配置,就假定共享存储里的数字所有者一定对应同一人。 更改既有账号的声明,不会自动为它重新编号,也不会自动更新家目录和描述字段。这种行为便于重复执行包初始化,但同时意味着配置差异不等于活动账号记录已经发生变化。真正迁移 UID,需要另外处理既有文件归属、正在运行的进程、共享存储和回退策略;不能只改一行声明,再把命令返回成功当作迁移完成。 家目录列设置的是账号数据库字段,不会创建实际目录。因此,一个服务可能已经拥有有效账号,却仍因为状态路径不存在或不可写而启动失败。需要目录时,应使用合适的受管理服务目录或 tmpfiles 规则,明确创建、所有权和权限。账号存在与目录可用,是两项独立条件,应沿真实服务启动路径分别验证。 快速验证 在装有 systemd-sysusers 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。它不修改机器正在使用的账号数据库,实测环境为 Debian 12、systemd 252.39。每组测试都通过 --root 指向单独的临时根目录;shadow 数据库只在内部比较内容,不会把值打印出来: from pathlib import Path from tempfile import TemporaryDirectory import subprocess definition='''r - 61000-61010 u field-worker - "Field worker" /var/lib/field-worker /usr/sbin/nologin g field-readers - m field-worker field-readers ''' def prepare(root,occupied=None): (root/'etc').mkdir(parents=True) passwd='root:x:0:0:root:/root:/bin/sh\n';group='root:x:0:\n' if occupied is not None: passwd+=f'occupied:x:{occupied}:{occupied}:Fixture:/nonexistent:/usr/sbin/nologin\n' group+=f'occupied:x:{occupied}:\n' (root/'etc/passwd').write_text(passwd);(root/'etc/group').write_text(group) directory=root/'usr/lib/sysusers.d';directory.mkdir(parents=True) (directory/'field.conf').write_text(definition) def apply(root): result=subprocess.run(['systemd-sysusers','--root='+str(root)],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr def entries(root,name): return {line.split(':')[0]:line.split(':') for line in (root/'etc'/name).read_text().splitlines()} with TemporaryDirectory(prefix='xbcatm-sysusers-') as directory: first=Path(directory)/'first';second=Path(directory)/'second';prepare(first);apply(first) worker=entries(first,'passwd')['field-worker'];groups=entries(first,'group') assert worker[3]==groups['field-worker'][2] assert 'field-worker' in groups['field-readers'][3].split(',') assert worker[5:]==['/var/lib/field-worker','/usr/sbin/nologin'] assert not (first/'var/lib/field-worker').exists() before={name:(first/'etc'/name).read_bytes() for name in ('passwd','group','shadow','gshadow')} apply(first) assert before=={name:(first/'etc'/name).read_bytes() for name in before} print('user, primary group, and supplementary membership created; repeat leaves databases unchanged') print('home and nologin shell recorded; home directory was not created') prepare(second,int(worker[2]));apply(second) other=entries(second,'passwd')['field-worker'] assert other[2]!=worker[2] print('same allocation range and definition choose different UIDs when existing accounts differ') changed=definition.replace('u field-worker - "Field worker" /var/lib/field-worker', 'u field-worker 62000 "Changed description" /changed-home') (first/'usr/lib/sysusers.d/field.conf').write_text(changed);apply(first) assert entries(first,'passwd')['field-worker']==worker print('changing a declaration does not renumber or rewrite an existing account') assert not (first/'changed-home').exists() print('only temporary alternate-root account databases were changed') 第一份根目录起初只有样本 root 账号。应用声明后,会生成工作用户、它的主组、读者组,以及工作用户的附加组成员关系。脚本检查数字主组关系和实际成员列表,而不是只检查配置能够解析。这样可以明确证明创建结果满足所声明的访问结构,避免把一个存在的组误认为用户已经加入其中。 ...

六月 8, 2027 · 2 分钟 · XB Field Notes