正则声明顺序改变处理器选择,精确和最长前缀规则则保留各自优先关系。

nginx 选择第一个匹配正则,不会计算通用具体程度分数

nginx 不会给所有 location 表达式计算一份通用的具体程度分数。普通前缀按匹配长度选择,精确 location 可以结束搜索,竞争正则则按声明顺序求值。某个表达式在人看来更窄,也可能输给前面更宽的正则。把这些选择规则混在一起,是许多看似仔细的路由变更影响意外路径的原因,必须分别理解每类规则。 坏掉的样子 通用 .css 后缀正则,可以先于后面的 /assets/ 限定正则获胜。后者因为包含目录,看起来似乎更具体,但 nginx 不会比较两个模式在语义上谁更窄。在这里的平面结构中,只要两者都匹配,顺序就决定使用哪个配置。路径更长或正则文字更多,都不是正则之间的额外优先分。 调整普通前缀顺序是另一回事。把 /assets/ 移到 / 之前,不会让它“更优先”;在采用前缀结果时,更长匹配本来就决定它获胜。如果把所有 location 都当成同一个先匹配先使用列表,审阅可能做出无效调整,却漏掉真正控制故障路径的正则顺序。修改文本与修改行为之间,需要通过规则和请求证据连接。 较短的 ^~ 前缀,也不是整个子树的永久保护层。如果存在更长的普通前缀并被选中,较短的标记前缀就不再是抑制正则阶段的候选。因此,新增一个看似无害的更详细前缀,可能让原来停留在标记 location 的请求重新被后缀正则接管。这种变化不能仅凭新增块自身内容看出来。 快速验证 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建两份临时配置,分别执行 nginx -t,再通过宿主回环端口发送真实请求。每份配置都只使用平面 location,结束时清理自己的容器、镜像标签和私有网络,不修改既有服务: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='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 wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' prefixes=[ 'location / { return 200 "fallback"; }', 'location /assets/ { return 200 "asset-prefix"; }', 'location = /assets/exact.css { return 200 "exact"; }', 'location ^~ /static/ { return 200 "static-prefix"; }', 'location /static/dynamic/ { return 200 "dynamic-prefix"; }'] regexes=[r'location ~ \.css$ { return 200 "generic-css"; }',r'location ~ ^/assets/ { return 200 "asset-regex"; }'] for reverse,expected in [(False,'generic-css'),(True,'asset-regex')]: ordered_prefixes=list(reversed(prefixes)) if reverse else prefixes ordered_regexes=list(reversed(regexes)) if reverse else regexes locations='\n'.join(ordered_prefixes+ordered_regexes)+'\n'+r'location ~* \.txt$ { return 200 "case-folded-text"; }' with lab(config(locations)) as (container,request): cases={'/assets/file.css':expected,'/assets/exact.css':'exact','/static/file.css':'static-prefix','/static/dynamic/file.css':'generic-css','/static/dynamic/plain':'dynamic-prefix','/README.TXT':'case-folded-text','/elsewhere.CSS':'fallback','/elsewhere':'fallback'} for path,wanted in cases.items(): status,headers,body=request(path);assert status==200 and body.decode()==wanted,(path,status,body) print('reversing the matching regex declarations changes the winner; the first matching regex is not necessarily the most specific') print('exact locations and longest-prefix selection ignore declaration order; a longer ordinary prefix can make a shorter ^~ prefix irrelevant') 第一份配置把 .css 正则放在 /assets/ 开头的正则之前,请求 /assets/file.css 返回 generic-css。第二份反转两个正则,相同请求改为返回 asset-regex。请求没有变得更具体或更宽泛,变化的只有两个同时匹配模式的顺序。这为“正则自动选最具体”的说法提供直接反例。 ...

十二月 27, 2027 · 2 分钟 · XB Field Notes
原始请求 URI 被保留,规范化和内部跳转则改变用于路由的当前路径。

