日历上标出低流量窗口,也是围绕谁有时间真正读结果来选。

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

一次发版既是技术时刻,也是人与协作的时刻。选在什么时候,在于谁会在场看结果。 流量只是算式的一半 选在流量最低的时段发布,用户受影响的面确实最小,但它与人撞了个满怀:凌晨运维最可能不在场、或正在处理别的告警。如果发布在半夜半途失败而无人留意,静默故障可以持续数小时才被人类读取。低流量是好的起点,但它回答的是"流量"而不是"风险"。要在减少用户波及与"是否有人来得及反应"之间权衡,优先选择你确实能观察结果的那个窗口,而不是把少一千个用户看得比回滚能力更重。 尊重人的时钟 窗口要服从的不是服务器,而是人。想一想出了问题谁要行动:值班的 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
分批逐个换 Pod、全程不断服务的部署。

滚动更新分批换上新版本

小批量是全部诀窍:只有替代 Pod 确认健康后,这一波才停掉一个旧 Pod。它不像 recreate 那样一次性翻转整个舰队去赌——控制器让大部分旧版本继续 serving,一个 Pod 一个 Pod 地验新版本。坏镜像于是只会卡住滚动,而旧版本还在线,而不是几秒内把能跑的舰队全换成坏的。 波次的节奏由两个数字定,懂不懂它俩,就是发布时心如止水还是心惊胆战的区别。太激进,坏版本带走的容量超过幸存者能扛的;太保守,安全补丁一小时才渗完,你在旁边看表。靠谱的值来自对 headroom 的了解,而不是抄默认值。 浪涌与缺口 maxSurge 为滚动期间多出来的 Pod 预留余量,maxUnavailable 则限制控制器一次最多停掉多少个旧 Pod。十个副本配 maxSurge 2、maxUnavailable 1,舰队就在十一和九之间呼吸:最多提前起两个替代,最多同时缺一个旧 Pod。怎么组合这一对,是速度与峰值容量之间的取舍——数字越大滚得越快,但要更多集群余量,万一新版本作妖,窟窿也更大。 两个旋钮的失效方向不同,值得记牢。Surge 超花的是资源:在紧张的集群里设高了,替代 Pod 因没地方落而 Pending,滚动卡在容量而不是健康上。Unavailable 超花的是风险:设高了,一半舰队可能同时下线,坏镜像叠加坏运气就把服务带走了。有状态或单 leader 的负载一般配 maxUnavailable 0——绝不主动低于满员;无状态 web 层则可以跑快一点。 健康是换班的门槛 替代 Pod 只有 readiness 检查过了才进就绪,所以控制器会在两波之间等着。这正是滚动更新"安全而不只是渐进"的机制:流量只会流向证明过自己能 serving 的 Pod,而"证明"指的是你的探针,不是容器运行时的乐观。一个真正 exercised 应用的就绪探针——查数据库连通性的 HTTP 端点,而不是端口 TCP 通了就行——才是坏发布和用户之间的那道墙。 ...

六月 7, 2027 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现产物进入下一个环境的过程。

让已经验证的产物逐级进入环境

