决定一篇文章何时可见的前置字段

Hugo 发布选项决定哪些页面进入产物

Hugo 在构建运行时判断哪些页面应当渲染。文章元数据提供发布时间与草稿状态,项目配置和命令参数提供包含规则,构建时钟提供判断所用的时间。生成目录一旦部署,在下次产物替换之前不会自行变化。因此,日期走到午夜不等于触发发布;没有写 draft: true 的文件,也不会自动被当成草稿。这两个边界决定了排期内容能否按预期上线。 元数据和构建选项分别起作用 在默认规则下,标记为 draft: true 的页面不进入产物;发布时间在未来的页面也会被排除,已经过了失效时间的页面同样如此。Hugo 根据 front matter 配置解析各类日期,显式的 publishDate 可以不同于页面显示的 date。对必须精确到某个边界的排期,应使用包含时区的完整时间戳,避免本机和构建服务器把同一个日期解释为不同时间。 --buildDrafts 包含草稿,--buildFuture 包含未来内容,--buildExpired 包含已失效内容,短选项分别是 -D、-F 和 -E。这些开关处理不同条件:一篇发布时间尚未到达的草稿,需要同时允许草稿和未来内容才能提前进入产物。项目配置或环境配置也能启用相同行为,所以只看文章里的标记不足以判断实际发布结果,还必须检查本次构建的有效选项。 本站明确设置了 buildFuture: true,用于保留包含未来日期文章的既有公开目录。因此,本站这些文章的日期不是保密截止时间,也不能用 Hugo 的默认排期规则推断它们尚未公开。修改发布策略之前,需要把配置和预期 URL 清单一起比较,否则关闭一个看似普通的开关,就可能让大量已经存在的地址消失。介绍默认机制与解释本站事实,应当采用各自对应的证据。 预览产物仍然可以被公开 预览开关只改变输出包含什么,不会给产物增加禁止部署的属性。如果流水线上传了用 -D -F 生成的目录,草稿和未来页面一样可以被访问。因此,预览应使用独立输出位置,正式发布则根据计划中的选项重新构建。Git 发布标签只是标识源码历史,既不会改变 Hugo 的包含规则,也不能证明某个生成目录已经经过审阅。交付记录需要关联源码版本、构建选项和实际产物。 按时发布还需要在发布时间之后执行新的构建与部署。反过来,把已经公开的页面重新标记为草稿,也不会撤回服务器上的旧 HTML 或缓存副本。需要重建、替换被服务的产物,再检查公共地址。Hugo 通常不会自动删除输出目录里的所有遗留文件,因此独立的新目录或合适的清洁构建,是证明被排除页面确实不存在的必要步骤。导航里不显示链接,远不足以证明内容已经下线。 ...

三月 18, 2028 · 2 分钟 · XB Field Notes
分批替换实例、而不是一次性全换的发布策略。

Kubernetes 滚动更新逐步替换 Pod