nginx 匹配规范化路径,并可能再次选择 location

nginx location 匹配使用规范化后的 URI 路径,而不是完全未经处理的请求目标文本。百分号编码字符会被解码,相对路径组件会被消解,相邻斜杠也可能按配置合并。之后的处理还能再次改变当前 URI,并触发新的 location 搜索。当请求似乎进入了与原始拼写不同的处理器时,需要同时理解这两类转换,而不是只比较配置中的字面前缀。 坏掉的样子 只根据原始请求文本判断的路径过滤器,可能与 nginx 路由视图不一致。编码斜杠可以变成路径分隔符,点段可以移除前面的组件,后端还可能采用自己的解码规则。因此,安全与路由决策需要在每个边界约定解释方式,不能假定所有组件看到完全相同的路径字符串。否则同一请求可能在检查时属于一个家族,处理时却属于另一个。 只记录 $uri 也可能掩盖实际输入。它表示当前规范化 URI,并可能在请求处理期间改变;$request_uri 则保留完整原始 URI,包括原始查询字符串。两者不能互相完全替代:前者解释当前路由,后者帮助重建客户端实际请求。诊断时应按问题选择字段,而不是把一个名字相近的变量当成全部路径信息。 index 和 try_files 还会引入另一类转换。目录请求可以内部变成索引文件请求,文件查找失败可以内部跳到回退 URI,使不同 location 和参数生效。所以,最初的前缀匹配不一定就是产生最终响应的配置。若只验证入口匹配,而忽略后续内容处理,就可能漏掉访问控制、缓存或响应状态发生变化的位置。 快速验证 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建并检查临时配置,只发布宿主回环端口,发送客户端未预先规范化的请求目标,最后删除自身镜像、容器和网络。这样可以观察 nginx 收到明确文本之后的处理,而不被浏览器自动整理路径干扰: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='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 wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' locations=r''' location / { return 200 "$uri|$request_uri|$args"; } location /protected/ { return 403 "blocked"; } location /docs/ { try_files $uri $uri/ /fallback?reason=missing; } location = /docs/index.html { return 200 "$uri|$request_uri|$args"; } location = /fallback { return 200 "$uri|$request_uri|$args"; } ''' with lab(config(locations),{'docs/index.html':'stored-index'}) as (container,request): cases={ '/a//b/../c?x=1':'/a/c|/a//b/../c?x=1|x=1', '/a%2Fb/%2e%2e/c?x=1':'/a/c|/a%2Fb/%2e%2e/c?x=1|x=1', '/Protected/item':'/Protected/item|/Protected/item|', '/docs/':'/docs/index.html|/docs/|', '/docs/missing?x=1':'/fallback|/docs/missing?x=1|reason=missing'} for path,expected in cases.items(): status,headers,body=request(path);assert status==200 and body.decode()==expected,(path,status,body) for path in ['/protected/item','/protected%2Fitem','/a/../protected/item']:assert request(path)[0]==403,path assert request('/bad%ZZ')[0]==400 print('matching uses normalized paths: percent-decoding, dot segments, and merged slashes differ from the original request URI') print('index and try_files internal redirects choose another location; request_uri remains original while uri and args can change') 请求 /a//b/../c?x=1 得到当前 URI /a/c,原始请求 URI 仍为 /a//b/../c?x=1,参数仍为 x=1。这在实测默认配置下展示斜杠合并和点段消解。响应同时包含三个字段,能够直接比较含义,不需要从最终页面内容反向猜测路径到底经过哪些转换。 ...

八月 26, 2027 · 2 分钟 · XB Field Notes
三次替换的 Pod 使用同一镜像,依次选择蓝色、绿色和保留的蓝色配置。

ConfigMap 把配置与镜像分开