预发布测试说明的是当时实际运行的产物与环境。生产部署前重新构建,会得到另一份输出,需要检查它的身份,或者重新验证它的行为。产物晋级指把已验证的那份文件继续交给下一个环境,并记录这次决定。它减少了一类不确定性,但不会让预发布与生产环境自动等价,也不会免除部署之后的运行检查。交付字节一致,是发布判断的一部分,而不是全部。 明确究竟把什么向前传递 使用不可变的软件包标识或内容摘要定位构建结果。会移动的标签、分支名乃至源码提交,本身都不足以唯一证明部署了哪些字节。同一源码的两次构建,可能因为工具链、依赖解析、嵌入设置或打包步骤而不同。可复现的重建确实可能得到完全相同的字节,但应先比较结果,再把它视为之前通过测试的同一产物,而不是仅凭源码相同推断。 应区分构建阶段与发布阶段。发布组合包含特定产物及该部署需要的配置,运行过程再把这个组合放到真实依赖和数据之上。记录产物身份与合适的配置版本,同时避免泄露秘密。如果前端环境设置在打包时直接编译进 JavaScript,修改设置就会改变字节,也就是生成了另一份需要验证的产物。不能一边修改包内内容,一边继续沿用原包通过测试的身份。 坏掉的样子 某些流水线名义上在晋级产物,部署脚本却悄悄重新编译;另一些流程下载了正确归档,却又覆盖归档内部的配置文件。这两种操作都改变了测试时的输入。应明确哪些设置位于不可变负载之外,并通过应用已有的配置接口读取。校验和如果发生在后续修改之前,就不能继续代表运行时文件;核验时点与文件变化顺序必须一起审查。 即便产物相同,数据库结构、功能开关、依赖版本、权限、资源限制或网络路径变化,也能让行为不同。晋级保证的是字节连续性,不是整个执行上下文都一致。部署后仍要验证关键生产行为;预发布结果无法在生产重现时,要比较真正相关的环境差异,而不是看到摘要相同就认定问题与发布无关。配置也是发布组合的一部分,需要可追溯的变化记录。 回滚同样有前提。旧程序可能无法理解新写入的数据,或者无法适应已经完成的破坏性结构迁移。把指针切回旧产物,并不会撤销这些外部影响。恢复说明应关联数据兼容性、配置和依赖状态;不能反向恢复时,就应准备必要的向前修复。不能把“重新选择旧镜像”描述成所有故障都适用的完整回滚方案。 快速验证 把代码保存为 promote.py,执行 python3 promote.py。示例只使用 Python 标准库、临时目录和绑定回环地址的 HTTP 服务。它写出一份很小的应用文件,记录摘要,再复制进两个本地环境。production-simulation 只是临时目录的标签,不会触发外部部署,也不会接触真实生产配置。这个命名用于让输出清楚地区分两个运行位置。 from hashlib import sha256 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from tempfile import TemporaryDirectory from threading import Thread from urllib.request import ProxyHandler, build_opener import json import runpy import shutil source = 'def render(config):\n return {"build": "local-build-41", "tier": config["tier"]}\n' digest = sha256(source.encode()).hexdigest() def verify(path): if sha256(path.read_bytes()).hexdigest() != digest: raise ValueError('artifact changed') def handler(path, config): verify(path) render = runpy.run_path(str(path))['render'] class Handler(BaseHTTPRequestHandler): def do_GET(self): data = path.read_bytes() if self.path == '/artifact' else json.dumps(render(config)).encode() self.send_response(200) self.send_header('Content-Length', str(len(data))) self.end_headers() self.wfile.write(data) def log_message(self, *args): pass return Handler with TemporaryDirectory() as directory: root = Path(directory) artifact = root / 'build.py' artifact.write_text(source) opener = build_opener(ProxyHandler({})) observed = [] for tier in ('staging-local', 'production-simulation'): destination = root / tier destination.mkdir() deployed = destination / 'app.py' shutil.copyfile(artifact, deployed) config = {'tier': tier} server = ThreadingHTTPServer(('127.0.0.1', 0), handler(deployed, config)) thread = Thread(target=server.serve_forever) thread.start() try: origin = f'http://127.0.0.1:{server.server_port}' with opener.open(origin + '/artifact', timeout=5) as response: received_digest = sha256(response.read()).hexdigest() with opener.open(origin + '/', timeout=5) as response: rendered = json.load(response) assert received_digest == digest assert rendered == {'build': 'local-build-41', 'tier': tier} observed.append({'artifact_sha256': received_digest, 'response': rendered, 'config_sha256': sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()}) finally: server.shutdown() thread.join() server.server_close() deployed.write_text(source + '# rebuilt\n') try: verify(deployed) except ValueError: changed_rejected = True else: changed_rejected = False assert changed_rejected print(json.dumps({'environments': observed, 'changed_copy_rejected': changed_rejected})) 每个服务启动之前,脚本都核对复制文件是否符合原始摘要,再加载其中的应用函数。请求 /artifact 会返回部署文件的真实字节;请求 / 则用该环境单独提供的配置执行应用函数。两次 HTTP 下载得到相同的产物摘要,两份应用响应也都声明 local-build-41。这些结果结合文件复制与实际请求,建立了本地传递的证据。 ...

