文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。
让已经验证的产物逐级进入环境
预发布测试说明的是当时实际运行的产物与环境。生产部署前重新构建,会得到另一份输出,需要检查它的身份,或者重新验证它的行为。产物晋级指把已验证的那份文件继续交给下一个环境,并记录这次决定。它减少了一类不确定性,但不会让预发布与生产环境自动等价,也不会免除部署之后的运行检查。交付字节一致,是发布判断的一部分,而不是全部。 明确究竟把什么向前传递 使用不可变的软件包标识或内容摘要定位构建结果。会移动的标签、分支名乃至源码提交,本身都不足以唯一证明部署了哪些字节。同一源码的两次构建,可能因为工具链、依赖解析、嵌入设置或打包步骤而不同。可复现的重建确实可能得到完全相同的字节,但应先比较结果,再把它视为之前通过测试的同一产物,而不是仅凭源码相同推断。 应区分构建阶段与发布阶段。发布组合包含特定产物及该部署需要的配置,运行过程再把这个组合放到真实依赖和数据之上。记录产物身份与合适的配置版本,同时避免泄露秘密。如果前端环境设置在打包时直接编译进 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。这些结果结合文件复制与实际请求,建立了本地传递的证据。 ...