ConfigMap 把非敏感配置放在镜像之外,让环境相关设置发生变化时,不必重新构建应用产物。真正有用的边界需要明确:镜像提供代码,工作负载定义则指定这些代码应该读取哪份配置。因此,一次发布不仅是一个镜像标签,配置引用同样属于正在交付的行为。只记录镜像版本,可能无法解释两个环境为什么表现不同。 给配置一个可审阅的身份 ConfigMap 的 data 值是字符串,也可以通过 binaryData 表达二进制内容及其 API 编码形式。它适合小规模配置,数据大小有一 MiB 限制,不是通用文件仓库,更不是保护秘密的机制。API 能检查对象结构,却不知道某个应用参数在业务上是否合理。因此,结构合法和业务正确仍需分别验证,不能以创建成功代替配置审查。 Pod 可以通过环境变量引用或挂载文件消费配置。普通 Pod 引用要求 ConfigMap 位于这个 Pod 所在的命名空间。名称、必需键和应用解释方式都应明确。Deployment 对象被 API 接受,并不证明它引用的每个键都存在,也不证明新容器能够带着这些值成功启动。发布检查必须继续观察真实工作负载的结果。 单位、允许值与默认值应属于应用配置规范。某个字符串看起来像布尔值或数字,并不说明接收方会采用什么转换规则。在不同环境之间推广配置之前,需要结合真实消费者检查这些规则,避免同一份文本被解释成不同业务含义。 版本化的不可变 ConfigMap 更容易表达发布身份。settings-v2 可以与 settings-v1 同时保留,而 immutable 会禁止修改当前对象的数据。但这项保护不阻止有权限的操作者删除对象,再用同名重新创建另一份内容。对象生命周期权限和发布记录,应与希望实现的不可变承诺一致,不能把一个字段理解成所有可能路径都被永久封闭。 坏掉的样子 修改配置与替换进程是两种不同动作。已经注入运行中容器的环境值,不会因为 ConfigMap 更新而被改写。普通投射文件可能最终更新,但应用仍要重新读取;subPath 挂载和已经打开的文件又有不同生命周期。因此,API 中出现了新值,并不能直接宣布应用已经完成配置切换,还需要查看消费者实际读到了什么。 把 Deployment 的 Pod 模板改为引用新的 ConfigMap,是明确的滚动替换触发条件。仅修改 ConfigMap 本身,不会自动改写这个模板。同样,校验和 annotation 只有在某个工具真正把它更新到 Pod 模板中时才起作用。Kubernetes 不会自行发现任意外部配置变化,并替应用生成期望的校验和;这部分自动化需要有真实实现。 ...

八月 20, 2027 · 3 分钟 · XB Field Notes
只有重新读取完整投射卷中的路径,才能在本例看到新的配置值。

ConfigMap 文件与环境变量有不同生命周期