二月 17, 2027 · 2 分钟 · XB Field Notes
一个开关决定已上线的代码是否对用户可见。

功能开关把发布与暴露解耦

部署代码与暴露代码是两回事。功能开关是一个运行时开关,决定已上线的代码是否生效,让你可以持续发布,而不必重新部署就能切换行为。 先「空投」,再打开 功能开关把「代码上线」和「用户可见」拆成了两件事。新代码默认藏在开关背后,发布了也什么都不会变,直到有人把它从关闭换成打开。这个分离让新功能能搭正常的发布车走:所谓回滚,无非是把开关关掉,比拆代码快得多。发布和曝光就成了两个不同的动作——可以周一上线,等配置真到该亮的时候再把功能点亮。更妙的是,「空投」阶段就能验证运行时的健康度:已经起来了、启动日志对、指标正常,而用户那边一无所感。 求值只做一次:每次请求进入时计算一次结果然后向下传递。未知开关必须当作关闭处理,这样配置通道出问题也不会变成意外发布。 放量与一键关停是超能力 当开关不只是 on/off,它的价值才显现。灰度,先放 10% 流量,盯着错误率和延迟,系统稳了再加比例,不用一上来就整套换。而最值钱的还是那个关闭键:开着的功能一旦不听话,把开关关掉 = 不用发版就把事故剔除。乱用开关也是坑:无人认领的开关会越攒越多,几乎永远停在「开」的开关悄悄占了不该占的目录端口。所以要有个节奏,全量放完之后尽快复核并删除这个开关。 粘性比分桶比例本身更重要。按用户标识这种稳定键分桶,同一个账号每次看到同一版本;按时间戳或随机抛硬币分桶,同一个人会同时看到新旧两半,对照数据也就被污染了。 让开关保持诚实 开关一多就成了解不开的拼图,一把散乱开关是配置化分支的灾难。命名要说明它是什么、谁开的、默认什么,代码里只留一处定义,让开关指向的功能清清楚楚。还要把开关关闭态当成真实路径来测:只有开了才出现的 bug,本来就不该存在;一次亲密的对齐放量就够检验它。要点:开关是短命的工具,不是一套永久状态机。 每个开关在创建时就要写明负责人与过期时间,并在放量开始之前先建好删除任务。 坏掉的样子 最常见的坏法是漂移:开关推到百分之百之后,删除任务从未排期,几个月后新旧两套分支都还在维护,谁也不敢删。临时门控就这样变成了永久分叉,测试矩阵翻倍,死代码躲在没人敢动的开关后面。根治办法是把删除写进放量计划。 镜像故障是默认开而非默认关。服务把从未配置过的开关读成开,或者开关客户端在取不到配置时选择失败为开,本来应该是空投的上线瞬间把未完成的功能暴露给所有人。客户端应当失败为关,未知求值一律按关处理。 同一次请求内求值打架也很常见:页面按用户标识分桶,写路径却按会话标识分桶,或者放量比例在请求中途被改掉,答案走到一半翻转。用户看到的是新界面,提交却走了旧处理器。正确做法是每次请求只求值一次,下游全部复用这个决定,并把求值原因记下来。 最后是无人清理的孤儿开关。死开关越积越多,重命名后的开关悄悄回退到默认值,两个重叠开关互相矛盾却没有定义谁赢。应当审计哪些开关真正被求值过,对长期无人求值的开关告警,并在完成放量的同一个版本里删掉开关及其分支。 快速验证 把下面代码保存为 flag_check.py,用 Python 3.10 以上版本运行 python3 flag_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。脚本模拟一个带一键关停的百分比开关,按用户稳定分桶,未知用户默认关闭,并把每次求值连同原因写入审计日志。 import hashlib class Flag: """One percentage flag with kill switch, default-closed, and audit log.""" def __init__(self, key, percent): self.key = key self.percent = percent self.killed = False self.audit = [] def _bucket(self, user): digest = hashlib.sha256(f"{self.key}:{user}".encode()).hexdigest() return int(digest, 16) % 100 def evaluate(self, user): if self.killed: decision, reason = False, "killed" elif not user: decision, reason = False, "default-closed" else: hit = self._bucket(user) < self.percent decision, reason = hit, ("in-bucket" if hit else "out-of-bucket") self.audit.append({"user": user, "decision": decision, "reason": reason}) return decision flag = Flag("new-checkout", 20) users = [f"user-{i}" for i in range(200)] # 1. Default closed: a missing user never gets the new path. assert flag.evaluate("") is False assert flag.evaluate(None) is False # 2. Stickiness: the same user gets the same answer twice. first = flag.evaluate("user-1024") second = flag.evaluate("user-1024") assert first == second # 3. Kill switch overrides every bucket. flag.killed = True assert flag.evaluate("user-1024") is False assert flag.evaluate("user-7") is False flag.killed = False # 4. The dial moves exposure for a fixed fixture set. open_20 = sum(flag.evaluate(u) for u in users) flag.percent = 100 open_100 = sum(flag.evaluate(u) for u in users) flag.percent = 0 open_0 = sum(flag.evaluate(u) for u in users) flag.percent = 20 assert open_0 == 0 assert 0 < open_20 < len(users) assert open_100 == len(users) # 5. A different bucketing key disagrees somewhere: the inconsistency to avoid. session_flag = Flag("new-checkout-session", 20) mismatch = [u for u in users if flag.evaluate(u) != session_flag.evaluate(u)] assert mismatch, "expected at least one user where user-key and session-key disagree" # 6. Audit log holds one entry per evaluation with its reason. assert len(flag.audit) == 2 + 2 + 2 + 200 * 3 + 200 assert len(session_flag.audit) == 200 assert all(set(e) == {"user", "decision", "reason"} for e in flag.audit) print(f"default-closed empty user: off; sticky user-1024: {'on' if first else 'off'} twice") print(f"dial 0/20/100 over 200 users: {open_0}/{open_20}/{open_100}") print(f"killed flag forces off; user-key vs session-key disagree on {len(mismatch)} users, e.g. {mismatch[0]}") print(f"audit entries: flag={len(flag.audit)}, session_flag={len(session_flag.audit)}") print("PASS: default-closed, sticky bucketing, kill switch, dial, and audit log behave as asserted") 放量百分之二十时,空用户保持关闭,被关停的开关对所有具名用户强制关闭,同一用户两次求值结果一致。在两百个固定用户上,百分之零、二十、一百三档分别打开零人、三十五人、两百人。按会话键构造的第二个求值器会在几十个账号上给出不同答案,这正是单次求值规则要消除的不一致。审计日志为每次求值保留一条带原因的记录。这个小模型不覆盖分布式配置下发与放量分析,真实开关系统仍需对照验证。 ...