maxSurge 与 maxUnavailable 两个窗口,给一次发布过程中最多能多出或缺少的 Pod 数定了上限,让升级始终在容量可控的前提下进行。 一次只换一小批 滚动更新不会把整份负载一次换掉,而是一批一批地先起新、再撤旧,并且整条链路都尊重 readiness。控制器会等新的 Pod 变就绪,才认为这次发布往前挪了一步,于是那些始终就绪不了的 Pod 会卡住这次发布,而不是偷偷用坏版本扛流量。这正是旧行为:任何时刻都不会从旧版一步跨到新版、中间一刻也没有空窗。因为切换被 readiness 拦住,你会在第一批新 Pod 就发现问题、中断发布,而不是等全量上线才知道砸了。 用 surge 与 drain 两扇窗 maxSurge 与 maxUnavailable 两扇窗,规定了整个发布的形状。maxSurge 允许多少个高于期望的额外 Pod,用于边排旧边补新;maxUnavailable 允许替换期间低于期望多少个。它们一组合,既定速度也定余量:surge 给得多就跑得快,代价是要有抵抗它的容量;而 maxUnavailable:1 + maxSurge:0 则偏向容量不动,却慢,并且发布窗口可以拉得很长。挑这两扇窗,看的应该是「如果这次发坏了我能容忍多坏」,而不是挑一组看起来左右均衡的数字,因为它们字面上就限定了这次发布能触碰多少 Pod。 回滚只是重新部署 因为一次发布本身只是另一张 spec,回滚不过是把镜像指回上一次跑的那份,然后同样受 readiness 约束的批次扰动,倒着再来一遍。把上一个版本保留着、随时能起,并优先用一个你读过、测过的旧镜像,而不是现场打的补丁——这样回滚路径本身就是一条被测试过的路径。审计一致、Pod 更替受同样的窗口约束,而团队也确切知道「回滚」是什么意思,它就是一次对最后已知好版本的重新部署,没有任何别的。 先选形状,再调节奏 strategy.type 只有两个值,而默认值把这个选择藏了起来。RollingUpdate 让新旧 Pod 在 surge 与 unavailable 两扇窗里重叠;Recreate 先把旧 Pod 删光,再建新 Pod,最小可用数按构造就是零,两扇窗直接被忽略。当两个版本绝不能并存,就选 Recreate:旧代码读不回去的单写迁移、排他文件锁或单例 worker、新旧互不兼容的有线协议。姊妹篇调的是波次节奏,这篇定的是发布形状。无状态层默认 RollingUpdate;一旦选了 Recreate,就要把停机排进计划,而不是等它找上门:测一次冷启动、预拉镜像、提前公告窗口。选之前先答三问:两个版本能同时 serving 吗,旧版本占着新版本要独占的资源吗,停机窗口能量化吗。第一问答否,直接 Recreate;第二问答是也倾向 Recreate,比如旧 Pod 占着的 hostPort 或 ReadWriteOnce 卷,新 Pod 根本起不来;第三问答否,说明还没测冷启动,先测再选。 ...

十一月 27, 2027 · 2 分钟 · XB Field Notes
发布标签移到新产物,既有容器与另一个标签仍引用旧产物。

镜像标签可以移动,既有容器仍保留原先选择的镜像