修改 ConfigMap 首先改变的是 API 对象。运行中的应用是否改变行为,还取决于 Kubernetes 如何交付数据,以及应用如何读取它。环境变量注入、普通卷投射、subPath 挂载和已经打开的文件,分别拥有不同生命周期。排查时应找到真正影响业务的消费者,而不是把所有配置传递方式都看成同一种机制。 区分交付与消费 容器环境中的 ConfigMap 引用会在容器启动时解析。更新对象不会重写已有进程的环境,也不会改写同一容器后续 exec 进程所使用的配置环境。替换 Pod 并引用所需版本,可以建立新的环境快照,但这属于生命周期动作,不是修改 data 字段之后自然发生的结果。配置变更记录与进程替换记录应能够对应。 普通 ConfigMap 卷则不同,kubelet 可以在之后把新数据投射进去。延迟受到同步周期和变更发现策略影响,因此 API 修改成功与文件发生变化不是同时发生的事件。投射更新也不会自动调用应用的重新加载函数。只在启动时解析一次配置的程序,即使文件已经变化,也可能一直使用先前读入的值。 打开文件描述引用的是已经打开的文件,而不是每次都重新解析原路径。路径背后的文件被替换,不会迫使旧描述符自动跟随新文件。因此,关闭并重新打开投射路径的进程可能读取新内容,另一个保留的描述符却仍读取旧文件。即使文件系统视图已经刷新,应用自己的缓存还可能再增加一层滞后。 把投射内容复制到可写位置,又会创建一个独立快照。副本不是指向 ConfigMap 的实时链接。如果启动脚本采用这种复制方式,更新这个副本也需要单独实现,不能期待源文件变化自动传播到另一份普通文件。 坏掉的样子 通过 subPath 挂载的 ConfigMap 不会获得普通方式的自动更新。为了方便只挂一个文件,实际上可能改变了配置更新契约。不能因为某个完整目录投射刷新成功,就承诺所有来自 ConfigMap 的文件都会刷新。两个容器对同一个对象表现不一致时,应查看各自真实 volumeMount 定义,而不是只比较 ConfigMap 名称。 监听配置变化需要明确的应用协议。监听器必须处理所用文件系统和库暴露的替换行为;读到新字节之后,验证与真正采用仍然是另外两步。分别读取多个键也可能与版本切换发生竞争。应用应定义怎样加载一致的一组配置、怎样拒绝无效值,以及怎样报告自己目前真正使用的版本,避免把检测到变化直接当成已经生效。 否则,一次成功编辑可能制造分裂视图:API 说 green,重新打开的投射路径也返回 green,进程却继续按 blue 工作。直接重启全部进程也许能暂时掩盖差异,却没有解释原因。应先比较对象、交付路径和应用状态,再选择符合预期契约的重新加载或替换方式,才能让后续配置变更保持可预测。 ...

七月 1, 2027 · 3 分钟 · XB Field Notes
一个在启动时为单元注入环境变量的外部文本文件。

EnvironmentFile 在 systemd 执行进程时供应取值