二月 9, 2027 · 2 分钟 · XB Field Notes
两套相同的栈,路由在其中拨动流量。

蓝绿部署让两套环境随时待命

同一套服务准备两套完整环境、都能对外服务,由路由器决定流量进哪一套。新版本先部署到闲置那一侧,在线上那侧继续服务的同时跑完整条流水线——准备、迁移、部署、冒烟。测试通过后,发布就是一次路由变更。 路由变更才是这个模式真正的产品。因为发布时不需要重建任何东西,回滚就是再切一次、切回那套还热着的环境;而就绪与否在看流量之前就已经判定。约束在于被切换的只有路由器:数据库、schema,以及所有已经建立的连接,都还停在原地。 切换就是发布 这个模式要的是两套环境,不是同一份配置的两个副本。闲置那一侧要按与线上相同的容量准备好并且健康,流水线才有真实的地方可跑。在切换那一刻之前,发布不会碰到正在服务的那套环境。 那一刻是一个单一决策点:一条 DNS 记录、一个负载均衡后端、一条网关路由。它是否原子,取决于路由层怎么应用这次变更,而不是取决于模式的名字。把变更推给五个各自按自己节奏应用的实例,实测会出现 59 毫秒的窗口,其间一部分实例还在服务旧颜色、一部分已经切到新颜色,282 次采样里有 47 次两种颜色并存;而翻转一个共享指针只产生一次状态变化,完全没有混合窗口。 两套环境的配置也必须一致,否则在新颜色上跑通的冒烟并不能证明什么:一旦两侧的实例规格、环境变量或依赖版本不同,通过的测试只说明绿色这一侧能跑,而不是这次发布在生产上能跑。 回滚是指针移动 旧颜色仍在跑上一个版本,所以恢复的耗时由路由变更决定,而不是由重建加重新部署决定。这正是坏发布便宜的原因,而它只在旧颜色仍然正确时成立——一次向前的 schema 迁移可以悄悄把这个前提拿走。 回滚路径本身也要演练:真正的回滚不只是把指针切回去,还包括确认旧颜色仍能读当前 schema、旧颜色的进程还活着、上一版的镜像还在仓库里。这三件事任意一件不成立,指针切回去也救不回这次发布。 代价是常态化的双倍容量:两套完整环境都要监控、打补丁、付费,而任意时刻总有一套闲置。这是预算决策而不是技术决策,也是当快速回滚不值 100% 开销时,团队改用滚动更新的诚实理由。 数据库没有两种颜色 两套颜色连的是同一个库,所以 schema 不是蓝绿能隔离的东西:只有新颜色看得懂的迁移,对还在服务的那一侧就是故障。安全的形状是先扩后收——用追加的方式加上新列,让两侧都跑在彼此都懂的 schema 上,等最后一条旧连接消失之后再删旧列。 最后这个条件恰恰最常被做错,因为排空窗口由活得最久的连接决定,而不是由路由器决定。在一次 200 条连接的模型里,切换前建立的连接在翻转之后仍向旧颜色发出了 7448 次请求,一直持续到最老那条连接 2.0 秒的生命周期结束。 坏掉的样子 最经典的是切换在所有实例上并非原子,于是两套颜色短暂地同时服务。它表现为部署期间很小比例的请求返回了错误的版本——页面看着像上一个发布、响应里少了一个字段——而且除非响应里带着是哪套颜色服务的,否则完全看不见。给每个请求记下颜色,再在部署时间点附近找混合窗口。 第二种是共享 schema。把列改名的迁移对新颜色没问题、对旧颜色是致命的,而且爆炸半径是数据而不是报错:旧颜色仍在通过一条含义已经改变的路径写库。长连接会让它更糟,因为翻转之后旧颜色在整个排空窗口里都还在接收写入。 快速验证 确认路由器指向哪套颜色、两套颜色是否都就绪、以及线上响应是否一致: kubectl -n prod get deploy -l app=checkout -o custom-columns=NAME:.metadata.name,VERSION:.metadata.labels.version,READY:.status.readyReplicas kubectl -n prod get ingress checkout -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}{"\n"}' for i in $(seq 1 50); do curl -sS https://checkout.example.com/-/version; done | sort | uniq -c 网关指到的那套颜色必须完全就绪,五十次采样必须只报告一个版本。采样里出现两个版本,说明切换不是原子的;网关指向的颜色就绪数为零,说明这次切换会把站点打挂。 ...