镜像标签是与镜像引用关联的可读名字。如果仓库策略没有禁止重新指派,同一个标签之后可以标识另一个产物。v1.4 和 latest 的区别首先是命名惯例,而不是前者天然不可变。要复现部署,需要记录实际选择的产物,以及影响行为的平台和运行配置。只保存一个看起来像版本号的名字,并不足以证明当时运行的是哪些内容。 坏掉的样子 latest 不会要求仓库寻找最新上传镜像或最高语义版本,它是在引用省略标签时采用的惯例默认值。发布另一个版本,不会自动移动 latest;必须有人或自动化更新这个具体引用。如果把每次 push 都解释成自动更新 latest,就会错误理解发布流程,甚至误判为什么一个节点仍然取得旧版本。 标签的本地解析结果,也可能不同于仓库当前结果。拉取策略、本地镜像是否存在,以及编排工具的行为,共同决定何时查询仓库。因此,两台机器运行相同可读标签,仍可能使用不同产物。仅记录命令文本,会留下实际取回什么、是否保留缓存引用等不确定性;排查时应读取所选产物身份。 移动标签不会改写已经创建的容器所选择的镜像。重启该容器,会保留原配置的镜像和可写状态,不会按照标签的新目标重新创建实例。如果更新流程只是重新打标签,然后 restart 旧容器,命令可能成功,应用却仍运行旧内容。标签更新、容器替换和容器重启需要分别判断,不能混成同一个动作。 快速验证 先取得固定基础镜像,再用 Python 和 Docker 构建器运行脚本。实测环境是 Docker 29.3.1、Linux arm64。所有标签使用独特的本地名字,不向仓库推送任何内容;容器关闭网络,清理只删除实验自己的容器与标签。因此可以实际验证移动行为,又不改变已有发布引用: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def inspect(name,kind='image'):return json.loads(docker(kind,'inspect',name).stdout)[0] name='xbcatm-tags-'+uuid.uuid4().hex[:10];tags=[name+':one',name+':two',name+':release',name+':latest'];container=name+'-old' try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory) (root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY version.txt /version.txt\nCMD ["cat", "/version.txt"]\n') for tag,version in zip(tags,['one','two']): (root/'version.txt').write_text(version+'\n');docker('build','--network','none','-t',tag,str(root)) one=inspect(tags[0])['Id'];two=inspect(tags[1])['Id'];assert one!=two docker('image','tag',tags[0],tags[2]);docker('image','tag',tags[0],name) assert inspect(tags[2])['Id']==inspect(tags[3])['Id']==one docker('create','--name',container,'--network','none',tags[2]) docker('image','tag',tags[1],tags[2]) assert inspect(tags[2])['Id']==two and inspect(tags[3])['Id']==one assert inspect(container,'container')['Image']==one assert docker('start','--attach',container).stdout.strip()=='one' assert docker('run','--rm','--network','none',tags[2]).stdout.strip()=='two' assert docker('run','--rm','--network','none',tags[3]).stdout.strip()=='one' print('moving release changes newly created containers; the existing container still runs its original image') print('omitting a tag selected latest, but updating release did not update latest') docker('image','rm',tags[2]);assert inspect(tags[1])['Id']==two print('removing one tag leaves another reference to the same image intact; all operations stayed local') finally: docker('rm','-f',container,check=False);docker('image','rm',*tags,check=False) 脚本构建两个镜像,/version.txt 分别包含 one 与 two,镜像身份不同。它先让 release 指向第一份镜像,再用没有显式标签的仓库名标记同一镜像。检查确认,省略标签会得到 latest,而这两个引用最初都选择第一份镜像。这里直接验证默认标签规则,不靠名字含义猜测。 ...

八月 24, 2027 · 1 分钟 · XB Field Notes
把仓库固定指向一个具体提交的带名字把手。

git tag 把一次发布固到提交

发布标签为某个源码版本提供一个容易交流的名字。它与分支不同,创建新提交时不会自动前进。但 Git 并没有从物理上禁止标签变化:拥有相应权限的人仍能替换或删除标签引用。可靠的发布约定来自几件事共同作用:记录对象标识、坚持发布名称不可变,以及限制谁能够更新发布引用。不能仅凭“用了标签”就断言任何人拿到的代码一定相同。 两种标签 轻量标签是直接指向某个对象的引用,通常指向提交。附注标签则指向单独的标签对象,其中保存目标、打标签者身份、时间、说明,以及可选的签名。打标签的人并不一定是提交作者;有了这些元数据,也不意味着附注已经经过密码学签名。发布记录需要说明谁作出发布决定,而不是把提交作者与发布者混为一谈。 对于附注标签,git rev-parse refs/tags/v2.4.1 得到标签对象标识,git rev-parse 'refs/tags/v2.4.1^{commit}' 则解引用到目标提交。当说明或签名身份也是发布证据的一部分时,应同时记录二者。只比较提交会漏掉标签留言或签名者变化;只比较可读名称,则连源码是否一致也无法确定。对象标识与名称各自承担不同的核对职责。 为什么给发布签名 git tag -v 使用配置的签名后端及本地信任材料验证签名标签。密码学验证成功之后,还需要确认该密钥确实属于项目发布策略认可的人。一个陌生人的有效签名,并不能证明维护者批准了这次发布。密钥的来源、授权范围和撤销状态,都应由实际发布流程明确管理,而不是靠命令里出现“成功”两个字代替。 源码标签有签名,也不能直接证明下载的二进制就是从该源码构建的。制品校验和、构建来源记录及构建过程,需要补上这条独立关系。另外,不要把验签命令接到 head 后面,再依赖管道最后一项的退出码:只截取几行输出可能掩盖验签失败。发布检查应保留并判断验签命令自身的结果,显示多少行只是界面选择。 移动要格外小心 修正尚未分享的本地标签,与替换消费者已经获取的标签,是两种不同操作。标签一旦分发,应优先使用新版本名称,并记录旧版本被撤回的原因。修补安全缺陷也不要求重写旧标签;保留原引用反而有助于识别受影响部署。服务端的引用保护应阻止未经授权的发布标签更新与删除,不能只靠客户端的习惯维持这个约定。 如果特殊情况下确实批准替换,应公布准确的新旧对象标识,并只协调那个明确的引用。把所有标签一起强制获取,会覆盖其他无关的本地引用,不应成为常规修复建议。消费者需要知道自己持有的是哪一个对象,不能让两个不同提交在没有说明的情况下继续共享同一个发布名称。 坏掉的样子 latest 这类浮动名称可能在两次构建之间解析到不同提交。即便界面保留方便阅读的名字,也应为每个制品和每次部署记录真正解析到的提交标识。同名不保证两次构建一致,而固定提交也不会自动固定外部依赖或编译器版本。源码定位与可复现构建有关,却不能互相替代。 另一种故障是发布标签只存在于某个本地克隆。普通分支推送不一定传输它;--follow-tags 处理的是从推送提交可达、符合条件的附注标签,不是本地全部标签。应使用 git ls-remote --tags 核对准确的远端引用及对象标识,不能对版本号作模糊子串匹配,否则可能把另一个版本当成目标。远端可见只能证明分发,不能证明构建正确或签名可信。 快速验证 用 Python 3 及 Git 运行下面的实验。它创建独立临时仓库,使用示例身份,为演示明确关闭签名,并且不配置远端。所有提交和标签都位于这个临时仓库,不会给当前项目打标签;退出后由临时目录管理器清理。命令通过参数列表调用,不需要拼接或解释来自外部的 Shell 文本。 import os import subprocess import tempfile env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) with tempfile.TemporaryDirectory(prefix="git-tag-demo-") as directory: def git(*args, check=True): return subprocess.run( ["git", "-c", "user.name=Tag Example", "-c", "user.email=tags@example.invalid", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args], cwd=directory, env=env, text=True, capture_output=True, check=check, ) git("init", "-q") git("commit", "--allow-empty", "-qm", "first") first = git("rev-parse", "HEAD").stdout.strip() git("tag", "-a", "demo-v1", "-m", "example release") git("tag", "demo-bookmark") print("annotated:", git("cat-file", "-t", "demo-v1").stdout.strip()) print("lightweight:", git("cat-file", "-t", "demo-bookmark").stdout.strip()) git("commit", "--allow-empty", "-qm", "second") target = git("rev-parse", "demo-v1^{commit}").stdout.strip() print("tag-still-first:", target == first) print("head-moved:", git("rev-parse", "HEAD").stdout.strip() != first) verification = git("tag", "-v", "demo-v1", check=False) print("unsigned-rejected:", verification.returncode != 0) 开头应分别输出对象类型 tag 和 commit,随后三个判断都为真。新提交让分支前进,但标签仍指向第一个提交;未签名的附注会验签失败。这个实验有意不把“标签存在”冒充“可信签名”或“远端已经发布”:后两项仍需要项目实际认可的密钥策略,以及对真实远端引用的准确检查。 ...