环境文件把部署取值与描述进程监管的单元定义分开。systemd 在执行服务命令前读取指定文件,再把取值放入那个进程的环境。修改文件不会直接重写已经运行的进程环境。不过,重载辅助命令本身也是一个新进程,因此可能读到比现有主进程更新的文件值。理解这个执行时点,比笼统记住“改配置后做一次 reload”更可靠。 坏掉的样子 daemon-reload 要求管理器重新读取单元定义、更新依赖信息;reload 则请求服务执行已配置的重载操作,例如 ExecReload。两者都不是把现有进程环境整体替换掉的通用指令。服务可以自行实现应用配置刷新,但这种能力必须来自应用或重载辅助程序,不能仅凭管理器接受了一个重载请求就认定业务设置已经切换。 EnvironmentFile 路径前加短横线,表示这个文件可以不存在。缺失时 systemd 会忽略它,并不会发出操作者可能期待的那条警告。因此,路径笔误可能让一个覆盖配置悄悄消失。只有缺少该文件确实具有合法含义时,才适合使用可选形式;必需的部署输入不应借这个前缀被静默跳过,否则很容易把供应错误变成难以解释的默认行为。 文件由 systemd 解析,不是交给 shell 加载。它有自己的引号和转义规则,但不会把命令替换和变量展开当作 shell 执行。包含 $HOME 的值仍然是这段文本。后列出的文件覆盖前面的同名值,文件赋值又覆盖单元中的 Environment 赋值。因此,需要检查真实优先级,不能认为自己首先看见的那一行就是最终值。 快速验证 在 PID 1 为 systemd 的隔离 Linux 环境中,以管理员身份运行下面的 Python 脚本。它只使用无害的样本设置与临时路径,实测环境为 Debian 12、systemd 252.39。确认运行时单元名称尚未使用。工作程序仅记录四个特定测试值,不会把整个环境全部打印出来: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time unit='xbcatm-environment-lab.service';unit_path=Path('/run/systemd/system')/unit assert not unit_path.exists() def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],capture_output=True,text=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(key):return ctl('show',unit,'-p',key,'--value') def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'state transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix='xbcatm-environment-',dir='/run') as directory: root=Path(directory);first=root/'required.env';second=root/'optional.env';worker=root/'worker.py' command=f'$(touch {root}/unexpected)' initial=f'MODE=first\nLITERAL=$HOME\nQUOTED="two words"\nCOMMAND={command}\n' first.write_text(initial);first.chmod(0o600);second.write_text('MODE=second\n') worker.write_text("""from pathlib import Path import json,os,sys,time root=Path(__file__).parent;name=sys.argv[1] data={key:os.environ.get(key) for key in ('MODE','LITERAL','QUOTED','COMMAND')} data['pid']=os.getpid() tmp=root/(name+'.tmp');tmp.write_text(json.dumps(data));tmp.replace(root/(name+'.json')) if name=='main': time.sleep(60) """) unit_path.write_text(f'''[Service] Type=exec Environment=MODE=unit-default EnvironmentFile={first} EnvironmentFile=-{second} ExecStart=/usr/bin/python3 {worker} main ExecReload=/usr/bin/python3 {worker} reload ''') def read(name):return json.loads((root/(name+'.json')).read_text()) try: subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True) ctl('daemon-reload');ctl('start',unit) wait_for(lambda:(root/'main.json').exists()) current=read('main');assert current['MODE']=='second' assert current['LITERAL']=='$HOME' and current['QUOTED']=='two words' assert current['COMMAND']==command and not (root/'unexpected').exists() assert 'MODE=unit-default' in value('Environment') print('ordered environment files override unit value; quoting works; shell text stays literal') second.write_text('MODE=third\n') ctl('daemon-reload');assert value('MainPID')==str(current['pid']) live=Path(f'/proc/{current["pid"]}/environ').read_bytes().split(b'\0') assert b'MODE=second' in live ctl('reload',unit) assert read('reload')['MODE']=='third' and read('main')==current assert value('MainPID')==str(current['pid']) live=Path(f'/proc/{current["pid"]}/environ').read_bytes().split(b'\0') assert b'MODE=second' in live print('reload helper receives new file value; existing main process retains old environment') second.write_text('MODE=fourth\n');ctl('restart',unit) wait_for(lambda:read('main')['pid']!=current['pid']) assert read('main')['MODE']=='fourth' print('restart reads changed environment file without another daemon-reload') first.unlink() subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True) ctl('restart',unit,success=False) assert value('ActiveState')=='failed' and value('MainPID')=='0' print('missing required environment file prevents start') first.write_text(initial);first.chmod(0o600);second.unlink() ctl('reset-failed',unit);ctl('start',unit) wait_for(lambda:read('main')['MODE']=='first') print('missing optional file is ignored; required-file value remains') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) unit_path.unlink();ctl('daemon-reload') 主进程最初得到第二份文件中的模式值:可选的第二份文件覆盖必需的第一份文件,第一份又覆盖单元默认值。带引号的内容正确解析为包含空格的两个词,看起来像变量和命令的文本则保持字面值;如果真经过 shell 替换本应产生的那个文件,也确实没有出现。实验同时验证了优先级与解析边界,而不是只展示几行配置样子。 ...

二月 19, 2027 · 2 分钟 · XB Field Notes
一份声明式、可重复执行的指令集,把机器一步步推向目标状态。

Ansible playbook 声明想要的状态,而不是步骤

Ansible 的 playbook 记录的是机器应该拥有的状态。部署中途崩溃后,下一次运行会先观察真实现状,只补上缺少的部分,而不是从头盲目重放每条命令。它是一份可以执行的契约:inventory 决定对象,模块实现比对,操作者审阅即将发生的变化。 只写终点,不写每一步 命令式 shell 脚本会说“按顺序执行这些命令”;playbook 则说“这个包应该已安装、这个文件应该是这些字节、这个服务应该启用并运行”。模块在动作前比较目标主机与目标状态,已经满足的任务报告 ok,只有不一致才报告 changed。同一份声明可以用于全新的机器、只配置了一半的机器,也可以用于被人手工改过的机器。 这里的目标不能只是模糊意图。state: present、模板的精确内容、所有者和权限、服务的 enabled/running 都是契约的一部分。把多台机器共用的值放到变量或 role 没问题,但最终值应该对审阅者可见。隐藏的默认值,或者绕过模块直接改变状态的命令,都会让这份契约难以推理。每一个 task 都应回答“运行完成后应该看到什么”,而不是“我习惯先敲哪一条命令”。 模块让重跑逐渐收敛 多数内置模块都围绕幂等性设计。ansible.builtin.copy 会先比较文件内容和元数据,只有不同才替换;ansible.builtin.package 向包管理器询问目标状态;ansible.builtin.service 只在启用状态或运行状态不符时调整。第二次运行应该是一份安静的确认,而不是再次重启、追加重复行或再写一条记录。幂等不是“什么都不做”,而是“已经达到目标,所以没有必要再做”。 并非所有动作天然幂等。直接用 command 创建用户、轮换密钥或调用外部 API,若没有条件限制就会重复副作用。优先找专用模块;没有时先 register 查询结果,再用 when 判断是否执行,或者使用精确的 creates/removes 文件条件。changed_when 只决定变更报告和 handler 通知,不控制任务是否执行;设成 false 不能阻止重复变更,还可能隐瞒真实副作用。对外部接口仍要定义超时、重试与部分成功后的恢复方法,不能把网络调用假装成普通文件复制。 Inventory 是地图 playbook 描述结果,inventory 选择主机,变量提供上下文。ansible_user、SSH agent 或 Vault 可以提供凭据而不把秘密写进仓库。组变量让同一份声明覆盖整支机器队伍,同时保留端口、区域等主机差异。在 CI 中使用显式 inventory,避免“localhost”意外指向执行器本身。执行前打印解析后的主机集合,确认别名、组继承和变量优先级都与审阅时一致。 ...

一月 17, 2027 · 2 分钟 · XB Field Notes
一个带标签的配置文件,上方有一盏灯泡。

命名的配置文件偏爱清晰

配置容易找、易读、可 diff,这不只是一种偏好;它正是变更保持可评审的方式。用清晰的约定命名文件,是迈向环境特定、单一来源配置的第一步。 文件是一份意图记录 一份带名字、进仓库、能追溯版本的配置文件,本身就是一份意图记录:部署上去的每个值都是显式可查的,改动也像代码一样被 review、被 diff。当文件与项目同住仓库时,每次修改都留下 blame 与 pull request,任何人随时都能回答「当前这套是什么、谁改过」。清晰的命名约定(应用参数放 app.conf、环境形态放 app.env)让每份文件的用途一眼可见,而不是被四处散落的命令行参数悄悄改写。 环境变量与文件解决不同问题 环境变量与文件要解决的是不同问题。变量的优势在于同一份构建可以部署到很多环境而不必改镜像——运行时按环境注入不同的值。文件的好处是能表达结构化的配置,而不是用一串拼字符串的变量去硬凑。多数团队两者并用:文件负责结构,环境负责在部署时注入值。让这套做法保持诚实的是——真值的来源明确,结构与可注入值之间的边界是刻意划开的,而不是随手撞出来的。 密钥仍然要移出去 把配置文件放进仓库,并不等于就允许把密钥一起提交。任何属于凭据的东西,都必须在运行时被注入或从密钥仓库里引用,绝不能写死在你提交进去的那个文件里。配置与密钥在架构上就该分开:文件管形态,密钥库管机密。这样配置永远可以放心给人看,真实值则以加密形态躺在该躺的地方。判断规则很简单:任何一个值,只要被公开就难堪或有害,它就不该出现在提交的配置文件里。 坏掉的样子 配置名漂移会让加载器读错文件。部署时写下的是 app-prod.json,程序启动时打开的却是 app.prod.json,预期文件根本不存在,加载器又悄悄回退到一份陈旧的开发配置。服务看似正常启动,实际连错了数据库,而版本差异里没有任何痕迹,因为错的是文件名而不是某个具体的值。正确做法是启动时只解析一条显式路径并把它打印出来,找不到就直接报错退出,绝不去猜测某个名字相近的兄弟文件。 环境变量覆盖则是另一个方向的静默失败。APP_PORT 被写成空字符串,或者把下划线误写成连字符,加载器可能用空值覆盖掉文件里的正确值,也可能什么都不覆盖,具体行为取决于实现。无论哪一种,运行时的真实值都已经和仓库里的文件对不上了,却又没有留下任何差异记录,排查的人反复阅读配置文件,只会得出什么都没变的错误结论。正确做法是为每个配置键记录赢家来源,明确区分文件、环境与命令行参数,并在启动时打印出来,同时把空字符串一律视为未设置或直接拒绝,绝不把它当成合法输入。 密钥进仓库是最贵的一类失败。有人为了临时修复预发布环境,把携带生产凭据的 .env 提交上去,下一次提交又把它删掉,以为事情已经过去。实际上凭据还留在版本历史、镜像与备份里,中间每一次克隆都把它带走了,后续删除并不能收回已经扩散的秘密。正确做法是提交的文件只保留形态,把敏感位置写成 password = "from-store" 这样的占位符,真实值只从密钥库或运行时环境注入,并用提交前扫描钩子在凭据落地之前就把它拦下来,否则清理历史与轮换密钥的代价会远超当初图方便省下的时间。 快速验证 把下面程序保存为 config_precedence_probe.py,然后运行 python3 config_precedence_probe.py。它只用标准库,在临时目录里写两个示例文件,不会触碰任何真实配置。好样本从 {"host": "127.0.0.1", "port": 8000, "mode": "dev"} 出发,先用环境变量 APP_PORT=9001 覆盖端口,再用命令行 --mode prod 覆盖模式,最后断言合并结果与每个键的来源映射完全符合文件小于环境、环境小于参数的优先级。 ...

十一月 21, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调设置 TLS 下限并验证正反两侧。

TLS 版本是需要锚定的特性

TLS 版本策略决定一个端点愿意接受哪些握手。应明确最低版本,而不是假定库每次升级后的默认值都符合自己的要求。最高版本是另一项独立约束,没有必要地把它固定死,可能阻止以后采用库已经支持的新协议。无论设置了哪种上下限,都不能替代证书身份验证、合适的算法以及持续的安全更新。 最低版本与最高版本是两回事 RFC 8996 已废弃 TLS 1.0 和 1.1;RFC 9325 同时排除 SSLv2、SSLv3,建议支持 TLS 1.3 并在实现支持时优先协商它,也给出了继续使用 TLS 1.2 时应满足的安全要求。对于客户端群体明确的服务,可以评估兼容性后只允许 TLS 1.3;但不能因为存在更严格的配置,就把所有 TLS 1.2 连接一律说成不安全。 双方都支持 TLS 1.3 时,服务器同时允许 TLS 1.2,不等于攻击者可以随意强迫它们选择最弱的版本。TLS 有经过认证的协商过程,也有针对降级攻击的保护。不过,真实的旧版客户端仍然可能合法选择服务器允许的较旧版本,不安全的重试逻辑也可能削弱策略。最低版本应在每条新连接上执行,不能把“最后总算连上”当作唯一验收标准。 抓包里的版本数字还必须结合字段含义解读。TLS 1.3 为兼容性把 hello 中的旧版本字段设为 0x0303,实际版本则通过 supported_versions 协商。因此,单独看到这个旧字段,不能断定连接正在使用 TLS 1.2。有条件时应读取 TLS 库提供的最终协商状态,而不是从某一个兼容字段猜测协议。 ...

九月 23, 2026 · 3 分钟 · XB Field Notes