十一月 2, 2026 · 1 分钟 · XB Field Notes
一根被分成若干可度量步骤、逐步移动的杠杆。

发布像拨开关,不如像拉闸

部署是工程风险与生产相遇的地方。一次把改动瞬间切到 100%,会把任何错误都变成全有或全无的事故。分阶段发布则把同一份改动,变成一系列小步、可逆的度量。 等量百分比不是真正的安全 一次把流量切到 100%,不是安全,而是在赌你能在整支舰队倒下来之前看清日志。等量的百分比本身不是一个数字的算术:你打翻一个服务时,50% 的流量照样是一次完整事故。渐进发布真正买到的是可逆性——在大多数人还在旧版本上时,能够停下来、退回;于是 5% 阶段的坏版本被及时发现,而其余 95% 的用户根本无感。这才是要守的属性,而不是百分比这个算术本身。 发布期间要盯的信号 发布应该由服务已经在用的信号来把关:错误率、延迟、饱和度,但要在金丝雀这一小片人群上算,而不是整支舰队。把关要真正有用,就必须能自动停下来并回滚,因为一个事故正在烧时盯着面板发呆的人,只是在守着问题,而不是在守系统。阈值在发布前就定好,接进流水线里,当指标越过整个系统内部划定的那根线时,由它自己喊停,而不是等着某个人回过神来。 按钮与阶段 把发布做成一份带序号的分阶段清单:每个阶段一个百分比、一段等停、一个明确的「停下并回滚」决策点,于是发布脚本本身就成了检查表。最后一个阶段也许就叫「满量后继续观察」,因为有些失败只会在流量饱和、旧版本完全排空之后才现形。要把脚本当成一件生产台账来维护——阈值、回滚路径都要在发布之夜以前很久就落在代码里,而不是发布时靠人脑里想象。 把阶段排成时间表 1%→10%→50%→100% 这样的梯子之所以有效,是因为每一级回答的问题都不一样。1% 抓的是预发环境永远复现不了的失败:起不来的进程、必定超时的查询形状;正因为金丝雀的分母很小,毛刺才格外醒目。50% 回答的是新版本能不能真正扛住线上负载,然后才让旧版本排空。级与级之间的等待和百分比同样重要:三十秒就晋级的阶段只采样到了最快的请求,所以每个百分比都要配一段浸润时间和一个健康门禁。这里正是发布策略与功能开关拨盘的区别:开关决定谁能看到新代码,发布决定每一批人被观察多久、用什么指标晋级、用哪一条命令回滚。 坏掉的样子 一刀切到 100% 的失败在于回滚太慢:切过去只要几秒,回滚却要重新构建、重新推送、重新过健康检查,坏版本于是要服务全量流量整整二十分钟,而流水线在身后重建它刚刚替换掉的版本。如果当时有金丝雀,这个爆炸半径本可以收敛到 1%。 镜像故障是停在半路却无人值守的放量:流水线在周五晚上卡在 10% 的黄色门禁上,两个版本带着不同的行为跑了整个周末,周一早上一点恢复,放出去的是一个根本没人看过的版本。停住的放量需要的不是多一行面板,而是一个负责人和一条告警。 第三种坏法是比例与健康脱钩:阶段靠定时器推进,或者门禁看的是全舰队的平均错误率,于是金丝雀 15% 的失败被稀释成全量的 1.5%,放量亲手把自己已经量到的事故推向全量。门禁必须只看金丝雀人群的信号,越线就停,而不是记一条警告;旧版本要一直留到 100% 的浸润结束,否则回滚按钮还在,服务却回不去了。 快速验证 把下面代码保存为 rollout_check.py,用 Python 3.10 以上版本运行 python3 rollout_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。脚本把四阶段放量各跑一遍,一遍用健康版本,一遍用故障版本,每个阶段只看金丝雀人群的错误率,越过 2% 就熔断回滚。断言覆盖三件事:健康版本一路晋升到 100%,故障版本在第一级就被拦下并回滚,且故障版本永远到不了 100%。最后一个断言是全量平均的陷阱:同样的金丝雀失败摊到 10% 的流量上只有 1.5%,看全量指标的门禁会直接放行。 ...

九月 18, 2026 · 2 分钟 · XB Field Notes