六月 11, 2027 · 1 分钟 · 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
以观察、判断和行动三个面板呈现版本变化的审阅。

语义化版本让兼容性承诺更明确

依赖版本有价值的前提,是发布者与使用者对公共契约有共同认识。语义化版本为变化提供一套表达:从 1.0.0 开始,不兼容的公共接口变化需要提升主版本,兼容的功能增加需要提升次版本,兼容的问题修复需要提升补丁版本。数字归纳的是发布者对这一版的判断,并不会自行检查实现,也不能保证应用升级之后必然正确运行。理解承诺边界,比只记住三个数字更有用。 先声明兼容性覆盖哪些内容 公共接口可以包括导出的函数、正式说明的命令参数、配置格式、响应字段和明确约定的错误行为。边界应写得足够清楚,才能判断一次变化是否违约。保持契约不变的内部重构,与删除已经承诺支持的参数,不属于同一类变化。修正原先违反文档契约的行为,可能仍是兼容的缺陷修复,即便有人曾经观察并依赖那个错误表现。仍需在变更说明中解释,并验证受影响的调用方。 语义化版本规范要求:把公共功能标记为废弃时,应提升次版本;之后真正移除功能,则需要按不兼容变化处理。已经发布的某个版本,其内容不应继续变化,修正应通过另一个新版本交付。在相同版本标签背后替换文件,会破坏追溯:两个依赖声明完全相同的安装过程,可能得到不同的字节,排查问题时也无法再靠版本号明确区分。 主版本为零表示初始开发阶段,公共接口不应被视为稳定。维护者可以为这一阶段声明额外约定,但使用者不能默认所有零主版本更新都兼容。预发布版本的优先级低于对应的正式版本;加号之后的构建元数据不参与优先级比较。因此,两个版本在排序意义上相等,不意味着它们的包内容、来源或文件摘要相同。兼容性、选择顺序与产物身份是不同问题。 坏掉的样子 标错的次版本即使满足依赖范围,也可能破坏已声明的契约。标号正确的发布,也可能暴露应用对内部实现细节的依赖。升级时仍应读变更说明,运行有代表性的集成验证;需要重复解析依赖时保留锁文件。锁文件记录当时选择了哪些版本,不能替这些版本证明行为正确,更不是来源真实性的证明。版本范围允许安装,只说明解析条件满足了。 包管理器的范围语法又是另一层规则。语义化版本规范定义版本及优先级,具体工具决定约束怎样筛选候选。在 node-semver 中,插入符通常允许最左非零分量右侧的变化,所以 ^0.2.3 不接受 0.3.0,^0.0.3 也不接受 0.0.4。三个分量完整的波浪号范围,通常只允许同一次版本线内的补丁更新。不能把所有插入符都理解成“只要主版本不变就行”。 预发布筛选还有附加规则。宽泛的稳定版本范围,不会自动接纳数值更高的测试版;明确允许某个预发布三元组后,才可能接纳该三元组后续的预发布版本。应验证实际解析器,而不是用普通字符串排序推断升级关系,也不要把数值更大直接等同于可被当前范围选择。不同生态的约束语法不能仅凭相似外观相互套用。 快速验证 将代码保存为 semver.py,在已有 Node 和 npm 的环境执行 python3 semver.py。实验需要访问公共 npm 仓库。脚本在临时目录安装 semver@7.7.2,隔离配置和缓存,关闭包安装脚本,再调用这个库真实的比较函数。它不会修改当前项目的依赖声明或锁文件;结束时临时安装目录随上下文清理。 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess with TemporaryDirectory() as directory: root = Path(directory) user_config = root / 'user.npmrc' global_config = root / 'global.npmrc' user_config.write_text('') global_config.write_text('') env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'NPM_CONFIG_USERCONFIG': str(user_config), 'NPM_CONFIG_GLOBALCONFIG': str(global_config), 'NPM_CONFIG_CACHE': str(root / 'cache')} subprocess.run(['npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--registry=https://registry.npmjs.org', 'semver@7.7.2'], cwd=root, env=env, check=True, capture_output=True) script = r''' const s = require('semver'); const assert = require('node:assert/strict'); const cases = [ ['1.9.0', '^1.2.3', true], ['2.0.0', '^1.2.3', false], ['1.2.9', '~1.2.3', true], ['1.3.0', '~1.2.3', false], ['0.2.9', '^0.2.3', true], ['0.3.0', '^0.2.3', false], ['0.0.4', '^0.0.3', false], ['1.3.0-beta.1', '^1.2.3', false], ['1.3.0-beta.2', '^1.3.0-beta.1', true] ].map(([version, range, expected]) => { const accepted = s.satisfies(version, range); assert.equal(accepted, expected); return {version, range, accepted}; }); assert.equal(s.compare('1.2.3+build.1', '1.2.3+build.2'), 0); assert.equal(s.lt('1.3.0-beta.2', '1.3.0'), true); assert.equal(s.valid('1.02.3'), null); console.log(JSON.stringify({library: require('semver/package.json').version, cases, metadata_precedence_equal: true, prerelease_before_release: true, leading_zero_rejected: true})); ''' result = subprocess.run(['node', '-e', script], cwd=root, env=env, capture_output=True, text=True, check=True) print(json.dumps(json.loads(result.stdout))) 九个范围案例应当全部符合断言:^1.2.3 接受 1.9.0,拒绝 2.0.0;波浪号拒绝下一次版本;两个零主版本范围更加收敛。稳定范围拒绝 1.3.0-beta.1,明确指定预发布的范围则接受 1.3.0-beta.2。输出保留版本、范围与布尔结果,便于逐项看清究竟是哪条条件在起作用。 ...

二月 13, 2027 · 1 分钟 · 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