<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://todzhang.com/feed.xml" rel="self" type="application/atom+xml" /><link href="http://todzhang.com/" rel="alternate" type="text/html" /><updated>2026-08-13T11:54:29+00:00</updated><id>http://todzhang.com/feed.xml</id><title type="html">Highly Distinguish</title><subtitle>Highly Distinguish pty. ltd.</subtitle><entry><title type="html"></title><link href="http://todzhang.com/2025-09-09-cn-nat-setup-issue-wireguard-technology-networking-security-vpn-firewall-configuration-issues/" rel="alternate" type="text/html" title="" /><published>2026-08-13T11:54:29+00:00</published><updated>2026-08-13T11:54:29+00:00</updated><id>http://todzhang.com/2025-09-09-cn-nat-setup-issue-wireguard-technology-networking-security-vpn-firewall-configuration-issues</id><content type="html" xml:base="http://todzhang.com/2025-09-09-cn-nat-setup-issue-wireguard-technology-networking-security-vpn-firewall-configuration-issues/"><![CDATA[<p>The question isn’t who is going to let me; it’s who is going to stop me. - Ayn Rand
iPhone 连 WireGuard 无法访问内网？6 小时踩坑后，真凶竟是 Windows NAT 配置！
昨晚 10 点，我顺利在 Windows 11 系统上部署完成 WireGuard 服务端。iPhone 连接后界面亮起绿灯，一切看似准备就绪。
可当我打开 Safari，输入公司内网可正常访问的oa.example.com并回车时，屏幕却始终停留在 ——转圈加载，最终显示连接超时。
我最初判断：”肯定是 DNS 配置问题，换成 8.8.8.8 应该就能解决”。然而，经过 6 小时的反复调试，我才发现问题远比想象中复杂。
第一个坑：被 “连接成功” 的假象误导
看到 WireGuard 显示绿色连接状态，我想当然地认为 “WireGuard 已正常工作”，就像看到电脑开机便默认网络通畅一样。这是最开始的核心认知误区。
随后的 1 小时里，我逐一测试了多种 DNS 配置：尝试 1.1.1.1、8.8.8.8、114.114.114.114 等公共 DNS，甚至将手机 DNS 设置为自动获取，但内网访问问题始终未解决。
直到我猛然意识到一个关键问题：”连接成功” 不等于 “数据包能正常传输”。
WireGuard 的核心作用是建立加密隧道，就像在两座山之间打通了一条通道。但如果通道另一端没有通往目标的路径，数据依然无法抵达终点。
第二个坑：误以为 Windows 会自动配置 NAT
作为拥有 10 年 Linux 使用经验的工程师，我下意识地沿用 Linux 思维 ——认为开启 IP 转发后，NAT 会自动生效。但事实证明，这个惯性认知完全错误。
我首先通过以下命令检查 IP 转发状态：
Get-ItemProperty -Path “HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters” -Name “IPEnableRouter”</p>

<p>返回结果为 1，表明 IP 转发已启用，表面上无异常。但我忽略了 Windows 与 Linux 的关键差异：Windows 需手动配置 NAT 规则，不会随 IP 转发自动开启。
为排除设备问题，我换用 iPad 重复测试，结果同样无法访问内网，这才确认问题根源在服务端配置。
NAT 到底是什么？为何 WireGuard 离不开它？
在继续排查前，有必要先厘清 NAT 这个被多数开发者忽视的核心概念 —— 它是 WireGuard 实现内网连通的关键支撑。
NAT 的本质：IP 地址的 “翻译官”
NAT（Network Address Translation，网络地址转换）的核心功能并非简单 “转换”，而是实现 WireGuard 隧道网段与内网网段的地址 “翻译”。
用生活化场景类比：
你的 WireGuard 客户端内网 IP 为 10.8.0.100（类似 “工号”）
访问公司内网时，需统一使用服务端的内网 IP 192.168.1.100（类似 “公司对外标识”）
你访问内网服务时，服务端看到的是 “公司标识” 而非你的 “工号”
服务端的返回数据，需通过 NAT”翻译” 后转发到你的客户端 IP
NAT 就像 “前台接待”，负责内外网地址的映射与数据转发协调。
为什么需要 NAT？IPv4 地址枯竭的解决方案
早期 IPv4 协议仅提供约 43 亿个可用地址，早已无法满足全球设备联网需求。NAT 通过 “地址复用” 解决这一痛点：
公网 IP：可在互联网路由的稀缺地址，多用于服务器对外访问
私有 IP：仅用于局域网内部通信，可重复分配，无需申请
常见私有 IP 段包括：
10.0.0.0/8 (10.0.0.1 - 10.255.255.254)
172.16.0.0/12 (172.16.0.1 - 172.31.255.254)
192.168.0.0/16 (192.168.0.1 - 192.168.255.254)
家庭路由器就是典型的 NAT 设备：所有家用设备使用 192.168.x.x 私有 IP，对外访问时统一 “伪装” 成路由器的公网 IP。
WireGuard 中的 NAT：双重地址转换机制
在 WireGuard 场景中，数据传输需经过双重 NAT 转换，具体流程如下：
iPhone (客户端)          Windows (WireGuard服务端)           公司内网服务器
10.8.0.100        →        10.8.0.1                →    192.168.1.200
                         (WireGuard隧道IP)              (内网服务真实IP)</p>

<p>数据包完整传输路径：
客户端发起请求：源 IP 为 10.8.0.100，目标地址为内网服务oa.example.com
隧道加密传输：数据包通过 WireGuard 加密隧道发送至 Windows 服务端
第一次 NAT 转换：服务端将源 IP 从 10.8.0.100 修改为隧道 IP 10.8.0.1
第二次 NAT 转换：服务端再将源 IP 从 10.8.0.1 修改为自身内网 IP 192.168.1.100
抵达目标服务：内网服务器接收到的请求源 IP 为服务端内网 IP
缺少任一环节的 NAT 转换，数据包都会在传输中丢失。
第三个坑：濒临放弃时的关键发现
深夜 12 点，我已逐一排查完以下配置，却仍未解决问题：
防火墙入站 / 出站规则（确保 WireGuard 端口开放）✓
IP 转发状态（已启用）✓
系统路由表（无异常路由冲突）✓
DNS 服务器（内网 DNS 配置正确）✓
WireGuard 服务（多次重启无效）✓
我甚至开始怀疑 Windows 版 WireGuard 存在兼容性缺陷，准备切换到 OpenWireGuard 时，突然意识到一个被忽略的细节：从未验证数据包是否从 WireGuard 接口转发至物理网卡。
真凶现身：Windows NAT 配置的致命盲区
我立即打开 Wireshark 监控 Windows 的以太网接口，同时用 iPhone ping 内网中可正常访问的192.168.1.200——结果显示无任何数据包通过物理网卡传输。
这一现象明确指向问题根源：数据包成功进入 WireGuard 隧道，但未能从服务端物理网卡转发至内网。核心问题并非 IP 转发，而是未配置 NAT 转换规则。
Windows 的 IP 转发仅表示 “允许数据包转发”，但不会自动将 WireGuard 隧道的 10.8.0.x 网段 IP 转换为内网可路由的 192.168.1.x 网段 IP。
Windows 与 Linux 的 NAT 配置差异
这是跨平台开发者最易踩的坑，两者核心配置逻辑差异如下：
Linux 系统（iptables 配置，一步到位）</p>
<h1 id="开启ip转发">开启IP转发</h1>
<p>echo 1 &gt; /proc/sys/net/ipv4/ip_forward</p>

<h1 id="配置nat规则内网段1080024物理网卡接口eth0">配置NAT规则（内网段10.8.0.0/24，物理网卡接口eth0）</h1>
<p>iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE</p>

<p>Linux 通过 iptables 可同时完成转发与 NAT 配置，这让开发者形成了 “一条命令解决” 的思维定式。
Windows 系统（NetNat 配置，分步执行）</p>
<h1 id="1-开启ip转发">1. 开启IP转发</h1>
<p>Set-ItemProperty -Path “HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters” -Name “IPEnableRouter” -Value 1</p>

<h1 id="2-单独创建nat规则指定wireguard隧道网段">2. 单独创建NAT规则（指定WireGuard隧道网段）</h1>
<p>New-NetNat -Name “WireGuardNAT” -InternalIPInterfaceAddressPrefix 10.8.0.0/24</p>

<p>核心差异：Windows 将 “数据包转发” 与 “NAT 地址转换” 拆分为独立功能，必须同时配置才能实现内网连通。
深入解析 Windows NetNat 命令
New-NetNat命令的具体作用及参数说明：
New-NetNat -Name “WireGuardNAT” -InternalIPInterfaceAddressPrefix 10.8.0.0/24</p>

<p>Name：NAT 规则标识名称，用于后续管理（如修改、删除）
InternalIPInterfaceAddressPrefix：需进行 NAT 转换的内网网段（即 WireGuard 隧道网段）
执行该命令后，Windows 会自动完成以下操作：
流量识别：标记源 IP 属于 10.8.0.0/24 的数据包为 “需 NAT 转换”
接口选择：自动匹配当前默认网关对应的物理接口（以太网 / WLAN）
映射表建立：记录隧道 IP: 端口与内网 IP: 端口的对应关系
双向转发： outbound 流量修改源 IP，inbound 流量修改目标 IP
当我执行完这条命令后，iPhone 立即成功访问到内网 OA 系统 —— 困扰 6 小时的问题终于解决。
第四个坑：AllowedIPs 配置的隐藏陷阱
解决 NAT 问题后，新的异常出现：部分内网服务可访问，部分无法打开。检查客户端配置后发现，AllowedIPs参数设置存在疏漏：
AllowedIPs = 10.8.0.0/24</p>

<p>AllowedIPs 的真实作用（并非 “白名单”）
多数人误解AllowedIPs为 “允许访问的 IP 白名单”，实际其核心作用是配置客户端路由表条目。
当配置为AllowedIPs = 10.8.0.0/24时，iPhone 会自动添加路由规则：
目标网段: 10.8.0.0/24
网关: WireGuard隧道</p>

<p>这意味着：
✅ 访问 10.8.0.x 网段的流量 → 通过 WireGuard 隧道传输
❌ 访问 192.168.1.x 等其他内网网段 → 仍通过原网络（4G / 本地 WiFi）传输
因此，仅 10.8.0.x 网段的内网服务可访问，其他网段服务无法通过 WireGuard 访问。
正确配置：覆盖完整内网网段
若需所有内网访问流量均通过 WireGuard 隧道，需将AllowedIPs配置为完整的内网网段：
AllowedIPs = 10.8.0.0/24, 192.168.1.0/24, 172.16.0.0/12, ::/0</p>

<p>10.8.0.0/24：WireGuard 隧道网段
192.168.1.0/24、172.16.0.0/12：公司内网实际网段（按需添加）
::/0：涵盖所有 IPv6 地址，确保双栈网络兼容性
NAT 进阶：端口映射与连接跟踪机制
NAT 映射表的工作原理
当 iPhone 访问内网 Web 服务（如oa.example.com:80）时，Windows NAT 模块会自动创建映射条目：
隧道地址            内网服务端地址         连接状态
10.8.0.100:51234 ↔ 192.168.1.100:51234   ESTABLISHED</p>

<p>需重点关注三点：
动态端口分配：若指定端口被占用，NAT 会自动分配空闲端口
状态跟踪：实时记录连接状态，确保返回数据精准转发至客户端
超时清理：无活动连接会被自动删除，释放系统资源
Windows NAT 的限制与优化方案
相比 Linux iptables，Windows NetNat 存在部分功能限制，需根据场景优化：</p>
<ol>
  <li>端口池范围查看
通过以下命令可查看 NAT 可用端口池配置：
    <h1 id="查看nat端口池及外部地址">查看NAT端口池及外部地址</h1>
    <p>Get-NetNat | Get-NetNatExternalAddress</p>
  </li>
  <li>并发连接限制
Windows NAT 默认支持约 1000 个并发连接，个人及小型团队使用完全足够；企业级场景可通过修改注册表调整上限。</li>
  <li>静态端口映射配置
若需通过固定端口访问客户端服务（如远程桌面客户端电脑），可配置静态映射：
Add-NetNatStaticMapping -NatName “WireGuardNAT” `
 -Protocol TCP `
 -ExternalIPAddress 0.0.0.0 `
 -InternalIPAddress 10.8.0.100 `
 -InternalPort 3389 `
 -ExternalPort 33890</li>
</ol>

<p>该配置实现：访问服务端IP:33890时，自动转发至客户端 10.8.0.100 的 3389 端口（远程桌面）。
系统化故障排查方法论
基于本次踩坑经历，我总结了适用于 WireGuard 的四层排查法，可快速定位问题根源：
层级排查流程图</p>

<p>常用排查命令汇总</p>
<ol>
  <li>NAT 状态检查
    <h1 id="查看所有nat规则">查看所有NAT规则</h1>
    <p>Get-NetNat</p>
  </li>
</ol>

<h1 id="查看当前活跃nat会话">查看当前活跃NAT会话</h1>
<p>Get-NetNatSession</p>

<h1 id="查看nat端口池统计">查看NAT端口池统计</h1>
<p>Get-NetNat | Get-NetNatExternalAddress</p>

<ol>
  <li>路由表检查
    <h1 id="查看完整ipv4路由表">查看完整IPv4路由表</h1>
    <p>route print -4</p>
  </li>
</ol>

<h1 id="查看wireguard接口路由">查看WireGuard接口路由</h1>
<p>netsh interface ipv4 show route interface=”WireGuard Tunnel”</p>

<ol>
  <li>网络接口监控
    <h1 id="查看各接口流量统计">查看各接口流量统计</h1>
    <p>Get-NetAdapterStatistics | Where-Object Name -like “<em>WireGuard</em>”</p>
  </li>
</ol>

<h1 id="实时监控隧道流量每秒刷新">实时监控隧道流量（每秒刷新）</h1>
<p>Get-Counter “\Network Interface(WireGuard Tunnel)\Bytes Total/sec” -Continuous</p>

<p>5 个核心技术教训
“连接成功”≠”内网通畅”
隧道建立仅是基础，需通过抓包工具验证数据包实际转发状态，避免被表象误导。
跨平台经验不可直接套用
Windows 与 Linux 的网络栈实现差异显著，尤其是 NAT、防火墙等核心功能的配置逻辑。
NAT 是内网连通的核心枢纽
理解 NAT 地址映射机制，可解决绝大多数 VPN / 隧道的连通性问题；需重点区分 Windows NetNat 与 Linux iptables 的配置差异。
AllowedIPs 本质是路由配置
该参数并非访问控制白名单，而是客户端路由规则的定义；需根据内网网段完整配置，避免路由覆盖不全。
抓包工具是故障排查的 “终极武器”
当配置看似无误却无法连通时，Wireshark 可直观展示数据包流向，精准定位阻塞环节。
生产级一键修复脚本
结合本次排查经验，编写了适用于 Windows 环境的 WireGuard 服务端配置检查与修复脚本，可快速解决 NAT 及转发问题：</p>
<h1 id="wireguard-fix-advancedps1">wireguard-fix-advanced.ps1</h1>
<h1 id="功能检查并修复wireguard服务端ip转发nat配置问题">功能：检查并修复WireGuard服务端IP转发、NAT配置问题</h1>
<p>param(
    [Parameter(Mandatory=$false)]
    [string]$WireGuardSubnet = “10.8.0.0/24”,  # WireGuard隧道网段（按需修改）
    [Parameter(Mandatory=$false)]
    [string]$NatName = “WireGuardNAT”          # NAT规则名称
)</p>

<p>Write-Host “=== WireGuard Windows 服务端配置检查工具 v1.0 ===” -ForegroundColor Green</p>

<h1 id="1-检查管理员权限">1. 检查管理员权限</h1>
<p>if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] “Administrator”)) {
    Write-Error “错误：&lt;/doubaocanvas&gt;</p>]]></content><author><name></name></author></entry><entry><title type="html"></title><link href="http://todzhang.com/2026-04-14-why-your-url-shortener-is-a-ticking-time-bomb/" rel="alternate" type="text/html" title="" /><published>2026-08-13T11:54:29+00:00</published><updated>2026-08-13T11:54:29+00:00</updated><id>http://todzhang.com/2026-04-14-why-your-url-shortener-is-a-ticking-time-bomb</id><content type="html" xml:base="http://todzhang.com/2026-04-14-why-your-url-shortener-is-a-ticking-time-bomb/"><![CDATA[<blockquote>
  <p>“The chain is only as strong as its weakest link.” - Thomas Reid</p>
</blockquote>

<h1 id="三行代码背后的宇宙当美军封锁霍尔木兹海峡你的系统能扛住吗">三行代码背后的宇宙：当美军封锁霍尔木兹海峡，你的系统能扛住吗？</h1>

<hr />

<h2 id="什么是短链接这道题的完整解法">什么是短链接？这道题的完整解法</h2>

<p>短链接（URL Shortener）把一个很长的网址变成一个简短的链接，用户点击短链接，系统自动跳转到原始地址。</p>

<p>核心操作只有两个：</p>

<table>
  <thead>
    <tr>
      <th>操作</th>
      <th>输入</th>
      <th>输出</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">encode</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://www.example.com/very/long/url</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://tinyurl.com/aB3</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">decode</code></td>
      <td><code class="language-plaintext highlighter-rouge">http://tinyurl.com/aB3</code></td>
      <td><code class="language-plaintext highlighter-rouge">https://www.example.com/very/long/url</code></td>
    </tr>
  </tbody>
</table>

<h3 id="完整实现代码">完整实现代码</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">string</span>

<span class="k">class</span> <span class="nc">Codec</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">code_to_url</span> <span class="o">=</span> <span class="p">{}</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">url_to_code</span> <span class="o">=</span> <span class="p">{}</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">base_url</span> <span class="o">=</span> <span class="s">"http://tinyurl.com/"</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">chars</span> <span class="o">=</span> <span class="n">string</span><span class="p">.</span><span class="n">ascii_letters</span> <span class="o">+</span> <span class="n">string</span><span class="p">.</span><span class="n">digits</span>  <span class="c1"># a-z A-Z 0-9，共62个字符
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">counter</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="k">def</span> <span class="nf">encode</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">longUrl</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="s">"""Encodes a URL to a shortened URL."""</span>
        <span class="k">if</span> <span class="n">longUrl</span> <span class="ow">in</span> <span class="bp">self</span><span class="p">.</span><span class="n">url_to_code</span><span class="p">:</span>
            <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">url_to_code</span><span class="p">[</span><span class="n">longUrl</span><span class="p">]</span>

        <span class="bp">self</span><span class="p">.</span><span class="n">counter</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="n">num</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">counter</span>
        <span class="k">if</span> <span class="n">num</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="n">code</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">res</span> <span class="o">=</span> <span class="p">[]</span>
            <span class="n">base</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">)</span>
            <span class="k">while</span> <span class="n">num</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
                <span class="n">res</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="n">num</span> <span class="o">%</span> <span class="n">base</span><span class="p">])</span>
                <span class="n">num</span> <span class="o">//=</span> <span class="n">base</span>
            <span class="n">code</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="nb">reversed</span><span class="p">(</span><span class="n">res</span><span class="p">))</span>

        <span class="bp">self</span><span class="p">.</span><span class="n">code_to_url</span><span class="p">[</span><span class="n">code</span><span class="p">]</span> <span class="o">=</span> <span class="n">longUrl</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">url_to_code</span><span class="p">[</span><span class="n">longUrl</span><span class="p">]</span> <span class="o">=</span> <span class="n">code</span>
        <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">base_url</span> <span class="o">+</span> <span class="n">code</span>

    <span class="k">def</span> <span class="nf">decode</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">shortUrl</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="s">"""Decodes a shortened URL to its original URL."""</span>
        <span class="n">code</span> <span class="o">=</span> <span class="n">shortUrl</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">base_url</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">code_to_url</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="如果不用-counter会怎样">如果不用 counter，会怎样？</h3>

<p><code class="language-plaintext highlighter-rouge">counter</code> 是整个设计的核心。一旦去掉它，你必须找到另一种方式生成唯一短码。常见的两种替代方案都有致命缺陷：</p>

<p><strong>替代方案一：随机生成字符串</strong></p>

<p>随机选6个字符（如 <code class="language-plaintext highlighter-rouge">xYz123</code>）作为短码，可能碰巧和已有的短码重复。</p>

<blockquote>
  <p><strong>缺陷：</strong> 你需要一个 <code class="language-plaintext highlighter-rouge">while</code> 循环反复查库检查是否冲突，再重试。系统越满，冲突越频繁，速度越不可预测。极端情况下退化为 O(N)，甚至引发级联故障。</p>
</blockquote>

<p><strong>替代方案二：对 URL 做哈希（MD5/SHA）</strong></p>

<p>对 <code class="language-plaintext highlighter-rouge">longUrl</code> 求哈希，取前6个字符作为短码。</p>

<blockquote>
  <p><strong>缺陷：</strong> 哈希同样会碰撞（两个不同的 URL 哈希后前6位相同）。你仍需要复杂的冲突重试逻辑，且还有安全风险（哈希反推）。</p>
</blockquote>

<p><strong>系统设计核心结论：</strong></p>

<p>用自增计数器，再做 Base62 转换，是工业界最成熟的方案（大规模落地时用 Redis、ZooKeeper 或 Twitter Snowflake 实现分布式计数器）。</p>

<p>它提供两个关键保证：</p>
<ul>
  <li><strong>方向性（Directionality）</strong>：编号单调递增，时序天然有序</li>
  <li><strong>无碰撞（Collision Elimination）</strong>：整数序列不会重复，从数学上消灭了碰撞的可能，<code class="language-plaintext highlighter-rouge">encode</code> 函数真正做到 O(1)</li>
</ul>

<hr />

<h2 id="-引子当封锁消息炸开2000万人同时点击同一个链接">📡 引子：当封锁消息炸开，2000万人同时点击同一个链接</h2>

<p>2026年某日，美军宣布对霍尔木兹海峡实施封锁。</p>

<p>消息在社交媒体瞬间爆炸。一个记者发出的突发新闻链接，被疯狂转发——某平台的阅读量在10分钟内突破了2000万。</p>

<p><strong>后台工程师的分享链接服务，在那个瞬间，承受了无法预测的洪峰流量。</strong></p>

<p>有人的服务扛住了。有人的，没有。</p>

<p>差距，不在于服务器多几台。差距，在那三行代码里。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="n">num</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="n">code</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="n">num</span> <span class="o">%</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">)]</span> <span class="o">+</span> <span class="n">code</span>
    <span class="n">num</span> <span class="o">//=</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>如果你也觉得这只是”进制转换”，那么这篇文章，就是为你准备的。</strong></p>

<hr />

<h2 id="第一幕孙悟空与如来佛的赌约普通工程师的认知陷阱">第一幕：孙悟空与如来佛的赌约——普通工程师的认知陷阱</h2>

<p>先讲一个故事。</p>

<p>西游记里，孙悟空飞到天涯海角，在一根石柱上留下了”齐天大圣到此一游”，然后自信满满回来，告诉如来：”我能跳出你的手掌心。”</p>

<p>如来淡淡一笑，展开手掌——那根石柱，就在他的中指旁边。</p>

<p>孙悟空的问题不是能力不行。他的问题是：<strong>他只看到了局部，以为那就是全部。</strong></p>

<p>在短链系统的设计里，99%的工程师都是那个刚写出上面三行代码、兴冲冲告诉面试官”我懂Base62”的孙悟空。</p>

<p><strong>他们确实懂Base62。但他们不知道自己站在谁的手掌心里。</strong></p>

<p>让我们来一层层剥开这个手掌心。</p>

<hr />

<h2 id="第二幕代码层当你以为你写对了其实你写出了一个定时炸弹">第二幕：代码层——当你以为你写对了，其实你写出了一个定时炸弹</h2>

<h3 id="-逐行解剖这三行代码到底在做什么">🔍 逐行解剖：这三行代码到底在做什么？</h3>

<p>首先，让我用你最熟悉的方式解释这个算法的本质：<strong>进制转换</strong>。</p>

<p>还记得小学数学？125这个数字是怎么构成的？</p>

<ul>
  <li>1 × 100 = 百位</li>
  <li>2 × 10  = 十位</li>
  <li>5 × 1   = 个位</li>
</ul>

<p>如果我们不用0-9这10个数字，而用62个字符（a-z, A-Z, 0-9）来表示，同样的逻辑成立——这就是Base62。</p>

<p>代码里的每一步：</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">num % len(self.chars)</code> → 取出当前最低位对应的字符索引</li>
  <li><code class="language-plaintext highlighter-rouge">self.chars[...]</code> → 把索引映射成字符</li>
  <li><code class="language-plaintext highlighter-rouge">code = char + code</code> → 拼到字符串最前面（<strong>← 问题就在这里</strong>）</li>
  <li><code class="language-plaintext highlighter-rouge">num //= len(self.chars)</code> → 整除，把最低位扔掉，处理高位</li>
</ul>

<p>听起来很完美，对吗？</p>

<p><strong>但这里藏着两颗地雷。普通工程师一个都发现不了，Principal工程师能找出来并说清楚为什么。</strong></p>

<hr />

<h3 id="-地雷一隐藏的-on-你以为在做加法其实在搬家">💣 地雷一：隐藏的 O(N²) ——你以为在做加法，其实在搬家</h3>

<p>Python里的字符串，是<strong>不可变对象（Immutable Object）</strong>。</p>

<p>这意味着每次执行 <code class="language-plaintext highlighter-rouge">code = char + code</code>，Python在背后做的事情，不是”在字符串前面加一个字符”——而是：</p>

<ol>
  <li>开辟一块<strong>全新的内存空间</strong></li>
  <li>把新字符和旧字符串的每一个字符<strong>全部复制进去</strong></li>
  <li>丢弃原来那块内存</li>
</ol>

<p>想象一下你在搬家。你每搬进来一件新家具，都要先把所有旧家具搬出去，拿到新房子，再把新家具搬进去，再把所有旧家具搬回来。</p>

<p>一件家具时，搬1趟。两件时，搬2趟。N件时，搬了1+2+3+…+N = <strong>N²/2趟</strong>。</p>

<p>这就是O(N²)的时间和空间浪费。</p>

<p><strong>专业写法只要一行改动，性能提升从量变到质变：</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ❌ 普通写法 — 每次循环都搬一次家
</span><span class="n">code</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="n">num</span> <span class="o">%</span> <span class="n">base</span><span class="p">]</span> <span class="o">+</span> <span class="n">code</span>  

<span class="c1"># ✅ Principal写法 — 先存起来，最后一次性拼接
</span><span class="n">res</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">while</span> <span class="n">num</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="n">res</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="n">num</span> <span class="o">%</span> <span class="n">base</span><span class="p">])</span>  <span class="c1"># O(1)，只追加到列表末尾
</span>    <span class="n">num</span> <span class="o">//=</span> <span class="n">base</span>
<span class="n">code</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="nb">reversed</span><span class="p">(</span><span class="n">res</span><span class="p">))</span>  <span class="c1"># 一次性拼接，O(N)
</span></code></pre></div></div>

<p>一个用的是<code class="language-plaintext highlighter-rouge">list.append()</code>，一个用的是字符串拼接。表面上差不多，背后的内存分配行为天差地别。</p>

<p><strong>这就是为什么同样会写Base62，资深工程师和普通工程师的代码，在高并发下性能可以差10倍。</strong></p>

<hr />

<h3 id="-地雷二counter从0开始你的系统能在用户注册第一个链接时就崩溃">💣 地雷二：Counter从0开始——你的系统能在用户注册第一个链接时就崩溃</h3>

<p>看这段代码：</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="n">num</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
    <span class="p">...</span>
</code></pre></div></div>

<p>如果 <code class="language-plaintext highlighter-rouge">num == 0</code> 会发生什么？</p>

<p>循环直接跳过。返回的 <code class="language-plaintext highlighter-rouge">code</code> 是空字符串 <code class="language-plaintext highlighter-rouge">""</code>。</p>

<p>然后你把这个空字符串存进数据库，作为用户注册的第一条短链接。</p>

<p>然后用户点击了这个链接……</p>

<p><strong>系统崩了。</strong></p>

<p>这是个典型的<strong>Corner Case</strong>。而在真实的工程实践里，<code class="language-plaintext highlighter-rouge">self.counter</code>从0开始，或者计数器被重置，是完全可能发生的场景。</p>

<p>用MECE原则（Mutually Exclusive and Collectively Exhaustive，完全穷尽、相互独立）来看，数值的状态空间是：</p>

<table>
  <thead>
    <tr>
      <th>状态</th>
      <th><code class="language-plaintext highlighter-rouge">num &gt; 0</code></th>
      <th><code class="language-plaintext highlighter-rouge">num == 0</code></th>
      <th><code class="language-plaintext highlighter-rouge">num &lt; 0</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>原始代码能处理吗？</td>
      <td>✅</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
  </tbody>
</table>

<p>正确的写法是在循环之外加一个判断：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">num</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
    <span class="n">code</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>  <span class="c1"># 0 对应 'a'，作为第一个合法短码
</span><span class="k">else</span><span class="p">:</span>
    <span class="n">res</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">base</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">)</span>
    <span class="k">while</span> <span class="n">num</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="n">res</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span><span class="p">[</span><span class="n">num</span> <span class="o">%</span> <span class="n">base</span><span class="p">])</span>
        <span class="n">num</span> <span class="o">//=</span> <span class="n">base</span>
    <span class="n">code</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="nb">reversed</span><span class="p">(</span><span class="n">res</span><span class="p">))</span>
</code></pre></div></div>

<p><strong>在Principal面试中，能一眼发现这个Corner Case，就已经把大多数候选人甩在了身后。</strong></p>

<hr />

<h2 id="第三幕算法层为什么o1是一个哲学结论而不是数学结论">第三幕：算法层——为什么O(1)是一个哲学结论，而不是数学结论</h2>

<p>这里有一个被绝大多数工程师搞混的概念。</p>

<p>有人会问：”这个while循环明明要执行 <code class="language-plaintext highlighter-rouge">log₆₂(num)</code> 次，怎么能说是O(1)？”</p>

<p>这个问题问得好。答案需要从三个维度来理解：</p>

<h3 id="维度一系统上下文让常数变得不存在">维度一：系统上下文让常数变得”不存在”</h3>

<p>在URL短链系统里，短码长度通常限定在6-7位。</p>

<ul>
  <li>6位Base62：62⁶ ≈ 568亿条</li>
  <li>7位Base62：62⁷ ≈ 3.5万亿条</li>
</ul>

<p><strong>哪怕你的系统存储了3.5万亿条短链接，while循环最多执行7次。</strong></p>

<p>在Big-O分析里，O(7) = O(1)。当一个操作的上界是个极小常数时，我们称之为<strong>Bounded Constant Time（有界常数时间）</strong>。</p>

<h3 id="维度二最关键的o1你消灭了查重这个不确定性怪兽">维度二：最关键的O(1)——你消灭了”查重”这个不确定性怪兽</h3>

<p>真正理解这个O(1)，要把它和<strong>随机生成短码</strong>的方法对比。</p>

<p>随机生成法的步骤：</p>
<ol>
  <li>随机生成6个字符</li>
  <li>去数据库查：这个短码已经存在了吗？</li>
  <li>存在？回到第1步重新生成</li>
  <li>不存在？好，存进去</li>
</ol>

<p>随着数据库里的短链越来越多（设总量为N），碰撞的概率越来越高，重试次数越来越多。<strong>在极端情况下，这个方法的时间复杂度会退化到O(N)，甚至触发系统雪崩。</strong></p>

<p>而Counter + Base62方法建立的是一个从整数到字符串的<strong>双射（Bijection）</strong>：</p>

<ul>
  <li>每个整数唯一对应一个字符串</li>
  <li>每个字符串唯一对应一个整数</li>
  <li>绝对不冲突，因为底层的整数自增序列绝对不重复</li>
</ul>

<p><strong>这等于从架构上彻底删除了”查重”这个操作。</strong> 消灭了随机性，消灭了重试，消灭了碰撞。执行路径单向、确定、恒定。</p>

<p>这才是真正工程意义上的O(1)。</p>

<h3 id="维度三用物理学打个比方">维度三：用物理学打个比方</h3>

<p>诺贝尔物理学奖得主理查德·费曼说过：<strong>“如果你真正理解了一件事，你应该能用简单的语言解释它。”</strong></p>

<p>用自由能原理（Free Energy Principle）来类比：</p>

<ul>
  <li>随机生成法 = 热力学的无序状态，熵极高，”惊奇”极多（你不知道下次会不会碰撞）</li>
  <li>Counter + Base62 = 引入严格因果关系，熵为0，系统不确定性降为最低</li>
</ul>

<p><strong>好的算法设计，本质上是在降低系统的”计算自由能”。</strong></p>

<hr />

<h2 id="第四幕为什么要自己写base62三个你从没想过的理由">第四幕：为什么要自己写Base62？——三个你从没想过的理由</h2>

<p>很多人会问：”Python有<code class="language-plaintext highlighter-rouge">hex()</code>，有<code class="language-plaintext highlighter-rouge">base64</code>库，为什么不用？”</p>

<p>这个问题，是区分<strong>初级工程师思维</strong>和<strong>架构师思维</strong>的分水岭。</p>

<h3 id="原因一python原生不支持base62技术限制">原因一：Python原生不支持Base62（技术限制）</h3>

<table>
  <thead>
    <tr>
      <th>方法</th>
      <th>支持进制</th>
      <th>字符集大小</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bin()</code></td>
      <td>2进制</td>
      <td>2个字符</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">oct()</code></td>
      <td>8进制</td>
      <td>8个字符</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hex()</code></td>
      <td>16进制</td>
      <td>16个字符</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">int(s, base)</code></td>
      <td>最大36进制</td>
      <td>36个字符（0-9+a-z）</td>
    </tr>
    <tr>
      <td>自研Base62</td>
      <td>62进制</td>
      <td><strong>62个字符</strong></td>
    </tr>
  </tbody>
</table>

<p>Python的<code class="language-plaintext highlighter-rouge">int(s, base)</code>最大只支持Base36，因为它不区分大小写字母。要同时使用大小写字母（26+26+10=62），必须自己实现。</p>

<h3 id="原因二信息密度的碾压base62-vs-base16">原因二：信息密度的碾压——Base62 vs Base16</h3>

<p>假设我们用<code class="language-plaintext highlighter-rouge">hex()</code>（Base16）来存短链：</p>

<ul>
  <li>6位十六进制：16⁶ = 16,777,216 ≈ <strong>1677万条</strong>，按Bitly的量级，几个月就耗尽了</li>
  <li>6位Base62：62⁶ ≈ <strong>568亿条</strong>，同样长度多表示<strong>3380倍</strong>的数据</li>
</ul>

<p>为了达到Base62的容量，Base16需要9-10位字符。你的短链会变成：<code class="language-plaintext highlighter-rouge">bit.ly/a3f8bc09e</code>——这还算”短链”吗？</p>

<p><strong>这是信息论的胜利。在相同的字符长度下，Base62的信息密度是Base16的log(62)/log(16) ≈ 1.54倍。</strong></p>

<h3 id="原因三url安全性一个会在生产环境爆炸的隐患">原因三：URL安全性——一个会在生产环境爆炸的隐患</h3>

<p>Python标准库的<code class="language-plaintext highlighter-rouge">base64.b64encode()</code>使用的字符集包含：<code class="language-plaintext highlighter-rouge">+</code>、<code class="language-plaintext highlighter-rouge">/</code>、<code class="language-plaintext highlighter-rouge">=</code></p>

<p>这三个字符在URL中是<strong>保留字符（Reserved Characters）</strong>：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">+</code> 在URL中代表空格</li>
  <li><code class="language-plaintext highlighter-rouge">/</code> 代表路径分隔符</li>
  <li><code class="language-plaintext highlighter-rouge">=</code> 在查询字符串中有特殊含义</li>
</ul>

<p>如果你的短链包含这些字符，浏览器会把<code class="language-plaintext highlighter-rouge">https://example.com/aB+/c=</code>解析成<code class="language-plaintext highlighter-rouge">https://example.com/aB%20%2Fc%3D</code>——不仅破坏了链接，还让短链更长了。</p>

<p>Base62只使用<code class="language-plaintext highlighter-rouge">[a-zA-Z0-9]</code>，100% URL Safe，无需任何转义。</p>

<h3 id="隐藏原因四principal专属安全混淆的自由度">隐藏原因四（Principal专属）：安全混淆的自由度</h3>

<p>如果用标准进制转换，发号顺序是：a, b, c, d, e…</p>

<p>竞争对手只需要递增访问你的短链，就能轻松爬取你系统里所有的URL，统计你每天的业务量。这叫<strong>IDOR（不安全的直接对象引用）漏洞</strong>。</p>

<p>但因为Base62是自己实现的，我们只需要在初始化时打乱字符表：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">random</span>
<span class="kn">import</span> <span class="nn">string</span>

<span class="n">chars</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">string</span><span class="p">.</span><span class="n">ascii_letters</span> <span class="o">+</span> <span class="n">string</span><span class="p">.</span><span class="n">digits</span><span class="p">)</span>
<span class="n">random</span><span class="p">.</span><span class="n">shuffle</span><span class="p">(</span><span class="n">chars</span><span class="p">)</span>  <span class="c1"># 在系统启动时随机打乱一次，永久固化
</span><span class="bp">self</span><span class="p">.</span><span class="n">chars</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">chars</span><span class="p">)</span>
</code></pre></div></div>

<p>仅仅通过打乱这个字母表，不引入任何加密开销，发号器发出的1, 2, 3就会映射成<code class="language-plaintext highlighter-rouge">X3m</code>、<code class="language-plaintext highlighter-rouge">Kq7</code>、<code class="language-plaintext highlighter-rouge">9Rn</code>这样的随机外观——用极低的CPU成本，在数学映射层面实现了安全混淆。</p>

<hr />

<h2 id="第五幕从单机到分布式那个藏在selfcounter里的定时炸弹">第五幕：从单机到分布式——那个藏在self.counter里的定时炸弹</h2>

<p>当你在面试里写出这段代码，面试官最希望你主动开口说的，是这句话：</p>

<blockquote>
  <p><strong>“这段代码在单机上完美运行，但在真实的分布式系统中，<code class="language-plaintext highlighter-rouge">self.counter</code>是一个致命的单点瓶颈。”</strong></p>
</blockquote>

<p>为什么？</p>

<p>想象一下，100台Web服务器同时调用<code class="language-plaintext highlighter-rouge">self.counter += 1</code>。</p>

<p>如果这个counter只存在每台机器的内存里，那100台机器完全独立自增，会同时发出ID=1, ID=1, ID=1…——100个相同的短码，映射到100个不同的长链。系统彻底乱了。</p>

<h3 id="问题的三个层次">问题的三个层次</h3>

<p><strong>层次一：并发冲突（Race Condition）</strong>
多线程环境下，单机的<code class="language-plaintext highlighter-rouge">self.counter</code>本身就是线程不安全的。<code class="language-plaintext highlighter-rouge">counter += 1</code>这个操作在Python里不是原子操作（即使有GIL，在某些情况下依然会出问题）。</p>

<p><strong>层次二：多节点冲突</strong>
多台服务器之间没有共享状态，各自独立计数，ID必然重复。</p>

<p><strong>层次三：单点宕机</strong>
如果counter存在内存里，服务器宕机重启，counter归零。所有新生成的短码与历史短码冲突。</p>

<h3 id="-principal级解决方案预分配号段池架构token-range-server">🏆 Principal级解决方案：预分配号段池架构（Token Range Server）</h3>

<p>这是业界标准的分布式发号器设计，被美团、微博、滴滴等大厂广泛采用：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────┐
│                    ZooKeeper / etcd                      │
│              (全局计数器：当前发到了10000)                  │
└────────────────┬────────────────┬──────────────────────-┘
                 │                │
        ┌────────▼───────┐  ┌─────▼────────┐
        │   Web Server 1 │  │  Web Server 2 │
        │ 号段: [1, 1000] │  │号段:[1001,2000]│
        │ 本地counter: 42 │  │本地counter: 1150│
        └────────────────┘  └──────────────┘
</code></pre></div></div>

<p><strong>工作原理：</strong></p>
<ol>
  <li>Web服务器启动时，向ZooKeeper申请一个号段（比如1000个ID）</li>
  <li>ZooKeeper原子性地将全局计数器推进1000，返回<code class="language-plaintext highlighter-rouge">[1, 1000]</code>给Server 1</li>
  <li>Server 1在本地内存中从1自增发号，完全不需要网络请求</li>
  <li>当本地号段耗尽时，再去申请下一批<code class="language-plaintext highlighter-rouge">[2001, 3000]</code></li>
</ol>

<p><strong>为什么这个设计是天才之举（第一性原理分析）：</strong></p>

<ul>
  <li>把原本需要”每次都跨网络的分布式锁操作”，降维成了”纯本地内存O(1)操作”</li>
  <li>即使ZooKeeper短暂宕机，Web服务器依靠本地缓存的号段，依然能存活相当长时间</li>
  <li>哪怕服务器宕机，丢失的号段最多1000个，相比于3.5万亿的总空间，九牛一毛</li>
</ul>

<p><strong>关于”丢号”的哲学：</strong></p>

<p>很多人会担心：服务器宕机，没用完的号段丢了怎么办？</p>

<p>这里有一个非常深刻的<strong>工程哲学</strong>：</p>

<blockquote>
  <p>我们用极少量且极廉价的ID碎片空间，换取了系统架构的极度简单、无锁化处理和超高吞吐量。
宁可让ID序列不连续，也绝不引入脆弱且沉重的回收机制。</p>
</blockquote>

<p>这与Twitter Snowflake算法的设计理念完全一致——时间戳空转时浪费序号，是刻意为之的设计权衡，而非缺陷。</p>

<hr />

<h2 id="第六幕feistel密码让短码既无碰撞又无规律">第六幕：Feistel密码——让短码既无碰撞，又无规律</h2>

<p>等等，我们刚才用了号段池解决了冲突问题。但还有一个安全隐患没解决：</p>

<p>打乱<code class="language-plaintext highlighter-rouge">self.chars</code>只是一种<strong>弱混淆</strong>，而不是真正的安全。如果攻击者通过逆向分析找到了你的字符表顺序，依然能预测你的短码规律。</p>

<p>有没有办法，在<strong>保持双射（绝不冲突）</strong>的前提下，让生成的短码呈现<strong>完全随机的分布</strong>？</p>

<p>答案是：<strong>Feistel密码网络（Feistel Cipher Network）</strong>。</p>

<p>Feistel网络的神奇之处在于：它是一种<strong>可逆的置换（Reversible Permutation）</strong>。无论你输入什么，它都能给你一个唯一的输出，且这个映射是一一对应的——完美保持双射性质。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">feistel_encrypt</span><span class="p">(</span><span class="n">n</span><span class="p">,</span> <span class="n">rounds</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="mh">0xDEADBEEF</span><span class="p">):</span>
    <span class="s">"""将输入的整数n映射到一个完全不同的整数，保证双射"""</span>
    <span class="n">left</span> <span class="o">=</span> <span class="n">n</span> <span class="o">&gt;&gt;</span> <span class="mi">16</span>
    <span class="n">right</span> <span class="o">=</span> <span class="n">n</span> <span class="o">&amp;</span> <span class="mh">0xFFFF</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">rounds</span><span class="p">):</span>
        <span class="n">new_left</span> <span class="o">=</span> <span class="n">right</span>
        <span class="n">new_right</span> <span class="o">=</span> <span class="n">left</span> <span class="o">^</span> <span class="p">((</span><span class="n">right</span> <span class="o">*</span> <span class="n">key</span> <span class="o">+</span> <span class="n">i</span><span class="p">)</span> <span class="o">%</span> <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">))</span>
        <span class="n">left</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">new_left</span><span class="p">,</span> <span class="n">new_right</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">left</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">)</span> <span class="o">|</span> <span class="n">right</span>

<span class="c1"># 使用方式：在Base62转换前，先对counter做一次Feistel加密
</span><span class="k">def</span> <span class="nf">encode</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">longUrl</span><span class="p">):</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">counter</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">shuffled_num</span> <span class="o">=</span> <span class="n">feistel_encrypt</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">counter</span><span class="p">)</span>  <span class="c1"># 打散单调性
</span>    <span class="n">code</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_base62_encode</span><span class="p">(</span><span class="n">shuffled_num</span><span class="p">)</span>       <span class="c1"># 再转Base62
</span>    <span class="p">...</span>
</code></pre></div></div>

<p>输入1, 2, 3…，输出完全随机的整数，再经过Base62转换，得到的短码看起来毫无规律，但每个都保证唯一。</p>

<p><strong>这才是真正的”工业级安全混淆”，把双射的数学特性发挥到了极致。</strong></p>

<hr />

<h2 id="第七幕分布式存储那个叫selfcode_to_url的字典终将成为回忆">第七幕：分布式存储——那个叫self.code_to_url的字典，终将成为回忆</h2>

<p>在面试里，很多人把短链系统的分布式存储设计答成了”用MySQL就好了”。</p>

<p>Principal级别的候选人，会从三个核心问题出发反向推导存储方案：</p>

<p><strong>问题一：读写比是多少？</strong>
URL短链系统是典型的<strong>读多写少</strong>场景。用户创建链接（写）一次，但每次分享出去，可能有成千上万次点击（读）。读写比通常在<strong>100:1以上</strong>。</p>

<p><strong>问题二：数据模型复杂吗？</strong>
核心数据就两张表：</p>
<ul>
  <li>ShortCode → LongURL（用于重定向解析）</li>
  <li>LongURL_Hash → ShortCode（用于去重，可选）</li>
</ul>

<p>几乎没有复杂的JOIN操作，完全是Key-Value读取。</p>

<p><strong>问题三：数据量有多大？</strong>
按Bitly的量级，数十亿甚至百亿条记录。</p>

<p><strong>结论：NoSQL（Cassandra/DynamoDB）是首选</strong></p>

<table>
  <thead>
    <tr>
      <th>特性</th>
      <th>MySQL/PostgreSQL</th>
      <th>Cassandra/DynamoDB</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>水平扩展</td>
      <td>需要手动分库分表</td>
      <td>原生支持</td>
    </tr>
    <tr>
      <td>读写性能</td>
      <td>受限于单机</td>
      <td>线性扩展</td>
    </tr>
    <tr>
      <td>运维复杂度</td>
      <td>分库分表极复杂</td>
      <td>相对简单</td>
    </tr>
    <tr>
      <td>强一致性</td>
      <td>✅</td>
      <td>可调（最终一致）</td>
    </tr>
  </tbody>
</table>

<p><strong>完整的三级存储架构：</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>用户请求 → 布隆过滤器（无效请求拦截） → Redis L1本地缓存 → Redis集群缓存 → Cassandra
</code></pre></div></div>

<p>每一层都比上一层慢10-100倍，但容量大10-100倍。</p>

<hr />

<h2 id="第八幕布隆过滤器那个神奇的差不多数据结构">第八幕：布隆过滤器——那个神奇的”差不多”数据结构</h2>

<p>当系统规模达到亿级别，直接去Redis或Cassandra查”这个短码存不存在”，在高并发下会把存储层打挂。</p>

<p>这时候，我们需要一个能以极低代价回答”<strong>这个短码一定不存在</strong>“的工具。</p>

<p><strong>布隆过滤器（Bloom Filter）</strong>就是这个工具。</p>

<p>它的工作原理用一句话概括：</p>

<blockquote>
  <p><strong>布隆过滤器可以100%确定地告诉你”这个元素绝对不在集合里”。但它告诉你”在”，可能是谎言（假阳性）。</strong></p>
</blockquote>

<p>这个”有限度的谎言”，就是布隆过滤器的魔法所在。对于短链系统的防穿透场景：</p>

<ul>
  <li>攻击者随机生成短链访问 → 布隆过滤器说”不存在” → 直接返回404，不查数据库 ✅</li>
  <li>布隆过滤器说”存在” → 可能是假阳性 → 去数据库查一次，最多增加1次DB读 ✅</li>
</ul>

<p><strong>用极小的内存（几百MB存几十亿条记录），换取了对绝大多数无效请求的O(1)拦截。</strong></p>

<h3 id="分布式环境下的布隆过滤器同步">分布式环境下的布隆过滤器同步</h3>

<p>在多台服务器的环境里，布隆过滤器的同步是个挑战。三种主流方案：</p>

<p><strong>方案A：RedisBloom（集中式，强一致）</strong></p>
<ul>
  <li>把布隆过滤器存在Redis里，所有Web服务器共享</li>
  <li>优点：架构简单，强一致</li>
  <li>缺点：每次查询都有网络开销（约1-2ms），高并发下Redis成为热点</li>
</ul>

<p><strong>方案B：本地内存BF + Kafka广播（最终一致，极致性能）</strong></p>
<ul>
  <li>每台机器维护独立的本地BF</li>
  <li>新增元素时，通过Kafka通知所有节点更新本地BF</li>
  <li>优点：查询延迟纳秒级（本地内存vs Redis相差10000-50000倍）</li>
  <li>缺点：存在Kafka延迟造成的短暂不一致</li>
</ul>

<p><strong>方案C：离线定时重建 + S3全量拉取（适合黑名单类静态数据）</strong></p>
<ul>
  <li>每天凌晨用大数据任务重建BF，存入S3</li>
  <li>各服务器定时拉取最新版本，双Buffer热切换</li>
  <li>优点：架构解耦，极其稳定</li>
  <li>缺点：实时性差</li>
</ul>

<p><strong>选型原则（黄金圈法则）：</strong></p>

<p>从”为什么”出发——你引入布隆过滤器，是为了”保护数据库不被无效请求打挂”。</p>

<p>如果QPS在10万以内，RedisBloom足够了，因为Redis完全能扛住。</p>

<p>如果QPS在百万级别，你需要本地BF + Kafka，因为百万QPS打向同一个Redis节点会把它打挂。</p>

<p>这就是架构设计的第一性原理：<strong>从你要解决的核心问题出发，而不是从你熟悉的技术方案出发。</strong></p>

<h3 id="-布隆过滤器的删除难题">🛑 布隆过滤器的删除难题</h3>

<p>布隆过滤器有一个致命限制：<strong>标准实现不支持删除</strong>。</p>

<p>因为多个不同的元素可能对应相同的Bit位，如果把Bit从1改成0，会误删其他元素。</p>

<p>解决方案：</p>
<ol>
  <li><strong>定期重建</strong>：最简单有效，每天重跑一次，基于数据库活跃记录构建新BF</li>
  <li><strong>布谷鸟过滤器（Cuckoo Filter）</strong>：支持删除，且空间效率更高，是现代替代品</li>
  <li><strong>计数布隆过滤器（Counting Bloom Filter）</strong>：用小整数代替单比特，支持删除，但内存占用增加4倍</li>
</ol>

<hr />

<h2 id="第九幕热点攻击防御当霍尔木兹封锁新闻链接遭到ddos">第九幕：热点攻击防御——当霍尔木兹封锁新闻链接遭到DDoS</h2>

<p>回到开篇的场景。</p>

<p>霍尔木兹封锁消息爆发，某个突发新闻链接被转了2000万次。这不是攻击，这是<strong>自然流量洪峰</strong>，但对后端的破坏效果和DDoS没有区别。</p>

<p>这种场景叫<strong>“热点Key（Hot Key）”</strong>问题：同一个短码被集中访问，打向Redis集群的同一个分片（Shard），超过单节点的10万QPS上限。</p>

<p><strong>多级防御架构（Defense in Depth）：</strong></p>

<p><strong>第一级：L1本地微缓存（TTL=1-2秒）</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 在每台Web服务器的内存里，缓存最近访问的URL
</span><span class="kn">from</span> <span class="nn">cachetools</span> <span class="kn">import</span> <span class="n">TTLCache</span>

<span class="n">local_cache</span> <span class="o">=</span> <span class="n">TTLCache</span><span class="p">(</span><span class="n">maxsize</span><span class="o">=</span><span class="mi">10000</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>  <span class="c1"># 只存最热的1万条，2秒过期
</span>
<span class="k">def</span> <span class="nf">get_long_url</span><span class="p">(</span><span class="n">short_code</span><span class="p">):</span>
    <span class="c1"># 先查本地内存
</span>    <span class="k">if</span> <span class="n">short_code</span> <span class="ow">in</span> <span class="n">local_cache</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">local_cache</span><span class="p">[</span><span class="n">short_code</span><span class="p">]</span>  <span class="c1"># 纳秒级返回
</span>    
    <span class="c1"># 本地未命中，查Redis
</span>    <span class="n">url</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">short_code</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">url</span><span class="p">:</span>
        <span class="n">local_cache</span><span class="p">[</span><span class="n">short_code</span><span class="p">]</span> <span class="o">=</span> <span class="n">url</span>
        <span class="k">return</span> <span class="n">url</span>
    
    <span class="c1"># Redis未命中，查DB...
</span></code></pre></div></div>

<p>TTL只有2秒，但面对百万QPS，100台服务器的本地缓存各自承担，每台只承受1万QPS。</p>

<p>每台服务器每2秒只向Redis发送1次查询请求。百万QPS被降维成了100次/2秒=<strong>50次QPS</strong>打向Redis。</p>

<p><strong>第二级：Singleflight（请求合并）</strong></p>

<p>当本地缓存和Redis同时失效（缓存雪崩），大量并发请求同时冲向数据库：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">threading</span>

<span class="n">singleflight_locks</span> <span class="o">=</span> <span class="p">{}</span>
<span class="n">lock</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Lock</span><span class="p">()</span>

<span class="k">def</span> <span class="nf">get_with_singleflight</span><span class="p">(</span><span class="n">short_code</span><span class="p">):</span>
    <span class="k">with</span> <span class="n">lock</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">short_code</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">singleflight_locks</span><span class="p">:</span>
            <span class="n">singleflight_locks</span><span class="p">[</span><span class="n">short_code</span><span class="p">]</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Event</span><span class="p">()</span>
            <span class="n">should_fetch</span> <span class="o">=</span> <span class="bp">True</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">event</span> <span class="o">=</span> <span class="n">singleflight_locks</span><span class="p">[</span><span class="n">short_code</span><span class="p">]</span>
            <span class="n">should_fetch</span> <span class="o">=</span> <span class="bp">False</span>
    
    <span class="k">if</span> <span class="n">should_fetch</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">fetch_from_db</span><span class="p">(</span><span class="n">short_code</span><span class="p">)</span>
            <span class="c1"># 通知所有等待的请求
</span>            <span class="n">singleflight_locks</span><span class="p">[</span><span class="n">short_code</span><span class="p">].</span><span class="n">result</span> <span class="o">=</span> <span class="n">result</span>
            <span class="n">singleflight_locks</span><span class="p">[</span><span class="n">short_code</span><span class="p">].</span><span class="nb">set</span><span class="p">()</span>
            <span class="k">return</span> <span class="n">result</span>
        <span class="k">finally</span><span class="p">:</span>
            <span class="k">del</span> <span class="n">singleflight_locks</span><span class="p">[</span><span class="n">short_code</span><span class="p">]</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">event</span><span class="p">.</span><span class="n">wait</span><span class="p">()</span>  <span class="c1"># 等待第一个请求完成
</span>        <span class="k">return</span> <span class="n">event</span><span class="p">.</span><span class="n">result</span>  <span class="c1"># 共享结果
</span></code></pre></div></div>

<p>无论有多少并发请求，打到数据库的永远只有1个。</p>

<p><strong>第三级：布隆过滤器（随机无效请求拦截）</strong></p>

<p>如果攻击者不是打同一个真实短码，而是打随机生成的不存在的短码（缓存穿透），布隆过滤器在第一道关卡就把它们全部拦截。</p>

<p><strong>整体防御流程图：</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>用户请求
    │
    ▼
[L1本地缓存] ──命中──→ 立即返回（纳秒级）
    │未命中
    ▼
[布隆过滤器] ──不存在──→ 404（无效短码，无DB开销）
    │可能存在
    ▼
[Redis集群缓存] ──命中──→ 返回（毫秒级）
    │未命中
    ▼
[Singleflight合并] ──仅1个请求穿透──→ 数据库
    │
    ▼
结果回填所有层级缓存
</code></pre></div></div>

<hr />

<h2 id="第十幕redisbloom的分片突破单节点10万qps天花板">第十幕：RedisBloom的分片——突破单节点10万QPS天花板</h2>

<p>很多工程师以为，上了Redis Cluster，QPS就能线性扩展了。</p>

<p><strong>这是个危险的误解。</strong></p>

<p>Redis Cluster的分片是基于<strong>Key</strong>的（CRC16(key) % 16384）。如果你只有一个名叫<code class="language-plaintext highlighter-rouge">bf:global_urls</code>的布隆过滤器Key，无论集群有多少台机器，这个Key永远落在<strong>一台固定的物理节点</strong>上。</p>

<p>那10万QPS的天花板，依然是天花板。</p>

<p><strong>解决方案：客户端预分片（Client-side Pre-sharding）</strong></p>

<p>把一个逻辑上的布隆过滤器，物理上拆成N个独立的子Key：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">mmh3</span>  <span class="c1"># MurmurHash3，散列性优秀
</span>
<span class="k">def</span> <span class="nf">get_bloom_shard_key</span><span class="p">(</span><span class="n">element</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">num_shards</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1024</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="s">"""根据元素内容，决定它该存在哪个分片"""</span>
    <span class="n">hash_val</span> <span class="o">=</span> <span class="n">mmh3</span><span class="p">.</span><span class="nb">hash</span><span class="p">(</span><span class="n">element</span><span class="p">)</span>
    <span class="n">shard_id</span> <span class="o">=</span> <span class="n">hash_val</span> <span class="o">%</span> <span class="n">num_shards</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s">"bf:urls:</span><span class="si">{</span><span class="n">shard_id</span><span class="si">}</span><span class="s">"</span>

<span class="c1"># 添加元素
</span><span class="k">def</span> <span class="nf">bf_add</span><span class="p">(</span><span class="n">short_code</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="n">shard_key</span> <span class="o">=</span> <span class="n">get_bloom_shard_key</span><span class="p">(</span><span class="n">short_code</span><span class="p">)</span>
    <span class="n">redis</span><span class="p">.</span><span class="n">execute_command</span><span class="p">(</span><span class="s">"BF.ADD"</span><span class="p">,</span> <span class="n">shard_key</span><span class="p">,</span> <span class="n">short_code</span><span class="p">)</span>

<span class="c1"># 查询元素
</span><span class="k">def</span> <span class="nf">bf_exists</span><span class="p">(</span><span class="n">short_code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="n">shard_key</span> <span class="o">=</span> <span class="n">get_bloom_shard_key</span><span class="p">(</span><span class="n">short_code</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">redis</span><span class="p">.</span><span class="n">execute_command</span><span class="p">(</span><span class="s">"BF.EXISTS"</span><span class="p">,</span> <span class="n">shard_key</span><span class="p">,</span> <span class="n">short_code</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>关键设计原则：分片数要远大于当前物理节点数</strong></p>

<p>错误的做法：3台机器，拆成3个Key。</p>

<p>为什么错？因为未来扩容到4台时，取模变成<code class="language-plaintext highlighter-rouge">%4</code>，所有路由映射全部作废，已经存入BF的元素全部找不到了——相当于系统瞬间失忆。</p>

<p>正确的做法：哪怕现在只有3台机器，也要拆成<strong>1024个Key</strong>。</p>

<p>这1024个Key由Redis Cluster通过Hash Slot均匀分散到所有节点。未来扩容时，Redis Cluster在后台自动迁移Slot，客户端代码一行不用改。</p>

<p><strong>这就是分布式系统设计里的”预分片（Pre-sharding）”哲学：为未来的自己留好扩容空间。</strong></p>

<hr />

<h2 id="结语差距到底在哪里">结语：差距到底在哪里？</h2>

<p>孙悟空最后从如来掌心逃不掉，不是因为他的技术不行。</p>

<p>是因为他没有<strong>系统性地思考自己所处的世界</strong>。</p>

<p>同样的道理：</p>

<p><strong>初级工程师</strong>看到这三行代码，看到的是”进制转换”。</p>

<p><strong>中级工程师</strong>看到它，看到的是”O(N²)和Corner Case”。</p>

<p><strong>高级工程师</strong>看到它，看到的是”分布式发号器、双射、Feistel加密、事务一致性”。</p>

<p><strong>Principal工程师</strong>看到它，看到的是”霍尔木兹封锁新闻流量洪峰下，这个系统扛得住吗？如果扛不住，从哪里开始加固？”</p>

<p>这就是差距。</p>

<p>它不在于你知道多少技术名词，而在于：</p>

<ol>
  <li><strong>你能不能从一行代码出发，看到整个分布式系统的骨架？</strong>（系统思考）</li>
  <li><strong>你能不能在做每个技术决策时，说清楚你的Trade-off是什么？</strong>（架构直觉）</li>
  <li><strong>你能不能识别出那些隐藏的O(N²)、隐藏的Corner Case、隐藏的单点故障？</strong>（底层洞察）</li>
</ol>

<p>王阳明说：”知行合一。”</p>

<p>知道这些，不等于会用这些。<strong>下一次你写代码时，停一秒，想一想：如果这段代码要承受一条突发全球大事件新闻链接的流量洪峰，它会在哪里断掉？</strong></p>

<hr />

<h2 id="延伸阅读与下期预告">延伸阅读与下期预告</h2>

<p>本文涉及的核心知识点清单：</p>

<ul>
  <li>✅ Python字符串不可变性与O(N²)内存分配</li>
  <li>✅ Base62 vs Base16 vs Base64的信息密度对比</li>
  <li>✅ 分布式发号器：号段池架构（Token Range Server）</li>
  <li>✅ Feistel密码网络与双射安全混淆</li>
  <li>✅ 布隆过滤器（Bloom Filter）原理与分布式同步</li>
  <li>✅ 热点Key防御：本地缓存 + Singleflight + RedisBloom</li>
  <li>✅ RedisBloom集群分片：Client-side Pre-sharding</li>
</ul>]]></content><author><name></name></author></entry><entry><title type="html">Untangling Airflow GitOps: Code Sync vs. Infrastructure State</title><link href="http://todzhang.com/blogs/tech/en/gitops-airflow-code-vs-state" rel="alternate" type="text/html" title="Untangling Airflow GitOps: Code Sync vs. Infrastructure State" /><published>2026-08-13T00:00:00+00:00</published><updated>2026-08-13T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/en/gitops-airflow-code-vs-state-en</id><content type="html" xml:base="http://todzhang.com/blogs/tech/en/gitops-airflow-code-vs-state"><![CDATA[<blockquote>
  <p>“If it hurts, do it more frequently, and bring the pain forward.” — Jez Humble</p>
</blockquote>

<h1 id="untangling-airflow-gitops-code-sync-vs-infrastructure-state">Untangling Airflow GitOps: Code Sync vs. Infrastructure State</h1>

<p>I’ve watched teams turn a shared Airflow development cluster into a turf war. Team A changes the environment’s target Git branch to test their ingestion pipeline. Team B changes it back ten minutes later to test their transformations. The cluster thrashes, pods continuously restart, and no one gets any testing done.</p>

<p>The fundamental mistake here is treating “deploying configuration” and “deploying code” as the same action. They aren’t.</p>

<p>In a standard Kubernetes-native Airflow setup using ArgoCD, you are running two completely independent GitOps control loops. Conflating them is why your environment feels fragile.</p>

<p><strong>TL;DR:</strong> Switching a Git branch requires evacuating the building. Pushing new commits to an active branch is just restocking the fridge.</p>

<h3 id="the-dual-pipeline-mental-model">The Dual-Pipeline Mental Model</h3>

<p>Let’s map out exactly how code and state flow into your cluster. If you understand this routing, you understand why changing a branch name behaves differently than updating a DAG.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[ Loop 1: Infrastructure State ]
Infra Repo (Git) -&gt; ArgoCD -&gt; ConfigMap Mutation -&gt; Reloader -&gt; Rolling Restart
                                                                (Cost: High, ~2 min)

[ Loop 2: Runtime Code ]
DAG Repo (Git)   -&gt; git-sync sidecar -&gt; Shared Volume -&gt; Airflow Scheduler Parses File
                                                                (Cost: Low, ~120s)
</code></pre></div></div>

<p><strong>Loop 1: Infrastructure State (The Evacuation)</strong>
Your infrastructure repository holds Kubernetes manifests. ArgoCD watches this repo. When you change the target <code class="language-plaintext highlighter-rouge">BRANCH</code> variable in your Airflow ConfigMap, ArgoCD syncs the change. A controller like Stakater Reloader detects the ConfigMap mutation and triggers a rolling restart of all mounted Deployments (Scheduler, Webserver, Triggerer).</p>

<p>This is a heavy operation. API server replicas are replaced one by one. In-flight local tasks might be disrupted. It takes a couple of minutes before the cluster settles.</p>

<p><strong>Loop 2: Runtime Code (The Restock)</strong>
Your DAG repository holds Python code. A <code class="language-plaintext highlighter-rouge">git-sync</code> sidecar (or cron pod) runs alongside Airflow, pulling the currently configured branch every two minutes and writing to a shared persistent volume (NFS/EFS). The Airflow scheduler parses the new files on its next loop.</p>

<p>This is a lightweight operation. There are no pod restarts. ArgoCD is entirely oblivious to this process.</p>

<h3 id="the-integration-branch-pattern">The Integration Branch Pattern</h3>

<p>When two teams need to test cross-DAG dependencies on a single instance, ping-ponging the ConfigMap branch is an anti-pattern. Every branch switch triggers Reloader, causing a restart storm and thrashing the shared volume.</p>

<p>Instead, use a short-lived integration branch:</p>

<ol>
  <li>Cut an integration branch (e.g., <code class="language-plaintext highlighter-rouge">integration/feature-x</code>) from your main development trunk.</li>
  <li>Both teams merge their feature branches into this integration branch, resolving any conflicts in shared configurations locally.</li>
  <li>Update the infrastructure ConfigMap <code class="language-plaintext highlighter-rouge">BRANCH</code> target <strong>exactly once</strong>. Let Reloader restart the cluster.</li>
  <li>For the rest of the testing cycle, teams push commits directly to the integration branch. The <code class="language-plaintext highlighter-rouge">git-sync</code> sidecar handles the updates seamlessly without disrupting the environment.</li>
</ol>

<p>This treats the shared instance as a sandbox. Once integration testing is verified, merge the integration branch back to trunk and release the environment slot.</p>

<h3 id="honest-tradeoffs">Honest Tradeoffs</h3>

<p>This pattern is effective, but it is not a silver bullet.</p>

<ul>
  <li><strong>Merge conflicts shift left:</strong> You are forcing conflict resolution into the integration branch. Teams have to coordinate on shared files (like common operators) before testing, rather than overwriting each other in the cluster.</li>
  <li><strong>No branch protection:</strong> Development integration branches usually lack strict PR requirements. An integration branch can turn into a swamp if left alive too long. Put an expiry date on it and delete it when the test concludes.</li>
  <li><strong>Reloader’s blunt instrument:</strong> Reloader triggers on <em>any</em> ConfigMap change. If you update a UI title or an unrelated environment variable in the same ConfigMap, you still pay the price of a full rolling restart. It does not do field-level diffing.</li>
</ul>

<h3 id="action-items">Action Items</h3>

<p>Next time your DAGs aren’t showing up in the UI, check your mental model before you start restarting pods or complaining about ArgoCD:</p>

<ul>
  <li><strong>Did you push a commit to the currently active branch?</strong> Do nothing. Wait your sync interval (e.g., 120 seconds). Check the <code class="language-plaintext highlighter-rouge">git-sync</code> container logs if it takes longer.</li>
  <li><strong>Did you change the branch name in the infrastructure repo?</strong> Wait for ArgoCD to sync, then check Reloader’s logs (<code class="language-plaintext highlighter-rouge">kubectl logs deploy/reloader-reloader</code>) for the <code class="language-plaintext highlighter-rouge">Reloading deployment</code> event to confirm the cluster is cycling.</li>
</ul>

<p>Stop treating code syncs like infrastructure deployments. Your uptime will thank you.</p>]]></content><author><name></name></author><category term="tech" /><category term="kubernetes" /><category term="airflow" /><category term="gitops" /><category term="argocd" /><summary type="html"><![CDATA[“If it hurts, do it more frequently, and bring the pain forward.” — Jez Humble]]></summary></entry><entry><title type="html">Airflow GitOps 避坑：ArgoCD、Reloader 与 Git-Sync 的多项目联调模型</title><link href="http://todzhang.com/blogs/tech/zh/gitops-airflow-code-vs-state" rel="alternate" type="text/html" title="Airflow GitOps 避坑：ArgoCD、Reloader 与 Git-Sync 的多项目联调模型" /><published>2026-08-13T00:00:00+00:00</published><updated>2026-08-13T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/zh/gitops-airflow-code-vs-state-zh</id><content type="html" xml:base="http://todzhang.com/blogs/tech/zh/gitops-airflow-code-vs-state"><![CDATA[<blockquote>
  <p>“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。” — David Wheeler</p>
</blockquote>

<h1 id="airflow-gitops-避坑argocdreloader-与-git-sync-的多项目联调模型">Airflow GitOps 避坑：ArgoCD、Reloader 与 Git-Sync 的多项目联调模型</h1>

<p>你是不是也遇到过这种诡异的场景：两个团队要在同一个 Airflow Dev 环境里联调（比如同时读写同一个外部 CRM 系统的表），大家为了测试自己的 DAG，把 ConfigMap 里的 Git 分支名改来改去。结果呢？Pod 疯狂重启，UI 时不时报 502，EFS 存储压力飙升，测试环境一天瘫痪好几次，谁都没法干活。</p>

<p>其实，这根本不是 Airflow 的锅，而是很多人没搞懂 GitOps 架构下，<strong>基础设施层</strong>和<strong>业务代码层</strong>的分工配合。</p>

<p>今天我们把这套底层逻辑盘明白。搞懂这个，你不仅能优雅地解决多项目联调，还能对 Kubernetes 上的 GitOps 闭环有一个极其清晰的直觉。</p>

<hr />

<h3 id="-30-秒吃透别把两条管道混为一谈">🎯 30 秒吃透：别把两条管道混为一谈</h3>

<p>在现代 Airflow 的云原生部署中，我们通常有两个 Git 仓库，对应两条完全独立的更新管道。<strong>千万别混淆它们</strong>：</p>

<table>
  <thead>
    <tr>
      <th>管道</th>
      <th>仓库侧重</th>
      <th>触发机制</th>
      <th>谁在干活？</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>环境配置管道</strong></td>
      <td>K8s 清单仓库 (ConfigMap/Helm)</td>
      <td>Merge 代码 → ArgoCD 自动同步</td>
      <td><strong>Reloader</strong>：发现配置变了，滚动重启集群。</td>
    </tr>
    <tr>
      <td><strong>业务代码管道</strong></td>
      <td>DAG 源码仓库</td>
      <td>Push 代码到当前指定分支</td>
      <td><strong>Git-Sync</strong>：默默在后台每 ~120s 拉取一次代码。</td>
    </tr>
  </tbody>
</table>

<p><strong>核心认知</strong>：ArgoCD <strong>根本不关心</strong>你的 DAG 代码长什么样，它只盯着 K8s 的 YAML。你的业务代码更新，全靠 Git-Sync 那个 Sidecar 容器定时去拉。</p>

<hr />

<h3 id="-建立心智模型后厨里的三个打工人">🧠 建立心智模型：后厨里的三个打工人</h3>

<p>我们打个比方，把 Airflow 集群看作一家高档餐厅。为了让餐厅运转，后台有三个角色在配合：</p>

<ol>
  <li><strong>备货员 (Git-Sync)</strong>：他只认死理，每隔 120 秒就去供应商（DAG 代码仓库）那里看一眼：“有新菜吗？”有的话，就拉回来塞进<strong>冷库 (EFS)</strong>。</li>
  <li><strong>店长 (Reloader)</strong>：他死死盯着墙上的<strong>制度牌 (ConfigMap)</strong>。只要制度牌上的字改了（比如换了拉取代码的 <code class="language-plaintext highlighter-rouge">BRANCH</code> 字段），店长就会吹哨，把所有厨师（Airflow 的各个 Pod）踢下线，让他们重新洗手打卡上班。</li>
  <li><strong>厨师 (Airflow Scheduler/Worker)</strong>：从冷库里拿菜做饭。他们只有在每天“打卡上班”的那一刻，才会抬头看一眼制度牌上的环境变量。</li>
</ol>

<p>当你为了测试一段新代码，去改了 ConfigMap 里的分支名时，你以为你只是在“切分支”，<strong>实际上你是在让店长把整个后厨全部重置了一遍！</strong> API Server、Scheduler、Triggerer 全部要滚动重启（Rolling Restart），这通常会有 1-2 分钟的真空期。如果你和同事来回改分支名，这餐厅就别营业了。</p>

<hr />

<h3 id="️-架构总览数据流到底是怎么走的">🏗️ 架构总览：数据流到底是怎么走的</h3>

<p>一张图看懂底层的交接点：</p>

<pre><code class="language-mermaid">flowchart TD
    subgraph K8s_Cluster [Kubernetes 集群]
        ArgoCD[ArgoCD Controller]
        Reloader[Stakater Reloader]
        
        subgraph Airflow_Pods [Airflow 实例]
            GitSync[Git-Sync (Sidecar)]
            Scheduler[Scheduler / DAG Processor]
            API[API Server]
        end
        
        EFS[(EFS 共享存储 / 冷库)]
        ConfigMap[airflow-env ConfigMap]
    end

    RepoConfig[(配置仓库)] --&gt;|Watch Merge| ArgoCD
    RepoDAG[(DAG 仓库)] --&gt;|Pull 每 120s| GitSync

    ArgoCD --&gt;|更新| ConfigMap
    ConfigMap --&gt;|触发| Reloader
    Reloader -.-&gt;|滚动重启| Airflow_Pods
    
    ConfigMap --&gt;|读取 BRANCH 变量| GitSync
    GitSync --&gt;|写入 DAG 文件| EFS
    EFS --&gt;|读取 DAG 文件| Scheduler
</code></pre>

<p><strong>EFS 就是那个交接点</strong>：Git-Sync 负责<strong>写</strong>，Airflow 的各个组件负责<strong>读</strong>。</p>

<hr />

<h3 id="-最佳实践多项目联调的集成分支模式">💡 最佳实践：多项目联调的“集成分支”模式</h3>

<p>既然频繁改 ConfigMap 会导致灾难，那项目 A 和项目 B 必须在同一个 Dev 环境里做端到端测试时，该怎么办？</p>

<p><strong>答案是：引入一个中间层——集成分支（Integration Branch）。</strong></p>

<ol>
  <li><strong>拉取集成分支</strong>：从基准主干（如 <code class="language-plaintext highlighter-rouge">dev-main</code>）拉出一条短期的集成分支，例如 <code class="language-plaintext highlighter-rouge">integration/dev-projA-projB</code>。</li>
  <li><strong>一次性修改配置</strong>：把 Dev 环境 ConfigMap 里的 <code class="language-plaintext highlighter-rouge">BRANCH</code> 字段，改为这条集成分支。Merge 后，ArgoCD 同步，Reloader 触发<strong>最后一次</strong>集群重启。</li>
  <li><strong>日常推代码</strong>：接下来，团队 A 和团队 B 分别将自己的 Feature 分支合并进这条集成分支。解决诸如共享配置、公共依赖目录的冲突。</li>
  <li><strong>无感更新</strong>：此时，因为 ConfigMap 没变，Reloader 不会叫唤。Git-Sync 会每 120 秒自动把集成分支上的新 commit 搬进 EFS。你们只需要喝口水，刷新一下 Airflow UI，新改的 DAG 就出现了。</li>
</ol>

<h4 id="️-诚实的权衡trade-offs">⚠️ 诚实的权衡（Trade-offs）</h4>

<p>这种玩法很爽，但有代价：</p>
<ul>
  <li><strong>没有分支保护</strong>：集成分支是个“大杂烩”（Sandbox），没有严格的 CI 门禁，大家随时可能把别人跑通的代码覆盖掉。</li>
  <li><strong>必须有退出机制</strong>：这条分支是<strong>短生命周期</strong>的。一旦双方联调通过，必须立刻合并回上游的主干分支，并释放这个 Dev 环境。<strong>千万别把它养成第二个 <code class="language-plaintext highlighter-rouge">main</code></strong>，否则你会陷入无休止的“集成地狱”。</li>
</ul>

<p>如果项目 A 和 B 之间<strong>没有硬依赖</strong>，别折腾集成分支，直接去申请两个独立的 Dev 环境（一人占一个槽位），物理隔离永远是最高效的。</p>

<hr />

<h3 id="️-排坑自救指南">🛠️ 排坑自救指南</h3>

<p>最后，如果你发现代码推上去了，但环境没反应，别急着发脾气，按下面的逻辑排查（以 <code class="language-plaintext highlighter-rouge">kubectl</code> 为例）：</p>

<p><strong>Q1: 我 push 了 DAG 代码，为什么 UI 没更新？</strong></p>
<ul>
  <li><strong>真理</strong>：等。Git-Sync 的轮询间隔（<code class="language-plaintext highlighter-rouge">INTERVAL</code>）通常是 120 秒。如果 2 分钟后还没出，去查 Git-Sync 的日志，看是不是连不上 GitHub 或者有文件冲突。</li>
  <li><code class="language-plaintext highlighter-rouge">kubectl -n &lt;your-namespace&gt; logs deploy/airflow-git-sync --tail=50</code></li>
</ul>

<p><strong>Q2: 改了 ConfigMap 里的 BRANCH，还需要去推一次 DAG 仓库吗？</strong></p>
<ul>
  <li><strong>不需要</strong>。Reloader 重启 Pod 后，新的 Git-Sync 容器启动时，会全量 Clone 你在 ConfigMap 里指定的那个新分支。只要远端有这个分支，它自己会拉下来。</li>
</ul>

<p><strong>Q3: 怎么确认是 Reloader 在搞鬼？</strong></p>
<ul>
  <li>查 Reloader 的日志，搜索关键词。只要看到 <code class="language-plaintext highlighter-rouge">Changes detected in ConfigMap... Reloading deployment</code>，你就知道店长又在吹哨了。</li>
  <li><code class="language-plaintext highlighter-rouge">kubectl -n reloader logs deploy/reloader-reloader --tail=30</code></li>
</ul>

<p><strong>总结成一句话</strong>：GitOps 环境下，<strong>业务迭代靠 Git-Sync 等时间，环境切换靠 Reloader 滚集群。</strong> 把这俩拆开用，你的测试环境就能稳如老狗。</p>]]></content><author><name></name></author><category term="tech" /><category term="airflow" /><category term="gitops" /><category term="kubernetes" /><category term="argocd" /><summary type="html"><![CDATA[“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。” — David Wheeler]]></summary></entry><entry><title type="html">Your Git Identity and Your Push Credential Were Never the Same System</title><link href="http://todzhang.com/blogs/tech/en/git-multi-identity-direnv-includeif" rel="alternate" type="text/html" title="Your Git Identity and Your Push Credential Were Never the Same System" /><published>2026-08-12T00:00:00+00:00</published><updated>2026-08-12T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/en/git-multi-identity-direnv-includeif-en</id><content type="html" xml:base="http://todzhang.com/blogs/tech/en/git-multi-identity-direnv-includeif"><![CDATA[<blockquote>
  <p>The map is not the territory. — Alfred Korzybski</p>
</blockquote>

<hr />

<h1 id="your-git-identity-and-your-push-credential-were-never-the-same-system">Your Git Identity and Your Push Credential Were Never the Same System</h1>

<p><em>From “but my gitconfig is correct” to knowing exactly which three places to check</em></p>

<p>Kai pinged me with a screenshot of a PR. Every commit had that grey, faceless GitHub avatar next to it — the one that means “this email doesn’t belong to any account here.” The commits were on a work repo, authored, apparently, by a stranger.</p>

<blockquote>
  <p>“I checked. <code class="language-plaintext highlighter-rouge">git config user.email</code> returns my work address. So what exactly is it lying about?”</p>
</blockquote>

<p>I asked them to run it again with one extra flag:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>file:/Users/kai/.gitconfig	kai@personal.example.com
</code></pre></div></div>

<p>One line, and the story inverted. The earlier check had been run in a different terminal tab — one whose working directory wasn’t inside the repo they thought it was. At the moment the commits were actually made, <code class="language-plaintext highlighter-rouge">includeif</code> never matched at all.</p>

<p>That’s the boring half of the bug. The interesting half is this: <strong>even if <code class="language-plaintext highlighter-rouge">includeif</code> had matched, and every commit carried the right email, those pushes could still have gone out signed by an entirely different SSH key</strong> — and git would not have said a word about it.</p>

<p>That gap is what this post is about.</p>

<p><strong>Three things you’ll walk away with:</strong></p>

<ul>
  <li>A diagnostic one-liner that pins down a multi-identity problem in about ten seconds, and why it probes exactly those three places</li>
  <li>The one-sentence boundary between <code class="language-plaintext highlighter-rouge">includeif</code> and <code class="language-plaintext highlighter-rouge">direnv</code> — one swaps your ID card, the other swaps your wallet</li>
  <li>A broken symmetry almost nobody notices: <strong>whose name goes into a commit and whose key gets you through the door are two systems that never compare notes</strong></li>
</ul>

<p>The order below isn’t the order I actually debugged it in. Real debugging works backwards from the symptom; teaching should work forwards from what you ought to check first.</p>

<hr />

<h2 id="1-why-that-one-liner-probes-exactly-those-three-places">1. Why that one-liner probes exactly those three places</h2>

<p>For any multi-identity problem, I run this first. It’s read-only — it changes nothing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"--- local  ---"</span><span class="p">;</span> git <span class="nt">-C</span> ~/ws/work/etl-pipeline config <span class="nt">--local</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|core\.'</span> 2&gt;/dev/null
<span class="nb">echo</span> <span class="s2">"--- global ---"</span><span class="p">;</span> git config <span class="nt">--global</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|includeif|core\.ssh'</span> 2&gt;/dev/null
<span class="nb">echo</span> <span class="s2">"--- direnv ---"</span><span class="p">;</span> <span class="nb">ls</span> ~/.local/share/direnv/allow 2&gt;/dev/null | <span class="nb">head</span><span class="p">;</span> direnv status 2&gt;&amp;1 | <span class="nb">head</span> <span class="nt">-8</span>
</code></pre></div></div>

<p>Each section answers a separate question.</p>

<h3 id="section-1--is-this-repo-quietly-overriding-something">Section 1 — is this repo quietly overriding something?</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">-C</span> ~/ws/work/etl-pipeline config <span class="nt">--local</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|core\.'</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-C &lt;path&gt;</code>: run against a specific repo without <code class="language-plaintext highlighter-rouge">cd</code>-ing there. This matters more than it looks — <strong>the single most common misdiagnosis in multi-identity setups is checking config from the wrong working directory</strong>, which is precisely how Kai got fooled.</li>
  <li><code class="language-plaintext highlighter-rouge">--local</code>: reads only that repo’s <code class="language-plaintext highlighter-rouge">.git/config</code>, ignoring global. Highest-precedence layer first.</li>
  <li><code class="language-plaintext highlighter-rouge">--get-regexp 'ssh|url|core\.'</code>: regex against key names, pulling out <code class="language-plaintext highlighter-rouge">core.sshCommand</code> and <code class="language-plaintext highlighter-rouge">url."git@github.com:".insteadOf</code>-style rewrite rules in one shot.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">url.*.insteadOf</code> is the one people forget to check. It silently rewrites an <code class="language-plaintext highlighter-rouge">https://</code> remote into <code class="language-plaintext highlighter-rouge">git@</code>, sending you down a completely different authentication path than the one you think you’re on.</p>

<h3 id="section-2--what-do-the-global-rules-actually-say">Section 2 — what do the global rules actually say?</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--global</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|includeif|core\.ssh'</span>
</code></pre></div></div>

<p>Same idea, aimed at <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>, with <code class="language-plaintext highlighter-rouge">includeif</code> added — the mechanism that conditionally pulls in another config file.</p>

<p>Note what this checks: the <strong>rule</strong>, not the <strong>result</strong>. <code class="language-plaintext highlighter-rouge">--get-regexp 'includeif'</code> tells you what conditions you intended to switch identity on; <code class="language-plaintext highlighter-rouge">--show-origin --get user.email</code> tells you which one actually won just now. When those two disagree, your bug lives in the match condition.</p>

<h3 id="section-3--has-direnv-actually-been-allowed-here">Section 3 — has direnv actually been allowed here?</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> ~/.local/share/direnv/allow 2&gt;/dev/null | <span class="nb">head
</span>direnv status 2&gt;&amp;1 | <span class="nb">head</span> <span class="nt">-8</span>
</code></pre></div></div>

<p>This probes something git cannot see at all: whether you’ve ever signed off on this directory’s <code class="language-plaintext highlighter-rouge">.envrc</code>. Without that approval, not one environment variable loads — and nothing complains. Git won’t error. The AWS CLI won’t tell you that you forgot to <code class="language-plaintext highlighter-rouge">direnv allow</code>. It will just quietly use the default profile and do something you didn’t want.</p>

<table>
  <thead>
    <tr>
      <th>Section</th>
      <th>Question it answers</th>
      <th>What you miss by skipping it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--local</code></td>
      <td>Does this repo override the global rules?</td>
      <td>You edit <code class="language-plaintext highlighter-rouge">~/.gitconfig</code> and nothing changes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--global</code> + <code class="language-plaintext highlighter-rouge">includeif</code></td>
      <td>What are my identity-switching rules?</td>
      <td>The rule exists but its condition never matches</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">direnv</code> allow list</td>
      <td>Did the env vars load at all?</td>
      <td>Git looks fine while AWS / kubectl use the wrong credentials</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>A good diagnostic command earns its keep not from what it prints, but from what it <strong>rules out at the same time</strong>.</p>
</blockquote>

<hr />

<h2 id="2-includeif-a-static-id-card">2. includeif: a static ID card</h2>

<p>Written in <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[includeif "gitdir:~/ws/work/"]</span>
    <span class="py">path</span> <span class="p">=</span> <span class="s">~/.gitconfig-work</span>

<span class="nn">[includeif "gitdir:~/ws/personal/"]</span>
    <span class="py">path</span> <span class="p">=</span> <span class="s">~/.gitconfig-personal</span>
</code></pre></div></div>

<p>And the file it pulls in:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ~/.gitconfig-work
</span><span class="nn">[user]</span>
    <span class="py">name</span> <span class="p">=</span> <span class="s">Kai Chen</span>
    <span class="py">email</span> <span class="p">=</span> <span class="s">kai.chen@work.example.com</span>
<span class="nn">[core]</span>
    <span class="py">sshCommand</span> <span class="p">=</span> <span class="s">ssh -i ~/.ssh/work_ed25519</span>
</code></pre></div></div>

<p>Three things to hold onto:</p>

<ul>
  <li>Triggered by a <strong>directory path match</strong> (<code class="language-plaintext highlighter-rouge">gitdir:</code>), or by branch name (<code class="language-plaintext highlighter-rouge">onbranch:</code>, git 2.36+)</li>
  <li>On a match, it merges in <code class="language-plaintext highlighter-rouge">[user]</code>, <code class="language-plaintext highlighter-rouge">[core]</code>, and anything else from the target file</li>
  <li>Purely <strong>static and git-internal</strong> — no external process, and <code class="language-plaintext highlighter-rouge">git config --show-origin --get</code> will tell you exactly which file the winning value came from</li>
</ul>

<p>🩸 <strong>Hard-earned lesson</strong>: <code class="language-plaintext highlighter-rouge">gitdir:</code> matches the <strong>repo directory itself</strong> (where <code class="language-plaintext highlighter-rouge">.git</code> lives), not your shell’s current directory. Reach it through a symlink or a git worktree and the match can fail silently. macOS adds a second trap: the filesystem is case-insensitive, but <code class="language-plaintext highlighter-rouge">gitdir:</code> is case-sensitive by default — <code class="language-plaintext highlighter-rouge">~/ws/Work/</code> and <code class="language-plaintext highlighter-rouge">~/ws/work/</code> are the same folder in Finder and two different strings to <code class="language-plaintext highlighter-rouge">includeif</code>. Use <code class="language-plaintext highlighter-rouge">gitdir/i:</code> when that bites.</p>

<p>Stop guessing at it. One command settles it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">-C</span> &lt;repo&gt; config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email
</code></pre></div></div>

<p>The file path in that output is the verdict.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">includeif</code> is a rule, not a guarantee. It only exists in the moments the path actually matches.</p>
</blockquote>

<hr />

<h2 id="3-direnv-a-dynamic-wallet-that-needs-your-signature">3. direnv: a dynamic wallet that needs your signature</h2>

<p>An <code class="language-plaintext highlighter-rouge">.envrc</code> file plus one <code class="language-plaintext highlighter-rouge">direnv allow</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ~/ws/work/etl-pipeline/.envrc</span>
<span class="nb">export </span><span class="nv">AWS_PROFILE</span><span class="o">=</span>work-dev
<span class="nb">export </span><span class="nv">GIT_SSH_COMMAND</span><span class="o">=</span><span class="s2">"ssh -i ~/.ssh/work_ed25519"</span>
<span class="nb">export </span><span class="nv">KUBECONFIG</span><span class="o">=</span>~/.kube/work-dev.yaml
</code></pre></div></div>

<ul>
  <li>Triggered <strong>every time your shell <code class="language-plaintext highlighter-rouge">cd</code>s into the directory</strong>; the direnv hook sources it, and unloads it when you leave</li>
  <li>Manages <strong>arbitrary environment variables</strong> — not just git, but AWS profiles, Kubernetes contexts, API keys, whatever you need</li>
  <li><strong>Requires an explicit <code class="language-plaintext highlighter-rouge">direnv allow</code></strong> once (exactly what section 3 of the diagnostic was checking), and <strong>editing <code class="language-plaintext highlighter-rouge">.envrc</code> immediately revokes that approval</strong> until you allow it again</li>
</ul>

<p>That last point is deliberate design, and it’s also the single most common source of “but I changed it and nothing happened.”</p>

<p>🩸 <strong>Hard-earned lesson</strong>: <code class="language-plaintext highlighter-rouge">.envrc</code> is a <strong>real, executing shell script</strong>, not declarative config. Clone an unfamiliar repo that ships its own <code class="language-plaintext highlighter-rouge">.envrc</code> and you should not reflexively <code class="language-plaintext highlighter-rouge">direnv allow</code> it — that grants someone else’s script arbitrary execution on your machine, automatically, every time you <code class="language-plaintext highlighter-rouge">cd</code> in. The re-approval prompt isn’t friction for its own sake; it turns trust into something you have to perform by hand.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>includeif</th>
      <th>direnv</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Owned by</td>
      <td>git internals</td>
      <td>the shell (external process + hook)</td>
    </tr>
    <tr>
      <td>Fires when</td>
      <td>any git command runs</td>
      <td>you <code class="language-plaintext highlighter-rouge">cd</code> into or out of the directory</td>
    </tr>
    <tr>
      <td>Scope</td>
      <td>git config keys only</td>
      <td>any environment variable</td>
    </tr>
    <tr>
      <td>Needs approval</td>
      <td>no</td>
      <td>yes, and edits revoke it</td>
    </tr>
    <tr>
      <td>Security model</td>
      <td>static data, never executed</td>
      <td><strong>executes arbitrary code</strong></td>
    </tr>
    <tr>
      <td>How to verify</td>
      <td><code class="language-plaintext highlighter-rouge">git config --show-origin --get</code></td>
      <td><code class="language-plaintext highlighter-rouge">direnv status</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4-the-broken-symmetry-whose-name-versus-whose-key">4. The broken symmetry: whose name, versus whose key</h2>

<p>If you read one section, read this one.</p>

<p>Most people carry this model in their head: <strong>“I switched my git identity to work, therefore I’m operating as work.”</strong></p>

<p>That model is wrong. It fuses two things that have nothing to do with each other:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Decided by</th>
      <th>Verified by anyone?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Whose name is in the commit</td>
      <td><code class="language-plaintext highlighter-rouge">user.name</code> / <code class="language-plaintext highlighter-rouge">user.email</code>, plain text fields</td>
      <td><strong>No.</strong> Put any address you like in there; git records it without comment</td>
    </tr>
    <tr>
      <td>Whether the push succeeds</td>
      <td>which private key the SSH client presents</td>
      <td><strong>Yes.</strong> The server checks the public key and returns <code class="language-plaintext highlighter-rouge">Permission denied</code> if it’s wrong</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">user.email</code> is <strong>metadata you typed yourself</strong>, and git never validates it. Right now, today, you can run <code class="language-plaintext highlighter-rouge">git -c user.email=linus@kernel.org commit</code> and produce a commit attributed to Linus. Whether your push lands depends entirely on which private key came out during the SSH handshake — not one byte of which is influenced by <code class="language-plaintext highlighter-rouge">user.email</code>.</p>

<p>That’s the <strong>broken symmetry</strong> at the heart of this setup: two chains that both go by the name “identity,” one completely unguarded and one strictly verified, with no consistency check anywhere between them. So you get “the push succeeded” and “the attribution is wrong” at the same time, and you read them as if each confirms the other.</p>

<p>There’s a subtler layer underneath:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># in the file includeif pulls in
</span><span class="nn">[core]</span>
    <span class="py">sshCommand</span> <span class="p">=</span> <span class="s">ssh -i ~/.ssh/work_ed25519</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># in .envrc</span>
<span class="nb">export </span><span class="nv">GIT_SSH_COMMAND</span><span class="o">=</span><span class="s2">"ssh -i ~/.ssh/personal_ed25519"</span>
</code></pre></div></div>

<p><strong>The environment variable <code class="language-plaintext highlighter-rouge">GIT_SSH_COMMAND</code> takes precedence over the <code class="language-plaintext highlighter-rouge">core.sshCommand</code> config key.</strong> So the work key you carefully wired up through <code class="language-plaintext highlighter-rouge">includeif</code> gets silently overruled by a variable left sitting in your shell — and <code class="language-plaintext highlighter-rouge">git config --get core.sshCommand</code> will keep reporting the work key, because it’s reporting configuration, not behavior.</p>

<p>To see what’s actually happening, you have to go around git’s own account of itself:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># who is really driving SSH</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$GIT_SSH_COMMAND</span><span class="s2">"</span>
git config <span class="nt">--show-origin</span> <span class="nt">--get</span> core.sshCommand

<span class="c"># who the server thinks you are (GitHub answers with the account name)</span>
ssh <span class="nt">-T</span> git@github.com
</code></pre></div></div>

<p>That last command is the most honest one in the whole toolkit. It doesn’t ask your config. It asks the other side.</p>

<hr />

<h2 id="5-the-two-systems-as-one-chain">5. The two systems as one chain</h2>

<p>Here’s the full path, with each system owning one link:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd into ~/ws/work/etl-pipeline/
   ↓ direnv loads .envrc (only if allowed)
AWS_PROFILE=work-dev + GIT_SSH_COMMAND points at the work key
   ↓ a git command runs, includeif matches gitdir
user.email becomes the work address, written into the commit
   ↓ push, SSH handshake
the server authenticates the key — the key, never the email
</code></pre></div></div>

<p>Break any link and you get a different symptom, none of which names the broken link:</p>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Broken link</th>
      <th>Check first</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Commits attributed to your personal email, but the push worked</td>
      <td><code class="language-plaintext highlighter-rouge">includeif</code> didn’t match; the key was fine</td>
      <td><code class="language-plaintext highlighter-rouge">git config --show-origin --get user.email</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Permission denied (publickey)</code></td>
      <td>Identity right, key never arrived</td>
      <td><code class="language-plaintext highlighter-rouge">echo $GIT_SSH_COMMAND</code>; <code class="language-plaintext highlighter-rouge">ssh -T git@github.com</code></td>
    </tr>
    <tr>
      <td>Git behaves perfectly, AWS / kubectl use the wrong account</td>
      <td>direnv was never allowed</td>
      <td><code class="language-plaintext highlighter-rouge">direnv status</code></td>
    </tr>
    <tr>
      <td>Edited <code class="language-plaintext highlighter-rouge">.envrc</code>, still getting the old values</td>
      <td>the edit revoked the approval</td>
      <td><code class="language-plaintext highlighter-rouge">direnv allow</code></td>
    </tr>
    <tr>
      <td>Config clearly says work key, personal key is being used</td>
      <td>env var overrode the config key</td>
      <td><code class="language-plaintext highlighter-rouge">echo $GIT_SSH_COMMAND</code></td>
    </tr>
  </tbody>
</table>

<p>Look at those last two rows. Both are cases where you believe you already fixed it. They’re the expensive ones, because they raise no error at all — they just quietly do the wrong thing.</p>

<hr />

<h2 id="do-this-today">Do this today</h2>

<ol>
  <li><strong>Run <code class="language-plaintext highlighter-rouge">git -C . config --show-origin --get user.email</code> in your work repo right now.</strong> Not <code class="language-plaintext highlighter-rouge">--get</code> — <code class="language-plaintext highlighter-rouge">--show-origin --get</code>. Look at which file that value came from. If it’s <code class="language-plaintext highlighter-rouge">~/.gitconfig</code> rather than the work config you expected, your <code class="language-plaintext highlighter-rouge">includeif</code> has never once fired.</li>
  <li><strong>Run <code class="language-plaintext highlighter-rouge">ssh -T git@github.com</code></strong> and see what the server calls you. It’s the only answer that doesn’t depend on your local config telling the truth.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">echo "$GIT_SSH_COMMAND"</code>.</strong> If it’s non-empty and you don’t remember setting it, it is currently overriding every <code class="language-plaintext highlighter-rouge">core.sshCommand</code> you have.</li>
  <li><strong>Rewrite one <code class="language-plaintext highlighter-rouge">gitdir:</code> pattern as an absolute path and re-test</strong>, to rule out a symlink or worktree quietly breaking the match.</li>
  <li>
    <p><strong>Put those three checks into a <code class="language-plaintext highlighter-rouge">git-whoami</code> function in your dotfiles.</strong> This is the highest-value item on the list. Everything above is currently tribal knowledge living only in your head, and in three months you will re-derive all of it from scratch. Turn it into an artifact that executes and you never reason about it again:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git-whoami<span class="o">()</span> <span class="o">{</span>
  <span class="nb">echo</span> <span class="s2">"email : </span><span class="si">$(</span>git config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'(none)'</span><span class="si">)</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"ssh   : </span><span class="k">${</span><span class="nv">GIT_SSH_COMMAND</span><span class="k">:-</span><span class="si">$(</span>git config <span class="nt">--get</span> core.sshCommand <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'(default)'</span><span class="si">)</span><span class="k">}</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"aws   : </span><span class="k">${</span><span class="nv">AWS_PROFILE</span><span class="k">:-</span><span class="s1">'(default)'</span><span class="k">}</span><span class="s2">"</span>
  direnv status 2&gt;/dev/null | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s1">'loaded rc\|allowed'</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">"direnv: n/a"</span>
<span class="o">}</span>
</code></pre></div>    </div>
  </li>
  <li><strong><code class="language-plaintext highlighter-rouge">cat .envrc</code> before you <code class="language-plaintext highlighter-rouge">direnv allow</code> any repo you didn’t write.</strong> That’s not fastidiousness, it’s a security boundary.</li>
</ol>

<hr />

<p><em>Your config files record what you meant. Your runtime environment records what you did. Every multi-identity incident happens in the gap where those two drift apart and nothing raises a hand.</em></p>]]></content><author><name></name></author><category term="tech" /><category term="git" /><category term="shell-scripting" /><category term="devops" /><category term="security" /><category term="dotfiles" /><summary type="html"><![CDATA[The map is not the territory. — Alfred Korzybski]]></summary></entry><entry><title type="html">你的 Git 身份和你的推送凭证，从来不是同一套系统</title><link href="http://todzhang.com/blogs/tech/zh/git-multi-identity-direnv-includeif" rel="alternate" type="text/html" title="你的 Git 身份和你的推送凭证，从来不是同一套系统" /><published>2026-08-12T00:00:00+00:00</published><updated>2026-08-12T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/zh/git-multi-identity-direnv-includeif-zh</id><content type="html" xml:base="http://todzhang.com/blogs/tech/zh/git-multi-identity-direnv-includeif"><![CDATA[<blockquote>
  <p>名不正，则言不顺；言不顺，则事不成。——《论语·子路》</p>
</blockquote>

<hr />

<h1 id="你的-git-身份和你的推送凭证从来不是同一套系统">你的 Git 身份和你的推送凭证，从来不是同一套系统</h1>

<p><em>从”我的 gitconfig 明明是对的”到”我知道该查哪三个地方”</em></p>

<p>Kai 发消息过来的时候，语气是那种已经自己折腾了一个小时、开始怀疑人生的语气：</p>

<blockquote>
  <p>“我在公司仓库里提交，GitHub 上显示的是我的个人邮箱。但我 <code class="language-plaintext highlighter-rouge">git config user.email</code> 查了，返回的就是公司邮箱。它到底在骗谁？”</p>
</blockquote>

<p>我让他把 <code class="language-plaintext highlighter-rouge">git config user.email</code> 的输出发过来。是公司邮箱，没错。</p>

<p>然后我让他加一个参数重跑：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>file:/Users/kai/.gitconfig	kai@personal.example.com
</code></pre></div></div>

<p>一行输出，故事就全变了。他之前查的那次，是在另一个终端窗口里跑的——那个窗口的当前目录，根本不在他以为的那个仓库下面。而真正提交的时候，<code class="language-plaintext highlighter-rouge">includeif</code> 压根没匹配上。</p>

<p>但这还不是最有意思的部分。最有意思的是：<strong>就算 <code class="language-plaintext highlighter-rouge">includeif</code> 匹配上了、commit 里的邮箱是对的，他的 push 依然可能是用另一把 SSH key 完成的</strong>——而 git 一个字都不会提醒他。</p>

<p>这篇想讲清楚的就是这件事。</p>

<p><strong>读完你会拿到三个东西：</strong></p>

<ul>
  <li>一条能在 10 秒内定位多身份环境问题的诊断命令，以及它为什么恰好查这三处</li>
  <li><code class="language-plaintext highlighter-rouge">includeif</code> 和 <code class="language-plaintext highlighter-rouge">direnv</code> 的一句话分界线——一个换身份证，一个换钱包</li>
  <li>一个大多数人从没意识到的对称性破缺：<strong>commit 里写谁的名字，和 push 时用谁的钥匙，是两套互不通气的系统</strong></li>
</ul>

<p>下面的顺序不是我当时的排查顺序，是按”最先该查什么”重排过的。真实排查往往是从最下游的症状倒着摸上来的，但教学要反过来。</p>

<hr />

<h2 id="一那条诊断命令为什么恰好查这三处">一、那条诊断命令，为什么恰好查这三处</h2>

<p>排查多身份环境，我固定跑这一段（只读，不改任何东西）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"--- local  ---"</span><span class="p">;</span> git <span class="nt">-C</span> ~/ws/work/etl-pipeline config <span class="nt">--local</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|core\.'</span> 2&gt;/dev/null
<span class="nb">echo</span> <span class="s2">"--- global ---"</span><span class="p">;</span> git config <span class="nt">--global</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|includeif|core\.ssh'</span> 2&gt;/dev/null
<span class="nb">echo</span> <span class="s2">"--- direnv ---"</span><span class="p">;</span> <span class="nb">ls</span> ~/.local/share/direnv/allow 2&gt;/dev/null | <span class="nb">head</span><span class="p">;</span> direnv status 2&gt;&amp;1 | <span class="nb">head</span> <span class="nt">-8</span>
</code></pre></div></div>

<p>拆开看，每一段都在回答一个独立的问题。</p>

<h3 id="第一段--这个仓库自己有没有偷偷覆盖什么">第一段 — 这个仓库自己有没有偷偷覆盖什么</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">-C</span> ~/ws/work/etl-pipeline config <span class="nt">--local</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|core\.'</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-C &lt;path&gt;</code>：不用 <code class="language-plaintext highlighter-rouge">cd</code> 过去，直接指定在哪个仓库执行。这一点很关键——<strong>多身份问题最常见的误诊，就是在错误的当前目录下查配置</strong>，Kai 踩的就是这个坑。</li>
  <li><code class="language-plaintext highlighter-rouge">--local</code>：只读这个仓库的 <code class="language-plaintext highlighter-rouge">.git/config</code>，不看全局。优先级最高的一层先看。</li>
  <li><code class="language-plaintext highlighter-rouge">--get-regexp 'ssh|url|core\.'</code>：按 key 名做正则匹配，一次捞出 <code class="language-plaintext highlighter-rouge">core.sshCommand</code>、<code class="language-plaintext highlighter-rouge">url."git@github.com:".insteadOf</code> 这类 SSH 代理和 URL 重写规则。</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">url.*.insteadOf</code> 是很多人漏查的一项：它会在你毫无察觉的情况下把 <code class="language-plaintext highlighter-rouge">https://</code> 的远端改写成 <code class="language-plaintext highlighter-rouge">git@</code>，走完全不同的一套认证。</p>

<h3 id="第二段--全局这一层规则是怎么写的">第二段 — 全局这一层，规则是怎么写的</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--global</span> <span class="nt">--get-regexp</span> <span class="s1">'ssh|url|includeif|core\.ssh'</span>
</code></pre></div></div>

<p>同样的思路，对象换成 <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>，多加了 <code class="language-plaintext highlighter-rouge">includeif</code>——也就是”条件性引入另一个配置文件”的规则本身。</p>

<p>注意这里查的是<strong>规则</strong>，不是<strong>结果</strong>。<code class="language-plaintext highlighter-rouge">--get-regexp 'includeif'</code> 告诉你”你打算按什么条件切身份”，而 <code class="language-plaintext highlighter-rouge">--show-origin --get user.email</code> 才告诉你”此时此刻实际生效的是哪一个”。这两者对不上的时候，问题就在规则的匹配条件上。</p>

<h3 id="第三段--direnv-到底被授权了没有">第三段 — direnv 到底被授权了没有</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> ~/.local/share/direnv/allow 2&gt;/dev/null | <span class="nb">head
</span>direnv status 2&gt;&amp;1 | <span class="nb">head</span> <span class="nt">-8</span>
</code></pre></div></div>

<p>这一段查的是一个 git 完全看不见的东西：这个目录的 <code class="language-plaintext highlighter-rouge">.envrc</code> 有没有被你签字放行过。没放行，环境变量就一个都不会加载——而 git 不会报错，AWS CLI 也不会说”你忘了 allow”，它只会用默认 profile 干出你不想要的事。</p>

<table>
  <thead>
    <tr>
      <th>命令段</th>
      <th>回答的问题</th>
      <th>漏查会怎样</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--local</code></td>
      <td>这个仓库有没有覆盖全局规则？</td>
      <td>明明改了 <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>，行为一点没变</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--global</code> + <code class="language-plaintext highlighter-rouge">includeif</code></td>
      <td>我的身份切换规则长什么样？</td>
      <td>规则写了，但条件永远匹配不上</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">direnv allow</code> 列表</td>
      <td>环境变量到底加载了没？</td>
      <td>git 一切正常，AWS / kubectl 权限却是错的</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>一条好的诊断命令，价值不在于它输出了什么，而在于它<strong>同时排除了什么</strong>。</p>
</blockquote>

<hr />

<h2 id="二includeif静态的身份证">二、includeif：静态的身份证</h2>

<p>写在 <code class="language-plaintext highlighter-rouge">~/.gitconfig</code> 里：</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[includeif "gitdir:~/ws/work/"]</span>
    <span class="py">path</span> <span class="p">=</span> <span class="s">~/.gitconfig-work</span>

<span class="nn">[includeif "gitdir:~/ws/personal/"]</span>
    <span class="py">path</span> <span class="p">=</span> <span class="s">~/.gitconfig-personal</span>
</code></pre></div></div>

<p>被引入的文件长这样：</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ~/.gitconfig-work
</span><span class="nn">[user]</span>
    <span class="py">name</span> <span class="p">=</span> <span class="s">Kai Chen</span>
    <span class="py">email</span> <span class="p">=</span> <span class="s">kai.chen@work.example.com</span>
<span class="nn">[core]</span>
    <span class="py">sshCommand</span> <span class="p">=</span> <span class="s">ssh -i ~/.ssh/work_ed25519</span>
</code></pre></div></div>

<p>三个要点：</p>

<ul>
  <li>触发条件是<strong>目录路径匹配</strong>（<code class="language-plaintext highlighter-rouge">gitdir:</code>），也可以按分支名匹配（<code class="language-plaintext highlighter-rouge">onbranch:</code>，git 2.36+）</li>
  <li>一旦匹配，就把目标文件里的 <code class="language-plaintext highlighter-rouge">[user]</code>、<code class="language-plaintext highlighter-rouge">[core]</code> 等整体合并进来</li>
  <li><strong>纯静态、纯 git 内部机制</strong>，不依赖任何外部进程，<code class="language-plaintext highlighter-rouge">git config --show-origin --get</code> 能直接看到”最终生效的值来自哪个文件”</li>
</ul>

<p>🩸 <strong>血泪提醒</strong>：<code class="language-plaintext highlighter-rouge">gitdir:</code> 匹配的是<strong>仓库目录本身</strong>（<code class="language-plaintext highlighter-rouge">.git</code> 所在的位置），不是你 shell 的当前目录。如果你是通过 symlink 进去的、或者用的是 git worktree，路径匹配可能悄无声息地失效。macOS 上还有一个额外的坑：文件系统大小写不敏感，但 <code class="language-plaintext highlighter-rouge">gitdir:</code> 默认大小写敏感——<code class="language-plaintext highlighter-rouge">~/ws/Work/</code> 和 <code class="language-plaintext highlighter-rouge">~/ws/work/</code> 在 Finder 里是同一个目录，在 <code class="language-plaintext highlighter-rouge">includeif</code> 眼里不是。这种情况用 <code class="language-plaintext highlighter-rouge">gitdir/i:</code>。</p>

<p>排查它只需要一条命令，别再靠猜：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">-C</span> &lt;repo&gt; config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email
</code></pre></div></div>

<p>输出里的文件路径，就是判决书。</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">includeif</code> 是一条规则，不是一个保证。它只在路径真的匹配上时才存在。</p>
</blockquote>

<hr />

<h2 id="三direnv动态的钱包而且需要你签字">三、direnv：动态的钱包，而且需要你签字</h2>

<p><code class="language-plaintext highlighter-rouge">.envrc</code> 文件加上一次 <code class="language-plaintext highlighter-rouge">direnv allow</code>：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ~/ws/work/etl-pipeline/.envrc</span>
<span class="nb">export </span><span class="nv">AWS_PROFILE</span><span class="o">=</span>work-dev
<span class="nb">export </span><span class="nv">GIT_SSH_COMMAND</span><span class="o">=</span><span class="s2">"ssh -i ~/.ssh/work_ed25519"</span>
<span class="nb">export </span><span class="nv">KUBECONFIG</span><span class="o">=</span>~/.kube/work-dev.yaml
</code></pre></div></div>

<ul>
  <li>触发条件是 <strong>shell 每次 <code class="language-plaintext highlighter-rouge">cd</code> 进这个目录</strong>，direnv 的 hook 会自动 source 它，离开目录时自动卸载</li>
  <li>管的是<strong>任意环境变量</strong>，完全不局限于 git——AWS profile、K8s context、API key 全能塞</li>
  <li><strong>需要显式 <code class="language-plaintext highlighter-rouge">direnv allow</code> 授权一次</strong>（这正是诊断命令第三段在查的东西）；而且<strong>改了 <code class="language-plaintext highlighter-rouge">.envrc</code> 的内容，授权立即失效</strong>，必须重新 allow</li>
</ul>

<p>最后这条是设计上的刻意为之，也是最容易让人以为”我的改动没生效”的地方。</p>

<p>🩸 <strong>血泪提醒</strong>：<code class="language-plaintext highlighter-rouge">.envrc</code> 里的东西是<strong>真实执行的 shell 脚本</strong>，不是声明式配置。如果你 clone 下来一个陌生仓库，它自带 <code class="language-plaintext highlighter-rouge">.envrc</code>，千万别顺手 <code class="language-plaintext highlighter-rouge">direnv allow</code>——那等于允许别人写的脚本在你机器上跑任意命令，而且是在你每次 <code class="language-plaintext highlighter-rouge">cd</code> 进去的时候自动跑。direnv 要求重新授权，不是在给你添麻烦，它是在把”信任”这件事变成一个你必须亲手做的动作。</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>includeif</th>
      <th>direnv</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>归谁管</td>
      <td>git 内部</td>
      <td>shell（外部进程 + hook）</td>
    </tr>
    <tr>
      <td>触发时机</td>
      <td>每次执行 git 命令</td>
      <td>每次 <code class="language-plaintext highlighter-rouge">cd</code> 进/出目录</td>
    </tr>
    <tr>
      <td>管辖范围</td>
      <td>只有 git 配置项</td>
      <td>任意环境变量</td>
    </tr>
    <tr>
      <td>需要授权</td>
      <td>不需要</td>
      <td>需要，且改动后失效</td>
    </tr>
    <tr>
      <td>安全模型</td>
      <td>静态数据，不执行</td>
      <td><strong>执行任意代码</strong></td>
    </tr>
    <tr>
      <td>怎么验证</td>
      <td><code class="language-plaintext highlighter-rouge">git config --show-origin --get</code></td>
      <td><code class="language-plaintext highlighter-rouge">direnv status</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="四对称性破缺commit-写谁的名字和-push-用谁的钥匙">四、对称性破缺：commit 写谁的名字，和 push 用谁的钥匙</h2>

<p>这是这篇真正想让你记住的一节。如果你只看一节，看这节。</p>

<p>大多数人心里的模型是这样的：<strong>“我把 git 身份切成公司的，那我就是在以公司身份操作。”</strong></p>

<p>这个模型是错的。它把两件毫不相干的事当成了一件：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>决定它的是</th>
      <th>有没有人验证</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>commit 里写着谁</td>
      <td><code class="language-plaintext highlighter-rouge">user.name</code> / <code class="language-plaintext highlighter-rouge">user.email</code>，纯文本字段</td>
      <td><strong>没有</strong>。你可以写任何人的邮箱，git 照收不误</td>
    </tr>
    <tr>
      <td>push 能不能成功</td>
      <td>SSH 客户端拿出了哪把私钥</td>
      <td><strong>有</strong>。服务端拿公钥核对，错了就 <code class="language-plaintext highlighter-rouge">Permission denied</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">user.email</code> 是<strong>你自己填的一段元数据</strong>，git 从不校验它——你现在就可以用 <code class="language-plaintext highlighter-rouge">git -c user.email=linus@kernel.org commit</code> 提交一个署名 Linus 的 commit。而 push 能不能成，取决于 SSH 握手时用了哪把私钥，跟 <code class="language-plaintext highlighter-rouge">user.email</code> 一个字节的关系都没有。</p>

<p>这就是这套体系里的<strong>对称性破缺</strong>：两条链路看起来都叫”身份”，一条完全不设防，另一条严格校验，而它们之间没有任何一致性检查。于是你会同时拿到”push 成功了”和”署名是错的”这两个信号，还觉得它们互相印证。</p>

<p>更隐蔽的一层在这里：</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># includeif 引入的文件里
</span><span class="nn">[core]</span>
    <span class="py">sshCommand</span> <span class="p">=</span> <span class="s">ssh -i ~/.ssh/work_ed25519</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># .envrc 里</span>
<span class="nb">export </span><span class="nv">GIT_SSH_COMMAND</span><span class="o">=</span><span class="s2">"ssh -i ~/.ssh/personal_ed25519"</span>
</code></pre></div></div>

<p><strong>环境变量 <code class="language-plaintext highlighter-rouge">GIT_SSH_COMMAND</code> 的优先级高于配置项 <code class="language-plaintext highlighter-rouge">core.sshCommand</code>。</strong> 也就是说，你在 <code class="language-plaintext highlighter-rouge">includeif</code> 里精心配好的公司 key，会被一个残留在 shell 里的环境变量默默顶掉——<code class="language-plaintext highlighter-rouge">git config --get core.sshCommand</code> 查出来还是公司的那把，因为它查的是配置，不是最终生效的行为。</p>

<p>想看真相，得绕过 git 自己的说法：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 谁在真正决定 SSH 行为</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$GIT_SSH_COMMAND</span><span class="s2">"</span>
git config <span class="nt">--show-origin</span> <span class="nt">--get</span> core.sshCommand

<span class="c"># 服务端认为你是谁（GitHub 会直接告诉你账号名）</span>
ssh <span class="nt">-T</span> git@github.com
</code></pre></div></div>

<p>最后那条是整套排查里最实在的一条：它不问你的配置，它问服务端。</p>

<blockquote>
  <p>配置文件告诉你”你打算是谁”，<code class="language-plaintext highlighter-rouge">ssh -T</code> 告诉你”对面认为你是谁”。这两句话不一致的次数，比你想象的多。</p>
</blockquote>

<hr />

<h2 id="五把两套系统串起来看">五、把两套系统串起来看</h2>

<p>完整的链路是这样的，两个系统各管一段：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd 进 ~/ws/work/etl-pipeline/
   ↓ direnv 加载 .envrc（前提：allow 过）
AWS_PROFILE=work-dev + GIT_SSH_COMMAND 指向公司 key
   ↓ git 命令执行，includeif 匹配 gitdir
user.email 切成公司邮箱，写进 commit
   ↓ push，SSH 握手
服务端用公钥认人 —— 认的是 key，不是 email
</code></pre></div></div>

<p>链路上任何一段断了，症状都不一样，而且都不会明确告诉你断在哪：</p>

<table>
  <thead>
    <tr>
      <th>症状</th>
      <th>断在哪一段</th>
      <th>先查什么</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>commit 署名是个人邮箱，但 push 成功了</td>
      <td><code class="language-plaintext highlighter-rouge">includeif</code> 没匹配上；key 是对的</td>
      <td><code class="language-plaintext highlighter-rouge">git config --show-origin --get user.email</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Permission denied (publickey)</code></td>
      <td>身份对了，key 没到位</td>
      <td><code class="language-plaintext highlighter-rouge">echo $GIT_SSH_COMMAND</code>；<code class="language-plaintext highlighter-rouge">ssh -T git@github.com</code></td>
    </tr>
    <tr>
      <td>git 一切正常，AWS / kubectl 权限却是错的</td>
      <td>direnv 没 allow</td>
      <td><code class="language-plaintext highlighter-rouge">direnv status</code></td>
    </tr>
    <tr>
      <td>改了 <code class="language-plaintext highlighter-rouge">.envrc</code> 但值还是旧的</td>
      <td>改动让授权失效了</td>
      <td><code class="language-plaintext highlighter-rouge">direnv allow</code></td>
    </tr>
    <tr>
      <td>配置里明明是公司 key，用的却是个人 key</td>
      <td>环境变量顶掉了配置项</td>
      <td><code class="language-plaintext highlighter-rouge">echo $GIT_SSH_COMMAND</code></td>
    </tr>
  </tbody>
</table>

<p>注意最后两行：它们都是”你以为你改对了”的情况。这类问题最贵，因为它不触发任何报错，只是安静地做错事。</p>

<hr />

<h2 id="立刻可以做的事">立刻可以做的事</h2>

<ol>
  <li><strong>现在就在你手边的工作仓库里跑一次</strong> <code class="language-plaintext highlighter-rouge">git -C . config --show-origin --get user.email</code>。不是 <code class="language-plaintext highlighter-rouge">--get</code>，是 <code class="language-plaintext highlighter-rouge">--show-origin --get</code>。看清楚这个值来自哪个文件——如果来自 <code class="language-plaintext highlighter-rouge">~/.gitconfig</code> 而不是你以为的那个 work 配置，你的 <code class="language-plaintext highlighter-rouge">includeif</code> 从来就没生效过。</li>
  <li><strong>跑一次 <code class="language-plaintext highlighter-rouge">ssh -T git@github.com</code></strong>，看服务端叫你什么名字。这是唯一一个不依赖你本地配置说法的答案。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">echo "$GIT_SSH_COMMAND"</code></strong>。如果它非空，而你并不记得是谁设的，那它正在覆盖你所有的 <code class="language-plaintext highlighter-rouge">core.sshCommand</code> 配置。</li>
  <li><strong>把 <code class="language-plaintext highlighter-rouge">gitdir:</code> 的路径写成绝对路径来验证一次</strong>。确认不是 symlink 或者 worktree 让匹配悄悄失效了。</li>
  <li>
    <p><strong>把这三条命令写进你 dotfiles 里的一个 <code class="language-plaintext highlighter-rouge">git-whoami</code> 函数</strong>。这是这篇里最值钱的一条——上面那些排查步骤，现在还是”只存在于你脑子里的部落知识”，三个月后你会重新推理一遍。把它变成一个能执行的工程产物，你就再也不用推理了：</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git-whoami<span class="o">()</span> <span class="o">{</span>
  <span class="nb">echo</span> <span class="s2">"email : </span><span class="si">$(</span>git config <span class="nt">--show-origin</span> <span class="nt">--get</span> user.email 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'(none)'</span><span class="si">)</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"ssh   : </span><span class="k">${</span><span class="nv">GIT_SSH_COMMAND</span><span class="k">:-</span><span class="si">$(</span>git config <span class="nt">--get</span> core.sshCommand <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'(default)'</span><span class="si">)</span><span class="k">}</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"aws   : </span><span class="k">${</span><span class="nv">AWS_PROFILE</span><span class="k">:-</span><span class="s1">'(default)'</span><span class="k">}</span><span class="s2">"</span>
  direnv status 2&gt;/dev/null | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s1">'loaded rc\|allowed'</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">"direnv: n/a"</span>
<span class="o">}</span>
</code></pre></div>    </div>
  </li>
  <li><strong>克隆任何陌生仓库后，<code class="language-plaintext highlighter-rouge">direnv allow</code> 之前先 <code class="language-plaintext highlighter-rouge">cat .envrc</code></strong>。这条不是洁癖，是安全边界。</li>
</ol>

<hr />

<p><em>配置文件写的是你的意图，运行时环境写的是你的行为。多身份环境里出的每一个事故，都发生在这两者悄悄分岔、而没有任何人报错的那段距离里。</em></p>]]></content><author><name></name></author><category term="tech" /><category term="git" /><category term="shell-scripting" /><category term="devops" /><category term="security" /><category term="dotfiles" /><summary type="html"><![CDATA[名不正，则言不顺；言不顺，则事不成。——《论语·子路》]]></summary></entry><entry><title type="html">The CI Check That Never Ran Once Gave Us a Fake Green Checkmark</title><link href="http://todzhang.com/blogs/tech/en/ci-fix-verification-blind-spots" rel="alternate" type="text/html" title="The CI Check That Never Ran Once Gave Us a Fake Green Checkmark" /><published>2026-08-04T00:00:00+00:00</published><updated>2026-08-04T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/en/ci-fix-verification-blind-spots-en</id><content type="html" xml:base="http://todzhang.com/blogs/tech/en/ci-fix-verification-blind-spots"><![CDATA[<blockquote>
  <p>“The first principle is that you must not fool yourself — and you are the easiest person to fool.” — Richard Feynman</p>
</blockquote>

<hr />

<p>The batch job died in the middle of the night.</p>

<p>Whoever was on call got paged awake to one flat, uninformative line in the logs:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ModuleNotFoundError: No module named '_cffi_backend'
</code></pre></div></div>

<p>This job had been running fine for ages. Nothing had changed tonight — or so it seemed. The first instinct is always the same: a missing dependency. Except <code class="language-plaintext highlighter-rouge">cffi</code> was sitting right there in the lockfile. It was sitting right there in the image’s <code class="language-plaintext highlighter-rouge">site-packages</code>, too. The package was, unambiguously, installed.</p>

<p>The problem was never “installed or not.” It was that <strong>the Python that installed it, and the Python that was trying to run it, were not the same Python.</strong> CI had compiled and packaged this thing under 3.11; the artifact got dropped into a container whose base image was 3.10. A 3.10 interpreter scanning for native extensions only recognizes a short, specific list of filename suffixes — and <code class="language-plaintext highlighter-rouge">-311-</code> isn’t one of them. So it doesn’t fail to load the file. It <strong>doesn’t see the file at all.</strong> Even the error message can’t tell you which is true — it just shrugs and says “missing, or built for a different version.”</p>

<p>It took Alex half a day to trace that chain all the way down, and two more to ship the fix: stop hardcoding the Python version in CI, and derive it instead from each image’s own <code class="language-plaintext highlighter-rouge">Dockerfile</code> — so build and runtime are permanently locked to the same number, instead of relying on some engineer remembering “oh right, these two things have to match.”</p>

<p>PR merged. CI green across the board. He leaned back, let out a breath, and was one click away from closing the ticket and moving to the next one — when a thought floated up, weightless, and refused to leave:</p>

<blockquote>
  <p>“What does this green checkmark actually prove?”</p>
</blockquote>

<p>He’d find out, eventually, that the honest answer was: almost nothing.</p>

<h2 id="the-30-second-version">The 30-second version</h2>

<p>That PR touched exactly one file — <code class="language-plaintext highlighter-rouge">.github/workflows/build-and-deploy.yml</code>. He went and checked which jobs had actually run. <code class="language-plaintext highlighter-rouge">Test and build Images</code> sat there, quietly, saying <code class="language-plaintext highlighter-rouge">skipped</code>.</p>

<p>The repo’s CI gates its build matrix on path diffs: only images under <code class="language-plaintext highlighter-rouge">images/*</code> that actually changed get pulled into the build. This PR only touched the workflow file, not a single image directory. The new logic had landed, in full, on the main branch — and had never once been executed.</p>

<p>Alex ran four more rounds of testing after that, each one meaner than the last, each one circling closer to the same sentence:</p>

<p><strong>A safety net that’s “available” but has never actually fired is indistinguishable from one that doesn’t exist. You have to force it to run yourself, and then stand there and watch exactly what it catches — and what slips past it.</strong></p>

<p>This is the story of those five rounds — including the two real gaps nobody on the team knew existed until this exercise dragged them into the light. What you’ll walk away with:</p>

<ul>
  <li><strong>“CI is green” and “CI actually ran” are two different claims</strong> — path-based gating can merge code that never once executes.</li>
  <li><strong>Testing your own safety net against drift that’s already sitting in your repo beats inventing a scenario</strong> — but some blind spots haven’t happened yet, and the only way to find them is to go build one with your own hands.</li>
  <li><strong>A safety check’s own assumption can be its biggest hole</strong> — “abi3 is always safe” sounds like received wisdom. Dig, and it turns out to be wrong.</li>
</ul>

<p>What follows is the order Alex actually discovered things in, not the tidy “how you should do this” order — because every hole he fell into is the direct reason for the next test.</p>

<hr />

<h2 id="1-who-does-the-green-checkmark-actually-fool">1. Who does the green checkmark actually fool</h2>

<p>PR merged, checks green — the normal reflex is to move on to the next thing.</p>

<p>This time he looked one level deeper, at the actual job list:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Create Application Version   success
Test and build Images        skipped   ← the new logic lives inside here
Deploy to SIT/STG/PRD        skipped
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Test and build Images</code> is a matrix job carrying both new steps: deriving the version from the Dockerfile, and validating the ABI tag on compiled artifacts. It said <code class="language-plaintext highlighter-rouge">skipped</code>. Not <code class="language-plaintext highlighter-rouge">success</code>. Two words that look almost identical and mean entirely different things.</p>

<p>The reason is simple enough to walk right past: the repo’s <code class="language-plaintext highlighter-rouge">List modified images</code> job diffs which <code class="language-plaintext highlighter-rouge">images/*</code> paths changed. This PR touched only the workflow file — zero image paths moved — so the matrix concluded “nothing to build here” and skipped the whole job.</p>

<blockquote>
  <p><strong>The obvious read</strong>: green CI means the change is correct.
<strong>The read that actually matters</strong>: green CI means the part that ran is correct. Ask which jobs the change actually caused to execute before you let yourself trust the light.</p>
</blockquote>

<table>
  <thead>
    <tr>
      <th>What you see</th>
      <th>Looks like</th>
      <th>What it actually is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>All PR checks green</td>
      <td>New logic verified</td>
      <td>The job containing the new logic was path-filtered out — it never ran</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">skipped</code></td>
      <td>Some flavor of “passed”</td>
      <td>Never executed, not even once</td>
    </tr>
    <tr>
      <td>Merged to <code class="language-plaintext highlighter-rouge">develop</code></td>
      <td>Code is live</td>
      <td>Code is on the main branch, but its trigger condition has never once fired</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>Path filtering exists to save CI time, and there’s nothing wrong with that design on its own terms. The mistake is quietly conflating “this job is allowed to be skipped” with “this change doesn’t need verification” — one is a trigger rule, the other is a claim about correctness, and the two have nothing to do with each other.</p>
</blockquote>

<hr />

<h2 id="2-forcing-it-to-actually-run-a-throwaway-touch-commit">2. Forcing it to actually run: a throwaway touch commit</h2>

<p>Once he knew the thing had never run, Alex didn’t reach for a testing framework, and he didn’t try to mock GitHub Actions’ behavior in isolation — that tests what you <em>think</em> it does, not what it actually does. Nobody cares what you think.</p>

<p>His approach was blunter: a new branch, a version-only touch commit on one real image’s <code class="language-plaintext highlighter-rouge">pyproject.toml</code> (<code class="language-plaintext highlighter-rouge">0.1.0</code> → <code class="language-plaintext highlighter-rouge">0.1.1</code>, zero functional change), just enough to convince <code class="language-plaintext highlighter-rouge">List modified images</code> that this image had changed, and force its matrix job onto the track for real.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout <span class="nt">-b</span> verify-ci-python-version-resolution develop
<span class="c"># sftp-ingest-core depends on paramiko/cryptography — one of the few images</span>
<span class="c"># in this repo that actually produces compiled extensions. Picking it</span>
<span class="c"># wasn't arbitrary: pick a pure-Python image instead and the new logic</span>
<span class="c"># has nothing to check.</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/version = "0.1.0"/version = "0.1.1"/'</span> images/sftp-ingest-core/pyproject.toml
git commit <span class="nt">-am</span> <span class="s2">"touch sftp-ingest-core to exercise new CI python-version resolution"</span>
git push
</code></pre></div></div>

<p>Which image to pick is where the actual judgment lives — pick wrong (say, a boto3-only image with no compiled dependencies at all) and the job runs, but the new logic has nothing to validate. Another green light that proved absolutely nothing.</p>

<p>This time, the second it actually ran, CI turned on a dime and went red:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Building sftp-ingest-core against Python 3.11 (derived from Dockerfile base image)
...
ERROR: Package 'platform-shared-lib' requires a different Python: 3.10.20 not in '&lt;3.12,&gt;=3.11'
</code></pre></div></div>

<p>Not a word of this was staged. <code class="language-plaintext highlighter-rouge">sftp-ingest-core</code>’s <code class="language-plaintext highlighter-rouge">Dockerfile</code> really was <code class="language-plaintext highlighter-rouge">FROM python:3.10-slim</code> at the time, and its dependency <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> — an unpinned internal package tracking git HEAD directly — now required <code class="language-plaintext highlighter-rouge">&gt;=3.11,&lt;3.12</code>. This drift had been sitting quietly in the repo the whole time. The first time the new logic actually executed, on its very first breath, it caught a real, organically-occurring problem, with an error message that pointed straight at the fix.</p>

<blockquote>
  <p><strong>One-line takeaway</strong>: the best material for testing “does this catch drift” code was never something you invent. It’s whatever’s already lying in your repo, waiting patiently for someone to step on it.</p>
</blockquote>

<hr />

<h2 id="3-a-second-real-test-pushing-the-boundary-the-other-way">3. A second real test: pushing the boundary the other way</h2>

<p>The first test caught “version too old.” Alex wanted to know about the opposite direction — if someone bumped a Dockerfile’s Python version for a routine security patch, would the new logic catch a problem there, too?</p>

<p>He picked another real image depending on <code class="language-plaintext highlighter-rouge">platform-shared-lib</code>, <code class="language-plaintext highlighter-rouge">report-refresh</code>, and changed exactly one line of its Dockerfile — <code class="language-plaintext highlighter-rouge">pyproject.toml</code> left completely alone:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">- FROM python:3.11-slim
</span><span class="gi">+ FROM python:3.12-slim
</span></code></pre></div></div>

<p>Result:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Building report-refresh against Python 3.12 (derived from Dockerfile base image)
...
ERROR: Package 'platform-shared-lib' requires a different Python: 3.12.13 not in '&lt;3.12,&gt;=3.11'
</code></pre></div></div>

<p>The version resolution itself worked exactly as designed — Dockerfile said 3.12, CI actually built with 3.12. What stopped the upgrade cold was <code class="language-plaintext highlighter-rouge">platform-shared-lib</code>’s own version ceiling.</p>

<p>The real payoff of this test is the comparison it invites: <strong>how would the old, hardcoded CI have handled this exact same upgrade?</strong> It wouldn’t have. Regardless of whether the Dockerfile said 3.10, 3.11, or 3.12, the old CI always built and tested with 3.11, full stop. A real, consequential base-image bump would have shipped green, with nobody having ever verified the dependency graph even survives under 3.12. For the first time, the new logic put an actual gate in front of an action — “bump the base image” — that had never once been checked.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Old CI (hardcoded 3.11)</th>
      <th>New CI (derived from Dockerfile)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Dockerfile bumped to 3.12</td>
      <td>Still builds with 3.11 — the upgrade itself is <strong>never validated</strong></td>
      <td>Actually builds with 3.12, hits <code class="language-plaintext highlighter-rouge">platform-shared-lib</code>’s ceiling immediately</td>
    </tr>
    <tr>
      <td>When you find out</td>
      <td>Possibly a <code class="language-plaintext highlighter-rouge">ModuleNotFoundError</code> in production</td>
      <td>At PR time, with an error that points straight at the dependency</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4-attacking-a-pattern-that-doesnt-exist-yet-the-multi-stage-dockerfile">4. Attacking a pattern that doesn’t exist yet: the multi-stage Dockerfile</h2>

<p>The first two tests leaned on real, already-existing repo state. For the third, Alex went on the offensive — hunting for a pattern <strong>not a single image in the repo currently uses</strong>: the multi-stage Dockerfile. It’s an entirely ordinary Docker idiom. Nobody’s using it today doesn’t mean nobody will tomorrow. And the version-resolution script looked like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">version</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">sed</span> <span class="nt">-nE</span> <span class="s1">'s|^FROM[[:space:]]+python:([0-9]+\.[0-9]+).*|\1|p'</span> Dockerfile | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">head -1</code> — take only the <strong>first</strong> <code class="language-plaintext highlighter-rouge">FROM python:</code> line in the file. Fine, in a single-stage Dockerfile, where there’s only one. In a multi-stage one:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder   # ← first FROM — head -1 grabs this</span>

<span class="k">FROM</span><span class="s"> python:3.10-slim              # ← the last one is what actually ships</span>
<span class="k">RUN </span>adduser <span class="nt">--system</span> <span class="nt">--home</span> /svc-python <span class="nt">--group</span> svc-python
...
</code></pre></div></div>

<p>Alex pushed this to the same test branch and watched CI walk, step by step, straight into the trap: build with 3.11, test with 3.11, package with 3.11 — then the extension-validation step compared “expected ABI: cpython-311” against the actual artifact, which genuinely <strong>was</strong> compiled with 3.11. The two sides matched perfectly.</p>

<p><strong>Green.</strong></p>

<p>Clean, tidy, and false. The image that would actually ship is based on <code class="language-plaintext highlighter-rouge">python:3.10-slim</code>, not 3.11. The “expected value” the check compares against, and the artifact it’s comparing, come from <strong>the exact same wrong derivation</strong> — they can never disagree with each other, because they’re the same bug looking at itself in two mirrors.</p>

<blockquote>
  <p><strong>Symmetry breaking</strong>: this check looks like two independent signals cross-validating each other — the derived version, the actual compiled artifact — but it’s really one bug casting two shadows. Any “self-verifying” check earns the question: are these two signals genuinely independent, or do they share the same upstream assumption underneath?</p>
</blockquote>

<p>This is a risk the repo carries zero exposure to today — every image is single-stage. But “the new logic hasn’t been bitten by this pattern yet” and “the new logic can’t be bitten by this pattern” are two very different sentences. The first one is luck. The second one is an engineering promise.</p>

<hr />

<h2 id="5-the-abi3-landmine-a-green-light-check-that-doesnt-know-its-wrong">5. The abi3 landmine: a green-light check that doesn’t know it’s wrong</h2>

<p>By this point Alex was done pushing test scenarios through GitHub Actions — the multi-stage round had already proven every CI round-trip was burning real time, and a lot of these assumptions could be tested faster and cleaner on his own laptop. He opened Docker locally, and within a few minutes had reproduced something worse.</p>

<p>The new validation logic carries this comment:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Version-tagged .so files must carry the target ABI tag; abi3 (.abi3.so)
and pure-Python files are portable and skipped.
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">abi3</code> is CPython’s stable ABI subset — an extension that stays within that subset compiles once and runs across every 3.x minor version, no per-version wheels required. Buried in that comment is an assumption: <strong>abi3 files are always safe, skip them.</strong></p>

<p>It sounds reasonable enough that nobody would think to question it. Alex decided to test it with his own hands:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
<span class="k">WORKDIR</span><span class="s"> /build</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">--target</span> /build/pkgs cryptography paramiko

<span class="k">FROM</span><span class="s"> python:3.10-slim</span>
<span class="k">COPY</span><span class="s"> --from=builder /build/pkgs /app/pkgs</span>
<span class="k">ENV</span><span class="s"> PYTHONPATH=/app/pkgs</span>
<span class="k">CMD</span><span class="s"> ["python", "-c", "import paramiko"]</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ docker build -t py-drift-test . &amp;&amp; docker run --rm py-drift-test

ImportError: /app/pkgs/cryptography/hazmat/bindings/_rust.abi3.so: undefined symbol: PyType_GetName
</code></pre></div></div>

<p>The file that crashed is precisely the one judged “always safe.” The reason: the C API symbol <code class="language-plaintext highlighter-rouge">PyType_GetName</code> was only added to the stable ABI in Python <strong>3.11</strong>. <code class="language-plaintext highlighter-rouge">cryptography</code>’s Rust bindings reach for it, which means this particular abi3 wheel’s real minimum runnable version is 3.11 — an older interpreter breaks on it no matter what the filename claims. No negotiation.</p>

<p><strong>abi3 was never a promise that something works everywhere. It’s a promise that it works from its build floor, forward.</strong> It’s directional compatibility, not symmetric portability. Collapsing “safe across minor versions” into “skip anything with abi3 in the filename” is itself the hole in the floor.</p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>How the check treats it</th>
      <th>What’s actually true</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_cffi_backend.cpython-311-*.so</code></td>
      <td>Scanned, flagged if the tag doesn’t match</td>
      <td>Handled correctly</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_rust.abi3.so</code> (cryptography)</td>
      <td>Skipped outright, assumed safe</td>
      <td><strong>Wrong</strong> — this is exactly what crashed</td>
    </tr>
  </tbody>
</table>

<h3 id="correcting-an-instinct-that-comes-too-easily">Correcting an instinct that comes too easily</h3>

<p>It’s tempting, right about here, to think: “just bump the Docker base image to 3.12 everywhere and this goes away.”</p>

<p>It doesn’t. The root cause is “the running version is older than the compiled artifact’s minimum floor” (3.10 sitting below the 3.11 floor) — not “the version is old” as some abstract sin. A final stage on 3.11 or 3.12 both run this exact wheel just fine.</p>

<p>And “bump to 3.12” is already a dead end in this repo — section 3’s <code class="language-plaintext highlighter-rouge">report-refresh</code> test already proved it: any image depending on <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> slams into its own <code class="language-plaintext highlighter-rouge">&lt;3.12</code> ceiling the moment you try, with the identical error message waiting there. Upgrading the version here doesn’t fix this problem. It trades it for a different, equally guaranteed one.</p>

<p>There is exactly one real fix: <strong>build-time and runtime Python must always be the same number. No drift, no exceptions.</strong> That’s the same conclusion the original production incident forced — proven again here, through a completely different failure mode. Two different cracks, the same wall behind them.</p>

<hr />

<h2 id="putting-the-five-rounds-together">Putting the five rounds together</h2>

<table>
  <thead>
    <tr>
      <th>Round</th>
      <th>What it tests</th>
      <th>Material used</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Did the new logic run at all</td>
      <td>The repo’s real path-filtering rule</td>
      <td>Never ran, not once</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Force real execution — does it catch real drift</td>
      <td>Repo’s actual 3.10 vs <code class="language-plaintext highlighter-rouge">platform-shared-lib</code>’s requirement</td>
      <td>Caught it, clean actionable error</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Does it also block an upgrade going the other way</td>
      <td>A real one-line Dockerfile change (3.11→3.12)</td>
      <td>Caught it — the old CI never could have</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Structural blind spot in the resolution logic itself</td>
      <td>A hand-built multi-stage Dockerfile (doesn’t exist in the repo)</td>
      <td>False green — the check and the build share the same wrong assumption</td>
    </tr>
    <tr>
      <td>5</td>
      <td>Whether the check’s own assumption holds</td>
      <td>Local Docker repro, a real <code class="language-plaintext highlighter-rouge">ImportError</code></td>
      <td>False green — “abi3 is inherently safe” is itself wrong</td>
    </tr>
  </tbody>
</table>

<p>The first two rounds ask whether this code survives contact with the real world. The last two ask whether the assumption this code believes in survives contact with itself. You need both before you get to say a safety net has actually been checked — not “it merged, so it counts,” but “I forced it to run, forced it to face real drift, and forced it to face a scenario it didn’t even know it could lose.”</p>

<h2 id="do-this-today">Do this today</h2>

<ol>
  <li>Before merging any PR that only touches CI configuration and no application code, check which jobs actually executed — <code class="language-plaintext highlighter-rouge">skipped</code> and <code class="language-plaintext highlighter-rouge">success</code> look almost identical and mean completely different things.</li>
  <li>If your CI gates a build matrix on path filters, pair any “workflow-logic-only” PR with a dedicated, throwaway touch commit that forces at least one real branch to exercise the new logic before you merge.</li>
  <li>When reviewing any check that skips a category of file or a naming pattern, make this the mandatory question: is the condition that triggers the skip actually one-directional, or only true within a narrower range than it sounds? “abi3” sounds like “works everywhere.” It actually means “works from some version, forward.”</li>
</ol>

<hr />

<p><em>A green checkmark was never the finish line. It’s a question, hanging there, waiting for you to answer: who put that mark there, what did it actually verify, and under what conditions was it even allowed to say yes?</em></p>]]></content><author><name></name></author><category term="tech" /><category term="python" /><category term="docker" /><category term="ci-cd" /><category term="github-actions" /><category term="testing" /><summary type="html"><![CDATA[“The first principle is that you must not fool yourself — and you are the easiest person to fool.” — Richard Feynman]]></summary></entry><entry><title type="html">那个从没跑过一次的 CI 自动化构造脚本写的检查任务,给了我们一个假的绿色对勾</title><link href="http://todzhang.com/blogs/tech/zh/ci-fix-verification-blind-spots" rel="alternate" type="text/html" title="那个从没跑过一次的 CI 自动化构造脚本写的检查任务,给了我们一个假的绿色对勾" /><published>2026-08-04T00:00:00+00:00</published><updated>2026-08-04T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/zh/ci-fix-verification-blind-spots-zh</id><content type="html" xml:base="http://todzhang.com/blogs/tech/zh/ci-fix-verification-blind-spots"><![CDATA[<blockquote>
  <p>鸟儿在天空飞过时，是不会在乎地面上的栅栏的</p>
</blockquote>

<hr />

<p>批处理任务死在了半夜。</p>

<p>值班的人被叫醒时,日志里只有干巴巴的一行:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ModuleNotFoundError: No module named '_cffi_backend'
</code></pre></div></div>

<p>这个任务已经稳稳当当跑了很久,今晚什么都没改。第一反应是查依赖——<code class="language-plaintext highlighter-rouge">cffi</code> 好好地躺在 lock 文件里,镜像的 <code class="language-plaintext highlighter-rouge">site-packages</code> 目录里它也在。包,确实是装了的。</p>

<p>问题不在”装没装”,在于<strong>装它的那个 Python,和跑它的那个 Python,根本不是同一个</strong>。CI 在 3.11 上把这个包编译打包,产物被塞进了一个基础镜像是 3.10 的容器。3.10 的解释器扫描扩展模块时,只认识几种特定后缀的文件名——<code class="language-plaintext highlighter-rouge">-311-</code> 那个后缀不在白名单里,于是它不是”加载失败”,是<strong>压根看不见这个文件</strong>。库自己都分不清是哪种情况,只能报一句含糊的”缺失,或者是给另一个版本编译的”。</p>

<p>Alex 花了半天才把这条因果链理清楚,然后又花了两天,把修复推了上去:让 CI 不再写死 Python 版本,而是从每个镜像自己的 <code class="language-plaintext highlighter-rouge">Dockerfile</code> 里去读——这样构建和运行,永远是同一个数字,再也不用靠人记住”这两处要保持一致”这种脆弱的约定。</p>

<p>PR 合并,CI 全绿。他往后靠在椅子上,长舒一口气,正准备去关掉这张票,顺手点开下一个任务的时候——脑子里冒出一个念头,轻飘飘的,但赶不走:</p>

<blockquote>
  <p>“这个绿色对勾,到底证明了什么?”</p>
</blockquote>

<p>他后来才知道,答案几乎是:什么都没有。</p>

<h2 id="30-秒版本">30 秒版本</h2>

<p>那个刚合并的 PR,只改了一个文件——<code class="language-plaintext highlighter-rouge">.github/workflows/build-and-deploy.yml</code>。他去翻了一下具体是哪些 job 真正跑过,<code class="language-plaintext highlighter-rouge">Test and build Images</code> 那一行,安静地写着 <code class="language-plaintext highlighter-rouge">skipped</code>。</p>

<p>仓库的 CI 按路径过滤触发构建矩阵:只有 <code class="language-plaintext highlighter-rouge">images/*</code> 目录下真的有改动,对应的镜像才会被拉出来构建。这次 PR 只碰了 workflow 文件本身,一个镜像目录都没动。新逻辑,已经堂堂正正地进了主干,却一次都没有被执行过。</p>

<p>Alex 接下来又跑了四轮测试,一轮比一轮凶,一步比一步逼近同一句话:</p>

<p><strong>一个”可用”但从没被执行过的安全网,和一个根本不存在的安全网,没有任何区别。你得亲手把它逼到跑起来,再盯着它,看它到底接住了什么、又漏掉了什么。</strong></p>

<p>这篇文章讲的就是这五轮测试——包括最后揪出的两个真实存在、当时全团队没人知道的盲区。读完你会带走:</p>

<ul>
  <li><strong>“CI 绿了”和”CI 跑过了”是两件事</strong>——路径过滤能让一段代码永远合并进主干、又永远不被执行。</li>
  <li><strong>拿仓库里本来就有的漂移去测试你的检查,比自己编一个场景更有说服力</strong>——但有些盲区,现实里还没出现过,你只能亲手造一个去撞。</li>
  <li><strong>一条安全检查的假设本身,可能就是它最大的破口</strong>——”abi3 总是安全的”,听起来像常识,查到最后发现是错的。</li>
</ul>

<p>下面按 Alex 实际踩坑的顺序讲,不是按”教科书应该怎么做”的顺序——因为他每一步踩到的东西,都是下一步测试设计的直接原因。</p>

<hr />

<h2 id="1--绿色对勾骗过了谁">1. 🎯 绿色对勾骗过了谁</h2>

<p>PR 合并,check 全绿,正常人的反应是接着干别的去了。</p>

<p>Alex 这次多看了一眼具体的 job 列表:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Create Application Version   success
Test and build Images        skipped   ← 新逻辑就藏在这里面
Deploy to SIT/STG/PRD        skipped
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Test and build Images</code> 是一个 matrix job,新加的两个步骤都在里面:从 Dockerfile 推导版本、校验编译产物的 ABI 标签。它显示的是 <code class="language-plaintext highlighter-rouge">skipped</code>,不是 <code class="language-plaintext highlighter-rouge">success</code>——这两个词长得像,意思天差地别。</p>

<p>原因简单到容易被直接跳过去:仓库的 <code class="language-plaintext highlighter-rouge">List modified images</code> job 靠目录 diff 判断”这次改动碰了哪些镜像”。PR 只改了 workflow 文件,<code class="language-plaintext highlighter-rouge">images/*</code> 路径没有任何变化,矩阵直接判定”没有镜像需要构建”,整个 job 跳过。</p>

<blockquote>
  <p><strong>普通人的看法</strong>:CI 绿了,说明改动是对的。
<strong>资深工程师的洞察</strong>:CI 绿了,只能说明”跑过的那部分”是对的。先问一句”这次改动到底让哪些 job 真正执行了”,再决定要不要信这个绿灯。</p>
</blockquote>

<table>
  <thead>
    <tr>
      <th>现象</th>
      <th>看起来像</th>
      <th>实际是</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PR checks 全绿</td>
      <td>新逻辑验证通过</td>
      <td>新逻辑所在的 job 被路径过滤跳过,根本没跑</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">skipped</code></td>
      <td>一种”通过”的变体</td>
      <td>一次都没执行过</td>
    </tr>
    <tr>
      <td>合并到 <code class="language-plaintext highlighter-rouge">develop</code></td>
      <td>代码生效</td>
      <td>代码进了主干,但触发条件从没被满足过</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>路径过滤是为了省 CI 时间存在的,这个设计本身没错。错的是把”这个 job 允许被跳过”和”这次改动不需要被验证”划了等号——一个改的是触发规则,一个改的是新逻辑正不正确,这是两件完全不相关的事。</p>
</blockquote>

<hr />

<h2 id="2-️-逼它真正跑起来一个用完就扔的-touch-commit">2. ⚙️ 逼它真正跑起来:一个用完就扔的 touch commit</h2>

<p>发现”从没跑过”之后,Alex 没有去改测试框架,也没有去写单元测试模拟 GitHub Actions 的行为——那测的是”我以为它会怎么跑”,不是”它真的怎么跑”。谁在乎你以为。</p>

<p>他的做法更直接、也更笨:开一个新分支,对一个真实镜像的 <code class="language-plaintext highlighter-rouge">pyproject.toml</code> 做一次纯版本号的 touch commit(<code class="language-plaintext highlighter-rouge">0.1.0</code> → <code class="language-plaintext highlighter-rouge">0.1.1</code>,没有任何功能改动),让 <code class="language-plaintext highlighter-rouge">List modified images</code> 相信这个镜像”变了”,从而把矩阵里对应的那一个 job,真正逼上跑道。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout <span class="nt">-b</span> verify-ci-python-version-resolution develop
<span class="c"># sftp-ingest-core 依赖 paramiko/cryptography,是仓库里少数真的会产出</span>
<span class="c"># 编译扩展的镜像之一——选它不是随便选的,选一个只有纯 Python 依赖的</span>
<span class="c"># 镜像,新逻辑压根没有东西可测</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/version = "0.1.0"/version = "0.1.1"/'</span> images/sftp-ingest-core/pyproject.toml
git commit <span class="nt">-am</span> <span class="s2">"touch sftp-ingest-core to exercise new CI python-version resolution"</span>
git push
</code></pre></div></div>

<p>选哪个镜像去触发,才是这一步真正的技术含量所在——选错了,job 是跑了,但新逻辑没有任何东西可以校验(比如一个纯 boto3 依赖的镜像,根本不会产出任何编译扩展),又是一次”绿灯,但什么都没验证”。</p>

<p>这次它真的跑起来了。CI 立刻翻脸,变红:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Building sftp-ingest-core against Python 3.11 (derived from Dockerfile base image)
...
ERROR: Package 'platform-shared-lib' requires a different Python: 3.10.20 not in '&lt;3.12,&gt;=3.11'
</code></pre></div></div>

<p>没有一个字是演出来的。<code class="language-plaintext highlighter-rouge">sftp-ingest-core</code> 的 <code class="language-plaintext highlighter-rouge">Dockerfile</code> 当时真的是 <code class="language-plaintext highlighter-rouge">FROM python:3.10-slim</code>,而它依赖的 <code class="language-plaintext highlighter-rouge">platform-shared-lib</code>——一个没锁死版本、直接跟着 git HEAD 走的内部包——最新的要求是 <code class="language-plaintext highlighter-rouge">&gt;=3.11,&lt;3.12</code>。这个漂移,是仓库里本来就静静躺着的。新逻辑第一次真正执行,第一次就抓到了一个真实、自然生长出来的问题,报错信息直接可操作,连排查的力气都省了。</p>

<blockquote>
  <p><strong>一句话哲学</strong>:测试一段”检查漂移”的代码,最好的素材从来不是你脑子里编出来的场景,是仓库里本来就有、只是还没被人踩到的那个坑。</p>
</blockquote>

<hr />

<h2 id="3--第二次真实测试朝相反的方向撞一次">3. 🔍 第二次真实测试:朝相反的方向撞一次</h2>

<p>第一次测试撞见的是”版本太旧”。Alex 想知道另一个方向——如果哪天有人为了打个安全补丁,顺手把某个镜像的 Dockerfile 从 3.11 升到 3.12,新逻辑拦不拦得住?</p>

<p>他挑了另一个真实存在、同样依赖 <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> 的镜像 <code class="language-plaintext highlighter-rouge">report-refresh</code>,只改了 Dockerfile 的一行,<code class="language-plaintext highlighter-rouge">pyproject.toml</code> 一个字都没碰:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gd">- FROM python:3.11-slim
</span><span class="gi">+ FROM python:3.12-slim
</span></code></pre></div></div>

<p>结果:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Building report-refresh against Python 3.12 (derived from Dockerfile base image)
...
ERROR: Package 'platform-shared-lib' requires a different Python: 3.12.13 not in '&lt;3.12,&gt;=3.11'
</code></pre></div></div>

<p>版本推导本身完全正确——Dockerfile 说 3.12,CI 就真的拿 3.12 去构建。挡住这次升级的,是 <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> 自己的版本上限。</p>

<p>这次测试真正的价值在于对比:<strong>旧的 CI(写死 3.11)会怎么处理同样这次升级?</strong> 答案是,它压根不会处理——不管 Dockerfile 写的是 3.10、3.11 还是 3.12,旧 CI 永远拿 3.11 去构建、去测试。一次真实的、有风险的基础镜像升级,会在没有任何人验证过依赖是否兼容 3.12 的情况下,大摇大摆地绿灯上线。新逻辑第一次,让”升级基础镜像”这个动作,真正经过了它本该经过的那一道关。</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>旧 CI(写死 3.11)</th>
      <th>新 CI(从 Dockerfile 推导)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Dockerfile 升到 3.12</td>
      <td>构建仍然用 3.11,升级本身<strong>从未被验证</strong></td>
      <td>构建真的用 3.12,立刻撞见 <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> 的版本上限</td>
    </tr>
    <tr>
      <td>反馈时机</td>
      <td>可能是生产环境的一次 <code class="language-plaintext highlighter-rouge">ModuleNotFoundError</code></td>
      <td>PR 阶段,一条能直接定位到哪个依赖的报错</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4--造一个还不存在的攻击多阶段-dockerfile">4. 🧪 造一个还不存在的攻击:多阶段 Dockerfile</h2>

<p>前两轮测试,用的都是仓库里真实存在的状态。第三轮,Alex 开始主动进攻——去找一个仓库里<strong>目前一个镜像都没有</strong>的模式:多阶段 Dockerfile。这是 Docker 里再正常不过的写法,今天没人用,不代表明天不会有人用。而新逻辑的推导脚本,长这样:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">version</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">sed</span> <span class="nt">-nE</span> <span class="s1">'s|^FROM[[:space:]]+python:([0-9]+\.[0-9]+).*|\1|p'</span> Dockerfile | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">head -1</code>——只取文件里<strong>第一条</strong> <code class="language-plaintext highlighter-rouge">FROM python:</code> 行。单阶段 Dockerfile 里这没问题,全文只有一条。多阶段呢?</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder   # ← 第一条 FROM,head -1 会抓这个</span>

<span class="k">FROM</span><span class="s"> python:3.10-slim              # ← 最后一条才是真正要发布的镜像</span>
<span class="k">RUN </span>adduser <span class="nt">--system</span> <span class="nt">--home</span> /svc-python <span class="nt">--group</span> svc-python
...
</code></pre></div></div>

<p>Alex 把这个改动推上同一条测试分支,然后看着 CI 一步步走进那个陷阱:用 3.11 构建、测试、打包,再拿”期望的 ABI 是 cpython-311”去比对产物——而产物<strong>确实</strong>是用 3.11 编译的,两边严丝合缝。</p>

<p><strong>绿灯。</strong></p>

<p>漂亮,干净,而且是假的。真正会被发布出去的镜像,基础层是 <code class="language-plaintext highlighter-rouge">python:3.10-slim</code>,不是 3.11。校验逻辑用来”验证”的那个期望值,和产生构建产物的版本,来自<strong>同一处错误的推导</strong>——它们永远不可能互相矛盾,因为它们本来就是同一个错,只是照了两次镜子。</p>

<blockquote>
  <p><strong>对称性破缺</strong>:这个检查看起来像”两个独立信号互相印证”(推导出的版本 vs 实际编译出的产物),实际上是同一个 bug 投影出的两个影子。任何”自证”式的校验都要先问一句:这两个信号真的相互独立吗,还是共享同一个上游假设?</p>
</blockquote>

<p>这是仓库今天完全没有的风险——所有镜像清一色单阶段。但”新逻辑今天没被这个模式坑过”和”新逻辑不会被这个模式坑”是两句完全不同的话。前一句是运气,后一句才是工程上的承诺。</p>

<hr />

<h2 id="5--abi3-地雷一条自己都不知道自己错在哪的绿灯逻辑">5. 💥 abi3 地雷:一条自己都不知道自己错在哪的绿灯逻辑</h2>

<p>到这一步,Alex 不太想再往 GitHub Actions 上推测试了——多阶段这一场,已经证明每次 CI 往返都在烧时间,很多假设本可以在本地更快、更干净地撞出来。他打开自己笔记本上的 Docker,几分钟之内,复现出了一个更深的坑。</p>

<p>新加的校验逻辑里,躺着这样一句注释:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Version-tagged .so files must carry the target ABI tag; abi3 (.abi3.so)
and pure-Python files are portable and skipped.
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">abi3</code> 是 CPython 提供的稳定 ABI 子集——一个扩展只要不碰这个子集之外的东西,编译一次就能跨所有 3.x 小版本通用,不用为每个 Python 版本单独发 wheel。这句注释背后藏着一个假设:<strong>abi3 文件永远安全,不用检查。</strong></p>

<p>这句话听起来完全合理,合理到没人会去质疑它。Alex 决定亲手撞一次:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
<span class="k">WORKDIR</span><span class="s"> /build</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">--target</span> /build/pkgs cryptography paramiko

<span class="k">FROM</span><span class="s"> python:3.10-slim</span>
<span class="k">COPY</span><span class="s"> --from=builder /build/pkgs /app/pkgs</span>
<span class="k">ENV</span><span class="s"> PYTHONPATH=/app/pkgs</span>
<span class="k">CMD</span><span class="s"> ["python", "-c", "import paramiko"]</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ docker build -t py-drift-test . &amp;&amp; docker run --rm py-drift-test

ImportError: /app/pkgs/cryptography/hazmat/bindings/_rust.abi3.so: undefined symbol: PyType_GetName
</code></pre></div></div>

<p>崩溃的,恰恰就是那个被判定”永远安全”的 <code class="language-plaintext highlighter-rouge">abi3.so</code> 文件。原因是:<code class="language-plaintext highlighter-rouge">PyType_GetName</code> 这个 C API 符号,是 Python <strong>3.11</strong> 才被加进稳定 ABI 里的。<code class="language-plaintext highlighter-rouge">cryptography</code> 的 Rust 绑定编译时用到了它,于是这个 abi3 wheel 实际的最低可运行版本,就是 3.11——比它老的解释器,不管文件名标不标 <code class="language-plaintext highlighter-rouge">abi3</code>,一样会崩,毫无商量余地。</p>

<p><strong>abi3 保证的从来不是”跨所有版本通用”,是”从编译时那个最低版本开始,向后、向着更新的版本通用”。</strong> 它是单向的兼容性,不是对称的可移植性。把”跨小版本安全”简化成”文件名里带 abi3 就跳过检查”,这个简化本身,就是漏洞。</p>

<table>
  <thead>
    <tr>
      <th>文件</th>
      <th>检查怎么处理它</th>
      <th>实际情况</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_cffi_backend.cpython-311-*.so</code></td>
      <td>会被扫到,标签对不上就报错</td>
      <td>正确处理</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_rust.abi3.so</code>(cryptography)</td>
      <td>直接跳过,判定”天生安全”</td>
      <td><strong>错的</strong>——这次崩的就是它</td>
    </tr>
  </tbody>
</table>

<h3 id="一个太容易脱口而出的直觉需要被纠正">一个太容易脱口而出的直觉,需要被纠正</h3>

<p>看到这里,很难不冒出一个念头:”那把 Docker 基础镜像统一升到 3.12,不就一了百了了?”</p>

<p>不是。这次崩溃的根因是”运行的版本比编译产物需要的最低版本更老”(3.10 &lt; 3.11 的 floor),不是”版本太旧”这个抽象概念本身——只要最终运行的版本 ≥ 3.11,不管是 3.11 还是 3.12,这个具体的 <code class="language-plaintext highlighter-rouge">abi3</code> 崩溃都不会再发生。</p>

<p>而”升到 3.12”这条路,在这个仓库里,已经被亲手证明是死路——第 3 节里 <code class="language-plaintext highlighter-rouge">report-refresh</code> 的那次测试已经说得很清楚:任何依赖 <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> 的镜像,一升到 3.12 就会立刻撞上它自己 <code class="language-plaintext highlighter-rouge">&lt;3.12</code> 的版本上限,报错和这次一字不差。升级版本,不是在修这个问题,是拿一个已知会炸的方案去换另一个。</p>

<p>真正的修法只有一条:<strong>构建阶段和运行阶段的 Python 版本,永远保持同一个数字,不许有漂移。</strong> 这条结论,在这篇文章里被一个和最初那次生产事故完全不同的失败模式,重新证明了一遍——两条完全不同的裂缝,通向的是同一堵墙。</p>

<hr />

<h2 id="收尾五轮测试连起来看">收尾:五轮测试连起来看</h2>

<table>
  <thead>
    <tr>
      <th>轮次</th>
      <th>测什么</th>
      <th>素材</th>
      <th>结果</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>新逻辑到底跑没跑</td>
      <td>仓库真实的路径过滤规则</td>
      <td>一次都没跑过</td>
    </tr>
    <tr>
      <td>2</td>
      <td>逼它真正执行,看它能不能接住真实漂移</td>
      <td>仓库里本来就有的 3.10 vs <code class="language-plaintext highlighter-rouge">platform-shared-lib</code> 要求</td>
      <td>接住了,报错清晰可操作</td>
    </tr>
    <tr>
      <td>3</td>
      <td>反过来测版本升级会不会被拦住</td>
      <td>真实改一行 Dockerfile(3.11→3.12)</td>
      <td>接住了,而旧 CI 永远接不住</td>
    </tr>
    <tr>
      <td>4</td>
      <td>结构性盲区:版本解析逻辑本身的假设</td>
      <td>手工构造的多阶段 Dockerfile(仓库里还不存在)</td>
      <td>假绿灯——校验逻辑和构建逻辑共享同一个错误假设</td>
    </tr>
    <tr>
      <td>5</td>
      <td>检查逻辑自己的假设是否成立</td>
      <td>本地 Docker 复现,真实 <code class="language-plaintext highlighter-rouge">ImportError</code></td>
      <td>假绿灯——”abi3 天生安全”这个假设本身是错的</td>
    </tr>
  </tbody>
</table>

<p>前两轮测的是”这段代码能不能扛住真实世界”。后两轮测的是”这段代码信以为真的那个假设,到底站不站得住”。两种测试都做完,才轮得到你说这个安全网真正被检验过——不是”合并了就算数”,是”逼它跑起来、逼它接一次真实的漂移、逼它去撞一个它自己都不知道自己会输的场景”,全都撞完之后,才算数。</p>

<h2 id="立刻可以做的事">立刻可以做的事</h2>

<ol>
  <li>下次合并一个只改 CI 配置文件本身、不改任何业务代码的 PR 之前,先看一眼具体是哪些 job 真正执行了——<code class="language-plaintext highlighter-rouge">skipped</code> 和 <code class="language-plaintext highlighter-rouge">success</code> 长得很像,含义天差地别。</li>
  <li>如果你的 CI 用路径过滤触发矩阵构建,任何一次”只改 workflow 逻辑”的 PR,配一次专门的、用完即弃的 touch commit,强制至少一个真实分支跑一遍新逻辑,再合并。</li>
  <li>审查任何”跳过某类文件/某种标签”的安全检查逻辑时,把这句话当成必答题:这个跳过条件成立的前提,是不是”单向的”,还是”只在特定范围内成立”?——<code class="language-plaintext highlighter-rouge">abi3</code> 这个词听起来像”通用”,实际上是”以某个版本为起点,向后通用”。</li>
</ol>

<hr />

<p><em>绿色对勾从来不是终点。它只是一个问题,悬在那里,等你回答:这个勾,到底是谁打的、验证了什么、又是在什么条件下,才被允许打上去的。</em></p>]]></content><author><name></name></author><category term="tech" /><category term="python" /><category term="docker" /><category term="ci-cd" /><category term="github-actions" /><category term="testing" /><summary type="html"><![CDATA[鸟儿在天空飞过时，是不会在乎地面上的栅栏的]]></summary></entry><entry><title type="html">Every sleep in Your Deploy Script Is a Lie</title><link href="http://todzhang.com/blogs/tech/en/every-sleep-is-a-lie" rel="alternate" type="text/html" title="Every sleep in Your Deploy Script Is a Lie" /><published>2026-04-30T00:00:00+00:00</published><updated>2026-04-30T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/en/every-sleep-is-a-lie-en</id><content type="html" xml:base="http://todzhang.com/blogs/tech/en/every-sleep-is-a-lie"><![CDATA[<blockquote>
  <p>“Hope is not a strategy.” — traditional SRE maxim</p>
</blockquote>

<h1 id="every-sleep-in-your-deploy-script-is-a-lie">Every <code class="language-plaintext highlighter-rouge">sleep</code> in Your Deploy Script Is a Lie</h1>

<blockquote>
  <p><em>From <code class="language-plaintext highlighter-rouge">kubectl wait</code> to Windows path traps — three layers of bash discipline that separate Senior from Principal.</em></p>
</blockquote>

<hr />

<h2 id="a-90-second-story-before-we-get-clinical">A 90-second story before we get clinical</h2>

<p>Alex joined a data platform team last Tuesday. By Wednesday night he was on a video call with me at 11pm — exhausted, slightly furious, very confused. He had been “fixing” the team’s <code class="language-plaintext highlighter-rouge">./scripts/minikube-init.sh</code> for six hours straight on his Windows laptop. Five different fixes, three commits reverted, and the cluster still refused to come up cleanly.</p>

<p>He shared his screen. I read the script for thirty seconds and told him:</p>

<blockquote>
  <p><em>“There are three patterns in this 200-line script that show up in nearly every production deploy script I have ever reviewed. None of them are minikube-specific. None are even Kubernetes-specific. We are going to fix them in the order they will bite you in your career, not in the order you discovered them tonight.”</em></p>
</blockquote>

<p>What follows is what we walked through together. Three lessons, each designed to change how you read scripts, errors, and stateful CLIs forever:</p>

<ul>
  <li>✅ <strong><code class="language-plaintext highlighter-rouge">sleep</code> is a comment that lies. <code class="language-plaintext highlighter-rouge">kubectl wait</code> is the comment that runs.</strong></li>
  <li>✅ <strong><code class="language-plaintext highlighter-rouge">set -e</code> is bash’s gentle lie. You need three more flags before “strict” actually means strict.</strong></li>
  <li>✅ <strong>Fixing the code does not fix the system.</strong> Persisted state outlives bug fixes.</li>
</ul>

<p>I lead with the one you can apply at your next standup, not the one Alex hit first. Pedagogical order ≠ chronological order.</p>

<hr />

<h2 id="1-sleep-is-the-most-expensive-comment-in-your-bash-script">1. <code class="language-plaintext highlighter-rouge">sleep</code> is the most expensive comment in your bash script</h2>

<p>Two-thirds into Alex’s script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># wait until namespace created</span>
<span class="nb">sleep </span>3

<span class="c"># ... apply more configs ...</span>

<span class="c"># wait for pods to start</span>
<span class="k">while </span><span class="nb">true</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">n</span><span class="o">=</span><span class="si">$(</span>kubectl get pod | <span class="nb">grep</span> <span class="nt">-v</span> test- | <span class="nb">grep </span>Running | <span class="nb">wc</span> <span class="nt">-l</span><span class="si">)</span>
    <span class="o">[</span> <span class="s2">"</span><span class="nv">$n</span><span class="s2">"</span> <span class="nt">-ge</span> 5 <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">break
    sleep </span>2
<span class="k">done</span>
</code></pre></div></div>

<p>I asked Alex what this did. <em>“Wait for the pods to come up.”</em></p>

<p>I told him this nine-line snippet contains five distinct anti-patterns. He didn’t believe me. So we wrote them down.</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Anti-pattern</th>
      <th>Why it bites</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">sleep 3</code> after <code class="language-plaintext highlighter-rouge">kubectl apply</code></td>
      <td><strong>Magic number.</strong> Three seconds is enough on a fast laptop, never enough on a stressed CI runner, pure waste in between. The comment “wait for namespace” lies — it really means “I guessed”.</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get \| grep \| grep \| wc -l</code></td>
      <td><strong>Parsing human output instead of querying the API.</strong> Any column-width change, status-string rename, or kubectl version bump silently breaks this.</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">&gt;= 5</code></td>
      <td><strong>Hardcoded business truth.</strong> Today the cluster has five components. When someone adds a sixth deployment, this check is silently lying about readiness. There is no invariant linking the integer <code class="language-plaintext highlighter-rouge">5</code> to actual desired state.</td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">while true</code> with no timeout</td>
      <td><strong>A CI runner killer.</strong> When pods never come up, this loop spins until the runner’s wall-clock limit slaughters the whole job — with zero diagnostic output explaining why.</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code class="language-plaintext highlighter-rouge">set -e</code> cannot save a pipeline</td>
      <td>(Section 2. Stay tuned.)</td>
    </tr>
  </tbody>
</table>

<p>The deeper crime: this snippet is <strong>user-space code reimplementing what Kubernetes already does for you</strong>. The control plane <em>is</em> a reconcile loop. You are racing it instead of asking it.</p>

<h3 id="kubernetes-already-gave-you-the-right-primitive">Kubernetes already gave you the right primitive</h3>

<p><code class="language-plaintext highlighter-rouge">kubectl wait</code> is declarative, API-driven, and timeout-bounded:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># By condition name</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Ready pod/foo <span class="nt">--timeout</span><span class="o">=</span>60s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Available deployment <span class="nt">--all</span> <span class="nt">-n</span> ns <span class="nt">--timeout</span><span class="o">=</span>10m
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete job/foo <span class="nt">-n</span> ns <span class="nt">--timeout</span><span class="o">=</span>10m

<span class="c"># By jsonpath (1.23+) — covers any field on any resource</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.phase}'</span><span class="o">=</span>Active namespace/ns <span class="nt">--timeout</span><span class="o">=</span>30s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.loadBalancer.ingress[0].ip}'</span> svc/foo

<span class="c"># By lifecycle event</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span>delete pod/foo <span class="nt">--timeout</span><span class="o">=</span>60s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span>create deployment/foo  <span class="c"># 1.31+</span>

<span class="c"># Multiple conditions OR'd (1.30+) — best for jobs that may either complete or fail</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Failed job/foo
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">kubectl rollout status</code> is a separate primitive — use it for StatefulSets and DaemonSets (which don’t expose an <code class="language-plaintext highlighter-rouge">Available</code> condition), and whenever you want streaming progress output.</p>

<h3 id="the-replacement-we-shipped">The replacement we shipped</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">WAIT_TIMEOUT</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">WAIT_TIMEOUT</span><span class="k">:-</span><span class="nv">10m</span><span class="k">}</span><span class="s2">"</span>

kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.phase}'</span><span class="o">=</span>Active <span class="se">\</span>
    namespace/airflow <span class="nt">--timeout</span><span class="o">=</span>30s

kubectl rollout status statefulset/postgres <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">!</span> kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete job/db-init <span class="se">\</span>
        <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"❌ db-init did not complete. Recent logs:"</span>
    kubectl logs <span class="nt">-n</span> airflow job/db-init <span class="nt">--tail</span><span class="o">=</span>100 <span class="o">||</span> <span class="nb">true
    exit </span>1
<span class="k">fi

</span>kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Available deployment <span class="nt">--all</span> <span class="se">\</span>
    <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span>
</code></pre></div></div>

<p>Five wins, none of them about line count:</p>

<ol>
  <li><strong>No magic numbers.</strong> No business truth encoded as <code class="language-plaintext highlighter-rouge">&gt;= 5</code>.</li>
  <li><strong>No screen-scraping.</strong> API queries, not stdout parsing.</li>
  <li><strong>Bounded.</strong> <code class="language-plaintext highlighter-rouge">--timeout</code> makes failure observable, not eternal.</li>
  <li><strong>Diagnostic on failure.</strong> A failing wait dumps the last 100 lines of the relevant logs. <em>A failing script must produce more output than a passing one</em> — the highest-leverage habit in on-call work.</li>
  <li><strong>Forward-compatible.</strong> <code class="language-plaintext highlighter-rouge">deployment --all</code> adapts to new deployments without edits — Open/Closed Principle applied to ops scripts.</li>
</ol>

<h3 id="this-applies-far-beyond-kubernetes">This applies far beyond Kubernetes</h3>

<p>Every time you see <code class="language-plaintext highlighter-rouge">sleep N</code> immediately following one of these, treat it as a race condition disguised as documentation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sleep N  +  kubectl apply
sleep N  +  helm install
sleep N  +  docker run
sleep N  +  terraform apply
sleep N  +  aws cloudformation deploy
sleep N  +  systemctl start
</code></pre></div></div>

<p>All of these tools have their own <code class="language-plaintext highlighter-rouge">wait</code> / <code class="language-plaintext highlighter-rouge">--wait</code> / <code class="language-plaintext highlighter-rouge">rollout status</code> primitive. <strong>User-space polling is always second-best.</strong></p>

<h3 id="the-aphorism-to-internalise">The aphorism to internalise</h3>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">sleep</code> is a comment that lies. <code class="language-plaintext highlighter-rouge">kubectl wait</code> is the comment that runs.</strong></p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">sleep N</code> is a self-documenting “I guessed how long this needs”. It is a comment that the runtime has been forced to execute. Once you start spotting them, you cannot unsee them — and you will save yourself a 3am page.</p>

<hr />

<h2 id="2-set--e-is-bashs-gentle-lie">2. <code class="language-plaintext highlighter-rouge">set -e</code> is bash’s gentle lie</h2>

<p>Alex’s script started with <code class="language-plaintext highlighter-rouge">set -e</code>. Most scripts do. Most engineers think that means “exit on any error”. It doesn’t.</p>

<p>I asked Alex to open a fresh shell and run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-e</span>
<span class="nb">false</span> | <span class="nb">true
echo</span> <span class="s2">"I am still running"</span>
</code></pre></div></div>

<p>The terminal printed <em>“I am still running”</em>. Alex’s face was the face I have made on this discovery a hundred times.</p>

<h3 id="why-set--e-is-blind-to-pipelines">Why <code class="language-plaintext highlighter-rouge">set -e</code> is blind to pipelines</h3>

<p>In bash, the exit code of <code class="language-plaintext highlighter-rouge">a | b</code> is the exit code of <code class="language-plaintext highlighter-rouge">b</code>. Whatever happened to <code class="language-plaintext highlighter-rouge">a</code> is gone. So in our earlier offender:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl get pod | <span class="nb">grep</span> <span class="nt">-v</span> test- | <span class="nb">grep </span>Running | <span class="nb">wc</span> <span class="nt">-l</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">kubectl get pod</code> blows up because cluster auth expired:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">kubectl</code> writes its error to stderr and exits 1</li>
  <li><code class="language-plaintext highlighter-rouge">grep -v test-</code> reads empty stdin, finds no matches, exits 1 (yes — <code class="language-plaintext highlighter-rouge">grep</code> exits 1 when nothing matches)</li>
  <li><code class="language-plaintext highlighter-rouge">grep Running</code> does the same</li>
  <li><code class="language-plaintext highlighter-rouge">wc -l</code> reads empty stdin, prints <code class="language-plaintext highlighter-rouge">0</code>, exits 0</li>
  <li>The pipeline overall exits 0. <strong><code class="language-plaintext highlighter-rouge">set -e</code> sees nothing.</strong></li>
</ol>

<p>Downstream, <code class="language-plaintext highlighter-rouge">[ $n -ge 5 ]</code> evaluates <code class="language-plaintext highlighter-rouge">0 -ge 5</code>, never breaks the loop, and the script spins forever — exactly the failure mode we just spent a chapter fixing.</p>

<p><strong>Two bugs cancel each other out and the script appears to “work”</strong>. This is one of the most common ways production scripts die slowly.</p>

<h3 id="the-unofficial-bash-strict-mode">The unofficial bash strict mode</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-euo</span> pipefail
</code></pre></div></div>

<p>Each flag patches a specific design decision bash made in the 80s for backwards compatibility. None of them is optional in 2026:</p>

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>Patches</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-e</code></td>
      <td>“Keep going after a failed command.”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-u</code></td>
      <td>“Treat unset variables as empty strings.” (This is the line that turns <code class="language-plaintext highlighter-rouge">rm -rf "$DIR/$SUBDIR"</code> into <code class="language-plaintext highlighter-rouge">rm -rf "$DIR/"</code> when <code class="language-plaintext highlighter-rouge">SUBDIR</code> is mistyped — the canonical homedir-vapouriser.)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-o pipefail</code></td>
      <td>“A pipeline’s exit code is the last stage’s exit code.”</td>
    </tr>
  </tbody>
</table>

<p>Aaron Maxwell coined this trio “the unofficial bash strict mode”. It is the difference between a script that fails fast in dev and one that fails mysteriously at 3am in prod.</p>

<h3 id="the-traps-set--e-still-doesnt-catch-interview-gold">The traps <code class="language-plaintext highlighter-rouge">set -e</code> <em>still</em> doesn’t catch (interview gold)</h3>

<p>Even with strict mode on, bash has surprising blind spots. A principal must know all of them:</p>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Does <code class="language-plaintext highlighter-rouge">set -e</code> fire?</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd \|\| true</code></td>
      <td>❌ No (this is explicit suppression)</td>
      <td>—</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">if cmd; then ...</code></td>
      <td>❌ No (the test is by design)</td>
      <td>—</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd &amp;&amp; other</code>, the non-final command</td>
      <td>❌ No</td>
      <td>—</td>
    </tr>
    <tr>
      <td><strong>Command substitution <code class="language-plaintext highlighter-rouge">$(failing_cmd)</code></strong></td>
      <td>❌ <strong>No!</strong></td>
      <td><code class="language-plaintext highlighter-rouge">shopt -s inherit_errexit</code></td>
    </tr>
    <tr>
      <td>Function called as <code class="language-plaintext highlighter-rouge">f \|\| handle</code></td>
      <td>❌ No (errexit is suppressed inside)</td>
      <td><code class="language-plaintext highlighter-rouge">shopt -s inherit_errexit</code></td>
    </tr>
  </tbody>
</table>

<p>The command-substitution one is the meanest:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-e</span>
<span class="nv">DIR</span><span class="o">=</span><span class="si">$(</span>this_command_does_not_exist<span class="si">)</span>   <span class="c"># silently fails, but set -e shrugs</span>
<span class="nb">echo</span> <span class="s2">"DIR=[</span><span class="nv">$DIR</span><span class="s2">]"</span>                      <span class="c"># still runs; DIR is empty</span>
<span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$DIR</span><span class="s2">/cache"</span>                    <span class="c"># 💀 rm -rf "/cache"</span>
</code></pre></div></div>

<p>Any production-ready bash script’s minimum viable closing line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-euo</span> pipefail
<span class="nb">shopt</span> <span class="nt">-s</span> inherit_errexit
</code></pre></div></div>

<h3 id="what-about-ifsnt">What about <code class="language-plaintext highlighter-rouge">IFS=$'\n\t'</code>?</h3>

<p>The classic Maxwell post adds <code class="language-plaintext highlighter-rouge">IFS=$'\n\t'</code> to neutralise word splitting in <code class="language-plaintext highlighter-rouge">for x in $UNQUOTED</code>. I deliberately leave it out unless I see code that needs it. Every line of boilerplate has to earn its place; if you are quoting your variables and using arrays for lists (you should be), the IFS line is cognitive overhead with zero payoff. Parnas applied to discipline: <strong>complexity is a cost, even when it’s “best-practice” complexity.</strong></p>

<h3 id="one-more-trap-err-for-postmortems">One more: <code class="language-plaintext highlighter-rouge">trap ERR</code> for postmortems</h3>

<p><code class="language-plaintext highlighter-rouge">set -e</code> exits on failure but <strong>doesn’t tell you which line died</strong>. One line transforms silent script death into a useful incident-response artifact:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">trap</span> <span class="s1">'echo "❌ failed at line $LINENO: $BASH_COMMAND" &gt;&amp;2'</span> ERR
</code></pre></div></div>

<p>Mandatory in every CI deploy script. Five seconds to add, saves the on-call engineer thirty minutes of bisecting at 3am.</p>

<h3 id="the-principle-that-survives-bash">The principle that survives bash</h3>

<blockquote>
  <p><strong>Defaults are political. New code must opt into strictness.</strong></p>
</blockquote>

<p>Bash’s defaults are tuned for backwards compatibility with 1989 scripts, not for your 2026 production pipeline. Strict mode is not a fashion choice. It is the absolute minimum civilised baseline. The same principle applies to log levels, CORS policies, k8s NetworkPolicies, and IAM roles: <strong>defaults are the politics of “what was acceptable when this was built”, not “what is correct for what you’re building now”.</strong></p>

<hr />

<h2 id="3-the-origin-story--windows-paths-and-the-trap-of-fixed-it-but-still-broken">3. The origin story — Windows paths and the trap of “fixed it but still broken”</h2>

<p>By now Alex has rewritten the wait loop, hardened the script with strict mode, added <code class="language-plaintext highlighter-rouge">trap ERR</code>. Theoretically airtight. He reruns. Fifteen seconds in:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❌  Exiting due to GUEST_PROVISION:
    config: '\Program Files\Git\host' container path must be absolute
</code></pre></div></div>

<p>Alex squints. <strong><code class="language-plaintext highlighter-rouge">\Program Files\Git\host</code>?</strong> He has never typed that. He greps the entire repo. Nothing.</p>

<p>Welcome to the most cognitively expensive 30 minutes of debugging he will have all year.</p>

<h3 id="your-shell-is-not-transparent">Your shell is not transparent</h3>

<p>The string Alex typed was <code class="language-plaintext highlighter-rouge">/host</code>. The string minikube received was <code class="language-plaintext highlighter-rouge">\Program Files\Git\host</code>. The extra <code class="language-plaintext highlighter-rouge">\Program Files\Git\</code> part is — not a coincidence — the install path of Git for Windows.</p>

<p>That fingerprint is the calling card of <strong>MSYS2 path conversion</strong>. Git Bash is not “bash on Windows”. It is bash <em>running on top of an MSYS2 runtime</em> — a translation layer designed to let POSIX-style command lines invoke native Win32 binaries seamlessly. Helpful 95% of the time. Catastrophic the other 5%.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>your literal string  ──►  bash variable expansion  ──►  ⚠️ MSYS2 path rewrite  ──►  target binary
                                                              │
                                                              ▼
                                            Heuristic (simplified):
                                            "/foo"        →  &lt;MSYS_ROOT&gt;\foo
                                            "/c/Users/x"  →  C:\Users\x
                                            "//foo"       →  /foo            (escape)
                                            "a:/foo"      →  split on `:`, convert each side
                                            "--flag=/foo" →  convert the value
</code></pre></div></div>

<p>MSYS pattern-matches on the <strong>shape</strong> of the string. It cannot distinguish “a path on the host” from “a path inside a container”. Both are <code class="language-plaintext highlighter-rouge">/something</code> to a string-matcher.</p>

<blockquote>
  <p><strong>Semantics live in your head. Syntax lives in your tools.</strong></p>
</blockquote>

<p>Every layer between you and the kernel may rewrite your input. Whenever a value crosses a boundary — shell to binary, host to container, frontend to backend, ORM to SQL — assume rewriting until you have proof otherwise. I call this <strong>information directionality</strong>: data is never neutral as it crosses contexts; each layer applies its own conversion rules silently.</p>

<h3 id="the-principal-grade-fix-not-the-stack-overflow-magic">The principal-grade fix (not the Stack Overflow magic)</h3>

<p>The internet’s favourite “fix” is to write <code class="language-plaintext highlighter-rouge">//host</code> (double slash to suppress conversion). It works. It is also undocumented magic that no future reader will understand. Three months from now your colleague will “clean up that weird double slash” and you will get paged at 11pm.</p>

<p>A principal closes the loop with explicit ownership of every layer:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">uname</span> <span class="nt">-s</span><span class="si">)</span><span class="s2">"</span> <span class="k">in
    </span>MINGW<span class="k">*</span><span class="p">|</span>MSYS<span class="k">*</span><span class="p">|</span>CYGWIN<span class="k">*</span><span class="p">)</span>
        <span class="nv">HOST_MOUNT_SRC</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>cygpath <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$ROOT_DIR</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        <span class="nv">NO_PATHCONV</span><span class="o">=</span><span class="s2">"MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL=*"</span>
        <span class="p">;;</span>
    <span class="k">*</span><span class="p">)</span>
        <span class="nv">HOST_MOUNT_SRC</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ROOT_DIR</span><span class="s2">"</span>
        <span class="nv">NO_PATHCONV</span><span class="o">=</span><span class="s2">""</span>
        <span class="p">;;</span>
<span class="k">esac</span>

<span class="c"># shellcheck disable=SC2086</span>
<span class="nb">env</span> <span class="nv">$NO_PATHCONV</span> minikube start <span class="se">\</span>
    <span class="nt">--mount</span> <span class="nt">--mount-string</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HOST_MOUNT_SRC</span><span class="k">}</span><span class="s2">:/host"</span> <span class="se">\</span>
    <span class="nt">-p</span> platform-minikube
</code></pre></div></div>

<p>Four design choices, each with a justification:</p>

<table>
  <thead>
    <tr>
      <th>Choice</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">case "$(uname -s)"</code></td>
      <td>The script declares which environment it knows about. Linux/macOS skip the branch entirely; the quirk does not pollute non-affected platforms.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cygpath -m</code></td>
      <td>Explicit translation beats implicit rewriting. <code class="language-plaintext highlighter-rouge">-m</code> produces forward-slash mixed paths (<code class="language-plaintext highlighter-rouge">C:/Users/...</code>) which Docker, Java, and almost every CLI accept.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MSYS_NO_PATHCONV=1</code> scoped via <code class="language-plaintext highlighter-rouge">env</code> prefix</td>
      <td>Parnas information hiding (1972) applied to shell. The quirk lives <strong>next to its cause</strong>, not at the top of the file.</td>
    </tr>
    <tr>
      <td>Comment explains <em>why</em>, not <em>what</em></td>
      <td>Code says what; comments say why. Three years from now this case branch is the only thing keeping the next hire sane.</td>
    </tr>
  </tbody>
</table>

<p>Alex applies the fix. Reruns. Holds his breath.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mounting C:/Users/.../proj to /host in Minikube VM
✨  Using the docker driver based on existing profile
🤦  StartHost failed: config: '\Program Files\Git\host' container path must be absolute
</code></pre></div></div>

<p>His shoulders sag. <em>“It’s identical. I changed nothing.”</em></p>

<p>He had, in fact, changed everything. He just hadn’t fixed the system.</p>

<h3 id="fixing-the-code-does-not-fix-the-system">Fixing the code does not fix the system</h3>

<p>Look at the second line: <strong><code class="language-plaintext highlighter-rouge">Using the docker driver based on existing profile</code></strong>. Minikube is telling Alex, in plain English, <em>“I did not use your new flags. I read my old config from disk.”</em></p>

<p>On the very first <code class="language-plaintext highlighter-rouge">minikube start --mount-string=...</code>, minikube serialised every parameter into:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.minikube/profiles/&lt;profile-name&gt;/config.json
</code></pre></div></div>

<p>Every subsequent <code class="language-plaintext highlighter-rouge">minikube start</code> is a <strong>resume</strong>, not a fresh invocation. The CLI flags you pass on resume are largely ignored — <code class="language-plaintext highlighter-rouge">--mount-string</code> certainly is. So when the <em>first</em> run failed half-way through (because of the path conversion bug we just fixed), it nevertheless wrote the broken <code class="language-plaintext highlighter-rouge">--mount-string</code> into config.json. From that point forward, no amount of code-level fixing helps. The pollution had moved off the script and onto the disk.</p>

<p>Minikube’s own message even tells you so: <em>“Running <code class="language-plaintext highlighter-rouge">minikube delete -p &lt;profile&gt;</code> may fix it”</em>. The project is officially admitting the profile state has poisoned itself.</p>

<h4 id="the-2d-model-code-vs-state">The 2D model: code vs. state</h4>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                        STATE on disk (~/.minikube/profiles/&lt;name&gt;/config.json)
                        ─────────────────────────────────────────────────────────
                        │   clean                       polluted
        OLD code (bug)  │   buggy first run             buggy + cached
                        │   creates pollution           
                        │
        NEW code (fix)  │   ✅ works first time         ❌ resume reads
                        │                                old polluted state
                        │                                ◄── Alex was here
                        ─────────────────────────────────────────────────────────
</code></pre></div></div>

<p>Fixing the code only moves you down a row. Moving across — cleaning the persisted state — is a separate, deliberate action that no amount of <code class="language-plaintext highlighter-rouge">git pull</code> will trigger.</p>

<p>The aphorism I carry around for this:</p>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">git pull</code> cannot uncook an egg.</strong></p>
</blockquote>

<h4 id="this-pattern-is-universal">This pattern is universal</h4>

<p>Minikube is not special. <strong>Any CLI whose vocabulary includes the words <code class="language-plaintext highlighter-rouge">profile</code>, <code class="language-plaintext highlighter-rouge">workspace</code>, <code class="language-plaintext highlighter-rouge">context</code>, <code class="language-plaintext highlighter-rouge">project</code>, <code class="language-plaintext highlighter-rouge">environment</code>, or <code class="language-plaintext highlighter-rouge">release</code> has a hidden state machine living on disk.</strong> A non-exhaustive list of tools where I have personally seen this pattern bite teams:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Hidden state</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">docker compose</code></td>
      <td>named volumes, networks, container metadata</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">terraform</code></td>
      <td><code class="language-plaintext highlighter-rouge">terraform.tfstate</code>, lock file, workspaces</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">kubectl</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.kube/config</code> contexts/clusters/users</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">helm</code></td>
      <td><code class="language-plaintext highlighter-rouge">helm.sh/release.v1.&lt;name&gt;</code> Secret in the cluster</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gcloud config configurations</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/gcloud/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aws configure --profile</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.aws/credentials</code>, <code class="language-plaintext highlighter-rouge">~/.aws/config</code></td>
    </tr>
    <tr>
      <td>Conda / venv</td>
      <td><code class="language-plaintext highlighter-rouge">~/.conda/envs/&lt;name&gt;</code></td>
    </tr>
    <tr>
      <td>npm / pip / poetry lock files</td>
      <td><code class="language-plaintext highlighter-rouge">package-lock.json</code>, <code class="language-plaintext highlighter-rouge">poetry.lock</code></td>
    </tr>
    <tr>
      <td>Git submodules</td>
      <td><code class="language-plaintext highlighter-rouge">.git/modules/</code></td>
    </tr>
  </tbody>
</table>

<p>Whenever you adopt a tool from this family, ask one question on day one: <em>“Where does this thing keep its state, and how do I nuke that state?”</em> Add the answer to your team’s README before you write a single line of glue code.</p>

<h4 id="the-principal-grade-hardening">The principal-grade hardening</h4>

<p>Tribal knowledge — “if it’s still broken, run <code class="language-plaintext highlighter-rouge">minikube delete</code>” — is a smell. It means the next person to hit the wall has to either know the magic incantation or block the team waiting for someone who does. Encode it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">CLEAN</span><span class="o">=</span>0
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    case</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="k">in</span>
        <span class="nt">--clean</span> <span class="p">|</span> <span class="nt">--force</span><span class="p">)</span> <span class="nv">CLEAN</span><span class="o">=</span>1 <span class="p">;;</span>
    <span class="k">esac</span>
<span class="k">done

if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$CLEAN</span><span class="s2">"</span> <span class="nt">-eq</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"🧹 --clean: deleting any existing profile to clear stale config..."</span>
    minikube delete <span class="nt">-p</span> platform-minikube <span class="o">||</span> <span class="nb">true
</span><span class="k">fi</span>
</code></pre></div></div>

<p>One line in the README:</p>

<blockquote>
  <p><em>If your error says “Using existing profile” followed by something weird, rerun with <code class="language-plaintext highlighter-rouge">--clean</code>.</em></p>
</blockquote>

<p>That single change — moving recovery from oral tradition into the script — is one of the fastest ways to look senior on a new team.</p>

<hr />

<h2 id="closing-the-loop-three-maps-you-walk-away-with">Closing the loop: three maps you walk away with</h2>

<p>These three lessons — declarative readiness, strict mode, code-vs-state — are not about Kubernetes, bash, or minikube. They are three maps of meta-structure that recur in every tool you will ever use.</p>

<table>
  <thead>
    <tr>
      <th>Map</th>
      <th>What it shows</th>
      <th>Where it applies</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Declarative beats imperative</strong></td>
      <td>When the platform offers a first-class “wait for X” primitive, every line of polling you write is a re-implementation of an existing reconcile loop.</td>
      <td>Kubernetes, Docker, Helm, Terraform, systemd — any tool with a built-in wait/rollout</td>
    </tr>
    <tr>
      <td><strong>Defaults are political</strong></td>
      <td>Bash’s defaults are tuned for backwards compatibility with 1989, not for your 2026 production script. Strict mode is not optional; it is the absolute minimum civilised baseline.</td>
      <td>Any shell, any framework, any “out of the box” config</td>
    </tr>
    <tr>
      <td><strong>Code vs. state</strong></td>
      <td>Persisted state is a separate dimension from source code. Fixing one without addressing the other is the source of most “but I fixed it!” outages.</td>
      <td>Any CLI with profile/context/workspace concepts; any IaC tool with a state file</td>
    </tr>
  </tbody>
</table>

<p>Every kubectl, terraform, docker, gcloud, and helm script you have ever written sits on these three maps. Once you can see them, you start reading other people’s scripts the way a chess grandmaster reads board positions: not as moves, but as patterns.</p>

<hr />

<h2 id="what-to-do-this-week">What to do this week</h2>

<p>If you want this to stick, do three things before Friday:</p>

<ol>
  <li><strong>Audit your most-run deploy script for the three smell families:</strong>
    <ul>
      <li>any <code class="language-plaintext highlighter-rouge">sleep N</code> followed by a comment containing “wait for” → replace with <code class="language-plaintext highlighter-rouge">kubectl wait</code> / <code class="language-plaintext highlighter-rouge">--wait</code> / <code class="language-plaintext highlighter-rouge">rollout status</code></li>
      <li>any pipeline ending in <code class="language-plaintext highlighter-rouge">wc -l</code> or <code class="language-plaintext highlighter-rouge">head -n 1</code> feeding a numeric comparison → replace with API queries</li>
      <li>any <code class="language-plaintext highlighter-rouge">set -e</code> without <code class="language-plaintext highlighter-rouge">-u</code>, <code class="language-plaintext highlighter-rouge">pipefail</code>, and <code class="language-plaintext highlighter-rouge">inherit_errexit</code> → upgrade to full strict mode in one commit</li>
    </ul>
  </li>
  <li>
    <p><strong>Add a <code class="language-plaintext highlighter-rouge">--clean</code> (or equivalent reset) flag</strong> to any init script that drives a CLI with a <code class="language-plaintext highlighter-rouge">profile</code>/<code class="language-plaintext highlighter-rouge">context</code>/<code class="language-plaintext highlighter-rouge">workspace</code> concept. Document when to use it. You just turned tribal knowledge into a code artifact — that is the day-job of a principal.</p>
  </li>
  <li><strong>Add one line to your team README</strong>: <em>“If an error message starts with ‘Using existing X’, rerun with <code class="language-plaintext highlighter-rouge">--clean</code> before debugging anything else.”</em> That single sentence will save your team a quarter-hour per new joiner forever.</li>
</ol>

<hr />

<h2 id="whats-next">What’s next</h2>

<p>The next post in this thread is <strong>“Why your second <code class="language-plaintext highlighter-rouge">terraform apply</code> is not doing what you think”</strong> — same code-vs-state spine, but in the IaC universe, where state drift and provider lock files turn the trap into something much harder to spot.</p>

<p>If this resonated, send it to the colleague who lost yesterday evening to a deploy script’s race condition.</p>

<hr />

<blockquote>
  <p><em>The bug is not in your terminal. It is in the map between you and your tool.</em></p>
</blockquote>]]></content><author><name></name></author><category term="tech" /><category term="bash" /><category term="kubernetes" /><category term="devops" /><category term="shell-scripting" /><summary type="html"><![CDATA[“Hope is not a strategy.” — traditional SRE maxim]]></summary></entry><entry><title type="html">你脚本里的每一个 sleep 都在说谎</title><link href="http://todzhang.com/blogs/tech/zh/every-sleep-is-a-lie" rel="alternate" type="text/html" title="你脚本里的每一个 sleep 都在说谎" /><published>2026-04-30T00:00:00+00:00</published><updated>2026-04-30T00:00:00+00:00</updated><id>http://todzhang.com/blogs/tech/zh/every-sleep-is-a-lie-zh</id><content type="html" xml:base="http://todzhang.com/blogs/tech/zh/every-sleep-is-a-lie"><![CDATA[<blockquote>
  <p>“知而不行，只是未知。” —— 王阳明</p>
</blockquote>

<h1 id="你脚本里的每一个-sleep-都在说谎">你脚本里的每一个 <code class="language-plaintext highlighter-rouge">sleep</code> 都在说谎</h1>

<blockquote>
  <p><em>从 <code class="language-plaintext highlighter-rouge">kubectl wait</code> 到 Windows 路径陷阱 —— 三层修炼，把 Bash 脚本从 Senior 推到 Principal。</em></p>
</blockquote>

<hr />

<h2 id="开篇王阳明的一句未知">开篇：王阳明的一句”未知”</h2>

<p>王阳明说过一句让我反复琢磨的话：</p>

<blockquote>
  <p><strong>“知而不行，只是未知。”</strong></p>
</blockquote>

<p>我入行二十年，写过几百个部署脚本，调过无数次 CI。在最近一次 code review 里，一个看似平平无奇的 200 行 bash 脚本让我重新认识了这句话。</p>

<p>故事的主角叫 Alex，某数据平台团队新来的高级工程师。前一天刚领到 ThinkPad，对着 README 满怀信心地按下回车，结果一晚上掉进了三个层层叠叠的坑。</p>

<p>到他凌晨找我视频时，他已经修了五次代码、各种姿势重启，<strong>问题反复出现</strong>。我让他把脚本贴出来。</p>

<p>我看了三十秒后告诉他：你这个脚本里有三类<strong>普遍存在</strong>于无数生产部署脚本里的 anti-pattern。我们今晚一个个讲透。</p>

<p>读完这一篇，你会带走三条直觉。每一条都能改变你看脚本、看错误、看默认行为的方式：</p>

<ul>
  <li>✅ <strong><code class="language-plaintext highlighter-rouge">sleep</code> 是说谎的注释，<code class="language-plaintext highlighter-rouge">kubectl wait</code> 是会执行的注释</strong></li>
  <li>✅ <strong><code class="language-plaintext highlighter-rouge">set -e</code> 不够。它是 bash 给你的温柔谎言</strong></li>
  <li>✅ <strong>修了代码 ≠ 修了系统。状态机和代码一样需要被维护</strong></li>
</ul>

<p>我按这三条对你职业生涯<strong>痛点频率</strong>的排序来讲，而不是按 Alex 撞上它们的时间顺序。第一条你今天就能用，第二条让你的脚本从此不在凌晨炸，第三条是当所有显性问题都修完之后，那个让你头皮发麻的 <em>“我明明改对了”</em>。</p>

<hr />

<h2 id="chapter-1sleep-是-bash-里最贵的一行注释">Chapter 1：<code class="language-plaintext highlighter-rouge">sleep</code> 是 Bash 里最贵的一行注释</h2>

<p>Alex 脚本中段卡了 3 秒，再继续。再后面有一段：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># wait until namespace created</span>
<span class="nb">sleep </span>3

<span class="c"># ... apply more configs ...</span>

<span class="c"># wait for pods to start</span>
<span class="k">while </span><span class="nb">true</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">n</span><span class="o">=</span><span class="si">$(</span>kubectl get pod | <span class="nb">grep</span> <span class="nt">-v</span> test- | <span class="nb">grep </span>Running | <span class="nb">wc</span> <span class="nt">-l</span><span class="si">)</span>
    <span class="o">[</span> <span class="s2">"</span><span class="nv">$n</span><span class="s2">"</span> <span class="nt">-ge</span> 5 <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">break
    sleep </span>2
<span class="k">done</span>
</code></pre></div></div>

<p>我让他停下来，问他这段在干什么。他说：”等所有 pod 起来。”</p>

<p>我说：”这九行代码同时犯了五个错。”</p>

<h3 id="五个-anti-pattern一个不漏">五个 anti-pattern，一个不漏</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>病灶</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">sleep 3</code></td>
      <td><strong>魔法常数</strong>。3 秒在你机子上够，CI 慢机器上不够；快机器上又是浪费。本质是把”我猜”写进脚本。</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">kubectl get \| grep \| grep \| wc -l</code></td>
      <td><strong>解析人类输出，不查询 API</strong>。任何列宽变化、状态文案变化都会破。Kubernetes 给了你 structured API，你却在抓屏。</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">&gt;= 5</code></td>
      <td><strong>硬编码业务真相</strong>。今天 5 个组件，明天加一个 deployment 还是 5？这数字和真实状态之间没有任何 invariant。新人加一个 deployment，这条 check 就开始撒谎。</td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">while true</code> 没 timeout</td>
      <td><strong>CI 杀手</strong>。pod 永远起不来时，整段 loop 转到 CI runner 的 wall-clock 上限被外部 kill，没有任何诊断信息。</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code class="language-plaintext highlighter-rouge">set -e</code> 救不了 pipeline 内部的失败</td>
      <td>（第二章细讲）</td>
    </tr>
  </tbody>
</table>

<p>更深层的罪：这九行代码是<strong>用户态 reimplement Kubernetes 已经实现好的 reconcile loop</strong>。Control plane 本来就是一个”看谁还没就绪”的状态机，你不去问它，反而在外面写一个粗糙的克隆版。</p>

<h3 id="直觉口诀背下来">直觉口诀（背下来）</h3>

<blockquote>
  <p><strong>Don’t poll, don’t parse, don’t pick magic numbers. Tell the API what you mean.</strong></p>
</blockquote>

<p>Kubernetes 把”等到 X 满足”建模成了一等公民。你写”我想要什么”，控制器告诉你”什么时候到了”。这就是 <em>information directionality</em> 的正确方向：让权威源（API server）告诉你状态，而不是你去屏幕抓字符串猜。</p>

<h3 id="kubectl-wait-完整心法"><code class="language-plaintext highlighter-rouge">kubectl wait</code> 完整心法</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 按 condition 名等</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Ready pod/foo <span class="nt">--timeout</span><span class="o">=</span>60s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Available deployment <span class="nt">--all</span> <span class="nt">-n</span> ns <span class="nt">--timeout</span><span class="o">=</span>10m
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete job/foo <span class="nt">-n</span> ns <span class="nt">--timeout</span><span class="o">=</span>10m

<span class="c"># 按 jsonpath 等任意字段（1.23+）—— 几乎所有"等到状态 X"都能这么写</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.phase}'</span><span class="o">=</span>Active namespace/ns <span class="nt">--timeout</span><span class="o">=</span>30s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.loadBalancer.ingress[0].ip}'</span> svc/foo

<span class="c"># 按生命周期事件</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span>delete pod/foo <span class="nt">--timeout</span><span class="o">=</span>60s
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span>create deployment/foo                     <span class="c"># 1.31+</span>

<span class="c"># 多 condition 任一满足（1.30+）—— 等可能成功也可能失败的 Job</span>
kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Failed job/foo
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">kubectl rollout status</code> 是另一个独立原语 —— StatefulSet/DaemonSet 没有 <code class="language-plaintext highlighter-rouge">Available</code> condition，要用 <code class="language-plaintext highlighter-rouge">rollout status</code>；同时它会流式打印进度，看着安心。</p>

<h3 id="替换之后的样子">替换之后的样子</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">WAIT_TIMEOUT</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">WAIT_TIMEOUT</span><span class="k">:-</span><span class="nv">10m</span><span class="k">}</span><span class="s2">"</span>

kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.phase}'</span><span class="o">=</span>Active <span class="se">\</span>
    namespace/airflow <span class="nt">--timeout</span><span class="o">=</span>30s

kubectl rollout status statefulset/postgres <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">!</span> kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Complete job/db-init <span class="se">\</span>
        <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"❌ db-init did not complete. Recent logs:"</span>
    kubectl logs <span class="nt">-n</span> airflow job/db-init <span class="nt">--tail</span><span class="o">=</span>100 <span class="o">||</span> <span class="nb">true
    exit </span>1
<span class="k">fi

</span>kubectl <span class="nb">wait</span> <span class="nt">--for</span><span class="o">=</span><span class="nv">condition</span><span class="o">=</span>Available deployment <span class="nt">--all</span> <span class="se">\</span>
    <span class="nt">-n</span> airflow <span class="nt">--timeout</span><span class="o">=</span><span class="s2">"</span><span class="nv">$WAIT_TIMEOUT</span><span class="s2">"</span>
</code></pre></div></div>

<p>五个赢回来的设计点：</p>

<ol>
  <li><strong>没有魔法数字</strong>。再没有 <code class="language-plaintext highlighter-rouge">&gt;= 5</code> 这种业务真相硬编码。</li>
  <li><strong>不抓屏</strong>。直接问 API。</li>
  <li><strong>有边界</strong>。<code class="language-plaintext highlighter-rouge">--timeout</code> 让失败可观测，不再永生。</li>
  <li><strong>失败有现场</strong>。db-init 挂了自动 dump 最后 100 行日志。<strong>失败的脚本必须比成功的脚本输出更多信息</strong>。这是 on-call 工作里 ROI 最高的一条习惯。</li>
  <li><strong>向前兼容</strong>。<code class="language-plaintext highlighter-rouge">deployment --all</code> 让脚本对资源数量变化免疫 —— ops 脚本里的 Open/Closed Principle。</li>
</ol>

<h3 id="适用范围远不止-kubernetes">适用范围远不止 Kubernetes</h3>

<p>任何看到 <code class="language-plaintext highlighter-rouge">sleep N</code> 紧跟在这些命令后面的，立刻当 race condition 处理：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sleep N  +  kubectl apply
sleep N  +  helm install
sleep N  +  docker run
sleep N  +  terraform apply
sleep N  +  aws cloudformation deploy
sleep N  +  systemctl start
</code></pre></div></div>

<p>这些工具几乎都有自己的 <code class="language-plaintext highlighter-rouge">wait</code> / <code class="language-plaintext highlighter-rouge">--wait</code> / <code class="language-plaintext highlighter-rouge">rollout status</code> 原语。<strong>用户态 polling 永远是次优解</strong>。</p>

<h3 id="一句话哲学">一句话哲学</h3>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">sleep</code> 是说谎的注释。<code class="language-plaintext highlighter-rouge">kubectl wait</code> 是会执行的注释。</strong></p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">sleep N</code> 自我说明的语义是 <em>“我猜需要等这么久”</em> —— 它是一种被代码假装成解决方案的注释。每次看到它出现在生产部署脚本里，<strong>99% 是一个 race condition 等着在最不方便的时候被引爆</strong>。</p>

<hr />

<h2 id="chapter-2set--e-是-bash-给你的温柔谎言">Chapter 2：<code class="language-plaintext highlighter-rouge">set -e</code> 是 Bash 给你的温柔谎言</h2>

<p>Alex 的脚本顶部那一行：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-e</span>
</code></pre></div></div>

<p>我让他打开新的 shell，跑这三行：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-e</span>
<span class="nb">false</span> | <span class="nb">true
echo</span> <span class="s2">"我居然还在跑"</span>
</code></pre></div></div>

<p>打印了 <em>“我居然还在跑”</em>。Alex 的表情 —— 你能想象。</p>

<h3 id="为什么-set--e-看不到-pipe-里的失败">为什么 <code class="language-plaintext highlighter-rouge">set -e</code> 看不到 pipe 里的失败</h3>

<p><code class="language-plaintext highlighter-rouge">a | b</code> 这个管道，bash 默认的退出码 = <code class="language-plaintext highlighter-rouge">b</code> 的退出码。<code class="language-plaintext highlighter-rouge">a</code> 失败了？无所谓。</p>

<p>应用到第一章那条被替换掉的代码：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl get pod | <span class="nb">grep</span> <span class="nt">-v</span> test- | <span class="nb">grep </span>Running | <span class="nb">wc</span> <span class="nt">-l</span>
</code></pre></div></div>

<p>如果 <code class="language-plaintext highlighter-rouge">kubectl get pod</code> 因为 cluster auth 过期挂了：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">kubectl</code> 输出空 + 错误到 stderr，exit 1</li>
  <li><code class="language-plaintext highlighter-rouge">grep -v test-</code> 收到空 stdin → 无匹配 → exit 1（grep 没匹配也是 exit 1！）</li>
  <li><code class="language-plaintext highlighter-rouge">grep Running</code> 同上</li>
  <li><code class="language-plaintext highlighter-rouge">wc -l</code> 收到空 → 输出 <code class="language-plaintext highlighter-rouge">0</code>，exit 0</li>
  <li>整条管道 exit 0，<strong><code class="language-plaintext highlighter-rouge">set -e</code> 完全失效</strong></li>
</ol>

<p>下游的 <code class="language-plaintext highlighter-rouge">[ $n -ge 5 ]</code> 看到 <code class="language-plaintext highlighter-rouge">0</code>，永远不满足，<code class="language-plaintext highlighter-rouge">while true</code> 转到天荒地老。</p>

<p><strong>两个 bug 互相抵消，错觉是脚本工作正常</strong>。这是生产脚本里最常见的死法之一。</p>

<h3 id="strict-mode-三件套">Strict Mode 三件套</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-euo</span> pipefail
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>修补的默认缺陷</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-e</code></td>
      <td>命令失败后不要继续往下跑</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-u</code></td>
      <td><code class="language-plaintext highlighter-rouge">$FOO</code> 没定义时不要当空字符串（防 <code class="language-plaintext highlighter-rouge">rm -rf "$DIR/$SUBDIR"</code> 在 <code class="language-plaintext highlighter-rouge">SUBDIR</code> 拼错时变成 <code class="language-plaintext highlighter-rouge">rm -rf "$DIR/"</code> 这种灾难）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-o pipefail</code></td>
      <td>管道任一段失败 → 整个管道失败</td>
    </tr>
  </tbody>
</table>

<p>Aaron Maxwell 把这三件套命名为 <strong>“Bash Unofficial Strict Mode”</strong>。它不是花活，是修补 bash 三十年前一些为了向后兼容而做的”过度宽容”决策。每一个 flag 都对应<strong>默认行为里的一个具体设计缺陷</strong>：</p>

<table>
  <thead>
    <tr>
      <th>默认</th>
      <th>缺陷</th>
      <th>修补</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>命令失败 → 继续往下跑</td>
      <td>错误被悄悄吞掉</td>
      <td><code class="language-plaintext highlighter-rouge">-e</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">$FOO</code> 没定义 → 当空字符串</td>
      <td>拼错变量名变成静默炸弹</td>
      <td><code class="language-plaintext highlighter-rouge">-u</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">a \| b</code> 的退出码只看 <code class="language-plaintext highlighter-rouge">b</code></td>
      <td>管道前段失败被静默</td>
      <td><code class="language-plaintext highlighter-rouge">pipefail</code></td>
    </tr>
  </tbody>
</table>

<h3 id="set--e-的盲区面试拿分点"><code class="language-plaintext highlighter-rouge">set -e</code> 的盲区（面试拿分点）</h3>

<p>加了 strict mode，bash 也不是真严格。Principal 必须背下来这些<strong>例外</strong>：</p>

<table>
  <thead>
    <tr>
      <th>场景</th>
      <th><code class="language-plaintext highlighter-rouge">set -e</code> 触发吗？</th>
      <th>修补</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd \|\| true</code></td>
      <td>❌ 不触发（这是显式忽略）</td>
      <td>——</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">if cmd; then ...</code> 里的 <code class="language-plaintext highlighter-rouge">cmd</code></td>
      <td>❌ 不触发</td>
      <td>——</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd &amp;&amp; other</code> 链中除最后一个</td>
      <td>❌ 不触发</td>
      <td>——</td>
    </tr>
    <tr>
      <td><strong>命令替换 <code class="language-plaintext highlighter-rouge">$(cmd)</code> 失败</strong></td>
      <td>❌ <strong>不触发！</strong></td>
      <td><code class="language-plaintext highlighter-rouge">shopt -s inherit_errexit</code></td>
    </tr>
    <tr>
      <td>函数错误，被 <code class="language-plaintext highlighter-rouge">f \|\| handle</code> 调用</td>
      <td>❌ 不触发</td>
      <td><code class="language-plaintext highlighter-rouge">inherit_errexit</code></td>
    </tr>
  </tbody>
</table>

<p>最阴险的就是命令替换那条：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-e</span>
<span class="nv">DIR</span><span class="o">=</span><span class="si">$(</span>this_command_does_not_exist<span class="si">)</span>   <span class="c"># 失败了，但 set -e 不管</span>
<span class="nb">echo</span> <span class="s2">"DIR=[</span><span class="nv">$DIR</span><span class="s2">]"</span>                      <span class="c"># 仍然执行，DIR 是空字符串</span>
<span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$DIR</span><span class="s2">/cache"</span>                    <span class="c"># 💀 rm -rf "/cache"</span>
</code></pre></div></div>

<p>任何认真的生产 bash 脚本，闭环最小集：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">set</span> <span class="nt">-euo</span> pipefail
<span class="nb">shopt</span> <span class="nt">-s</span> inherit_errexit
</code></pre></div></div>

<h3 id="ifsnt-要不要加"><code class="language-plaintext highlighter-rouge">IFS=$'\n\t'</code> 要不要加？</h3>

<p>经典 Maxwell 版还有这一行，用来防止 <code class="language-plaintext highlighter-rouge">for x in $UNQUOTED</code> 的 word splitting。<strong>我倾向不加</strong>。它解决的问题是”你忘了加引号”，但你的代码本来就该用引号 + 数组。每加一行 boilerplate 都要赚到自己的位置 —— 这是 Parnas 的另一面：<strong>代码的复杂度也是一种成本，哪怕是”最佳实践”的复杂度</strong>。</p>

<h3 id="上-trap-给失败留现场">上 <code class="language-plaintext highlighter-rouge">trap</code> 给失败留现场</h3>

<p><code class="language-plaintext highlighter-rouge">set -e</code> 只是”挂了就退出”，<strong>不告诉你死在哪一行</strong>。一行 <code class="language-plaintext highlighter-rouge">trap</code> 让脚本失败时打印命令位置：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">trap</span> <span class="s1">'echo "❌ failed at line $LINENO: $BASH_COMMAND" &gt;&amp;2'</span> ERR
</code></pre></div></div>

<p>CI 部署脚本里这条<strong>必备</strong>。出事了能直接告诉值班的人是哪一行炸的，而不是让他对着 100 行输出做考古。</p>

<h3 id="一句话哲学-1">一句话哲学</h3>

<blockquote>
  <p><strong>默认行为是历史包袱。新代码要主动开严。</strong></p>
</blockquote>

<p>Bash 的默认行为是为了和 1989 年的脚本兼容设计的，不是为了你 2026 年的生产脚本。strict mode 不是可选项，<strong>是文明社会的最低基线</strong>。</p>

<hr />

<h2 id="chapter-3当所有显性问题都修完之后--windows-路径与隐形状态">Chapter 3：当所有显性问题都修完之后 —— Windows 路径与”隐形状态”</h2>

<p>到这里 Alex 已经修好了 strict mode、把所有 sleep + grep 换成 <code class="language-plaintext highlighter-rouge">kubectl wait</code>。脚本理论上无懈可击。</p>

<p>他重跑。15 秒后吐出这一行：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❌  Exiting due to GUEST_PROVISION:
    config: '\Program Files\Git\host' container path must be absolute
</code></pre></div></div>

<p>Alex 愣住了。<strong><code class="language-plaintext highlighter-rouge">\Program Files\Git\host</code> 是什么鬼？我没有写过这个路径啊。</strong></p>

<p>他翻遍脚本，确认自己只传了 <code class="language-plaintext highlighter-rouge">--mount-string="$ROOT_DIR:/host"</code>。<code class="language-plaintext highlighter-rouge">/host</code> 怎么会变成 <code class="language-plaintext highlighter-rouge">\Program Files\Git\host</code>？</p>

<p>如果你也遇到过类似 <em>“我明明没写这个，它怎么会出现”</em> 的诡异错误，下面这一节就是为你准备的。</p>

<h3 id="第一性原理shell-不是透明的">第一性原理：Shell 不是透明的</h3>

<p>我们都习惯把 shell 当成 <em>“用户和程序之间的玻璃”</em>：你输什么，程序看到什么。</p>

<p><strong>这是一个深远的误解。</strong></p>

<p>Git Bash 不是 Linux 的 bash。它跑在 MSYS2 运行时上 —— 一层专门为 <em>“让 POSIX 风格的命令行工具能调用 Win32 原生程序”</em> 而设计的翻译层。它的好心办坏事如下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>你写的字符串                bash 变量展开            ⚠️ MSYS 路径转换层               minikube 看到的
─────────────             ─────────────           ─────────────────────         ───────────────
"$ROOT_DIR:/host"   ──►  /c/Users/.../proj   ──►  C:\Users\...\proj         ──►  source 正确 ✓
                            :/host                    :\Program Files\Git\host        dest 错误  ✗
                                                       ▲
                                                       │
                                MSYS 看到 `/host` —— 一个以 `/` 开头的 POSIX 路径，
                                就好心地把它替换成 Win32 路径，
                                替换的"根"用的是 MSYS 安装目录 = `C:\Program Files\Git`
</code></pre></div></div>

<p>MSYS 的 heuristic 是按 <strong>字符串外形</strong> 工作的：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/foo            →  &lt;MSYS_ROOT&gt;\foo
/c/Users/x      →  C:\Users\x
//foo           →  /foo            (开头双斜杠 = 不要碰我)
a:/foo          →  按 `:` 切两半，每半都转
</code></pre></div></div>

<p>它<strong>不知道</strong> <code class="language-plaintext highlighter-rouge">/host</code> 在你脑子里的含义是 <em>“VM 里的挂载点”</em>。它只看到一个 <code class="language-plaintext highlighter-rouge">/</code> 开头的字符串。</p>

<blockquote>
  <p><strong>语义在你心里，语法在工具手里。</strong></p>
</blockquote>

<p>这就是 <strong>信息单向性 (Information Directionality)</strong> —— 信息每穿过一个上下文边界，都会被那一层按”自己以为对”的方式改写。从 host 到 guest、从 frontend 到 backend、从 ORM 到 SQL，无一例外。</p>

<h3 id="principal-的解法">Principal 的解法</h3>

<p>不是 Stack Overflow 上常见的 <code class="language-plaintext highlighter-rouge">//host</code> 这种”撞大运”魔法（双斜杠这个魔法字符串，三个月后没人记得为什么是双斜杠 —— 这是<strong>代码考古学陷阱</strong>），而是显式声明每一层的责任：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">uname</span> <span class="nt">-s</span><span class="si">)</span><span class="s2">"</span> <span class="k">in
    </span>MINGW<span class="k">*</span><span class="p">|</span>MSYS<span class="k">*</span><span class="p">|</span>CYGWIN<span class="k">*</span><span class="p">)</span>
        <span class="nv">HOST_MOUNT_SRC</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>cygpath <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$ROOT_DIR</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        <span class="nv">NO_PATHCONV</span><span class="o">=</span><span class="s2">"MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL=*"</span>
        <span class="p">;;</span>
    <span class="k">*</span><span class="p">)</span>
        <span class="nv">HOST_MOUNT_SRC</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ROOT_DIR</span><span class="s2">"</span>
        <span class="nv">NO_PATHCONV</span><span class="o">=</span><span class="s2">""</span>
        <span class="p">;;</span>
<span class="k">esac</span>

<span class="c"># shellcheck disable=SC2086</span>
<span class="nb">env</span> <span class="nv">$NO_PATHCONV</span> minikube start <span class="se">\</span>
    <span class="nt">--mount</span> <span class="nt">--mount-string</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HOST_MOUNT_SRC</span><span class="k">}</span><span class="s2">:/host"</span> <span class="se">\</span>
    <span class="nt">-p</span> platform-minikube
</code></pre></div></div>

<p>四个设计选择，每个都有理由：</p>

<table>
  <thead>
    <tr>
      <th>选择</th>
      <th>理由</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">case "$(uname -s)"</code></td>
      <td><strong>知人论世</strong>。脚本要先知道自己跑在谁的 shell 上。Linux/macOS 不需要这套，分支隔离，零干扰。</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cygpath -m</code></td>
      <td><strong>显式翻译胜于隐式假设</strong>。<code class="language-plaintext highlighter-rouge">-m</code> 输出 <code class="language-plaintext highlighter-rouge">C:/Users/...</code>（mixed slash），是 Docker、Java、几乎所有 CLI 都认的格式。</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MSYS_NO_PATHCONV=1</code> 用 <code class="language-plaintext highlighter-rouge">env</code> 前缀</td>
      <td><strong>作用域最小化</strong>（Parnas 信息隐藏 1972）。这个 quirk 只在调用 minikube 这一刻有意义，不应该污染整个脚本。</td>
    </tr>
    <tr>
      <td>注释解释 <em>为什么</em> 不解释 <em>是什么</em></td>
      <td>代码只说 <em>what</em>，注释要说 <em>why</em>。三年后的同事第一眼就懂”为什么不能去掉这个 case 分支”。</td>
    </tr>
  </tbody>
</table>

<p>Alex 改完保存，重新运行。</p>

<p>终端打出：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mounting C:/Users/.../proj to /host in Minikube VM
✨  Using the docker driver based on existing profile
🤦  StartHost failed: config: '\Program Files\Git\host' container path must be absolute
</code></pre></div></div>

<p><strong>Alex 直接懵了。</strong> “我明明改对了，怎么还是这个错？”</p>

<p>这就是这个故事最后一个、也是最值钱的一节。</p>

<h3 id="修代码--修系统">修代码 ≠ 修系统</h3>

<p>Alex 看到的第一行是我们脚本里的 <code class="language-plaintext highlighter-rouge">echo</code>，已经打出 <code class="language-plaintext highlighter-rouge">C:/Users/...</code> —— 我们的修复<strong>没问题</strong>。</p>

<p>但第二行是关键：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✨  Using the docker driver based on existing profile
</code></pre></div></div>

<p>minikube 在告诉你：<em>“我没用你这次传的参数，我从磁盘上的 profile 里读了一份。”</em></p>

<h4 id="持久化状态cli-工具的隐藏维度">持久化状态：CLI 工具的隐藏维度</h4>

<p>minikube 第一次启动时，会把所有 <code class="language-plaintext highlighter-rouge">--mount-string</code>、<code class="language-plaintext highlighter-rouge">--cpus</code>、<code class="language-plaintext highlighter-rouge">--memory</code> 等参数<strong>写进磁盘</strong>：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.minikube/profiles/&lt;profile-name&gt;/config.json
</code></pre></div></div>

<p>之后每一次 <code class="language-plaintext highlighter-rouge">minikube start</code>：</p>

<table>
  <thead>
    <tr>
      <th>你以为发生的</th>
      <th>实际发生的</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>用我命令行的最新参数重启</td>
      <td>读 profile 里的旧参数，<strong>resume</strong></td>
    </tr>
  </tbody>
</table>

<p>这意味着：<strong>当你的第一次 start 失败一半（mount 报错），minikube 已经把那个错的 mount-string 持久化了</strong>。从此你怎么改脚本它都不看，永远从磁盘那个污染过的 config 里拿。</p>

<p>minikube 自己的提示信息已经把这事说明白了：</p>

<blockquote>
  <p>*Running “minikube delete -p <profile>" may fix it*</profile></p>
</blockquote>

<p>它在<strong>官方承认</strong>：profile 状态污染了，得重置。</p>

<h4 id="修代码-vs-修系统一个二维矩阵">修代码 vs 修系统：一个二维矩阵</h4>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                        State on disk (~/.minikube/profiles/...)
                        ─────────────────────────────────────────────
                        │   clean                       polluted
        OLD code (bug)  │   creates pollution           buggy + cached
                        │   on first run                
                        │
        NEW code (fix)  │   ✅ works first time         ❌ resume reads
                        │                                old polluted state
                        │                                ◄── Alex 在这里
                        ─────────────────────────────────────────────
</code></pre></div></div>

<p><strong>修代码只能让你纵向移动一格。</strong> 横向那一格 —— 把已经污染的状态清掉 —— 是另一个动作。</p>

<p>口诀：</p>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">git pull</code> 不能给煎熟的鸡蛋退煎。</strong></p>

  <p>修代码 ≠ 修系统。状态和代码同样需要被维护。</p>
</blockquote>

<h4 id="这是一个普适模式">这是一个普适模式</h4>

<p>这个坑<strong>不是 minikube 独有的</strong>。任何 CLI 只要词典里有这些词，都有同样的二维空间：</p>

<table>
  <thead>
    <tr>
      <th>工具</th>
      <th>隐藏状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">docker compose</code></td>
      <td>named volumes、网络、容器名</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">terraform</code></td>
      <td><code class="language-plaintext highlighter-rouge">terraform.tfstate</code> 文件</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">kubectl</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.kube/config</code> 的 contexts</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">helm</code></td>
      <td>cluster 内 <code class="language-plaintext highlighter-rouge">helm.sh/release.v1.&lt;name&gt;</code> Secret</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gcloud config configurations</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/gcloud/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aws configure --profile</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.aws/credentials</code> 和 <code class="language-plaintext highlighter-rouge">config</code></td>
    </tr>
    <tr>
      <td>Conda / venv</td>
      <td><code class="language-plaintext highlighter-rouge">~/.conda/envs/&lt;name&gt;</code></td>
    </tr>
    <tr>
      <td>npm / pip lockfiles</td>
      <td><code class="language-plaintext highlighter-rouge">package-lock.json</code>, <code class="language-plaintext highlighter-rouge">poetry.lock</code></td>
    </tr>
    <tr>
      <td>Git submodules</td>
      <td><code class="language-plaintext highlighter-rouge">.git/modules/</code></td>
    </tr>
  </tbody>
</table>

<p><strong>第一性原则</strong>：凡是命令行工具用了这几个词 —— <em>profile / workspace / context / project / environment / release</em> —— 就一定有一份你看不见的状态机在磁盘上活着。修了代码不重置状态，等于换了油不换油底壳。</p>

<h4 id="principal-的工程化处理">Principal 的工程化处理</h4>

<p>让脚本<strong>自带逃生口</strong>，比让团队记忆”咒语”强一百倍：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">CLEAN</span><span class="o">=</span>0
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    case</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="k">in</span>
        <span class="nt">--clean</span> <span class="p">|</span> <span class="nt">--force</span><span class="p">)</span> <span class="nv">CLEAN</span><span class="o">=</span>1 <span class="p">;;</span>
    <span class="k">esac</span>
<span class="k">done

if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$CLEAN</span><span class="s2">"</span> <span class="nt">-eq</span> 1 <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"🧹 --clean: deleting any existing profile to clear stale config..."</span>
    minikube delete <span class="nt">-p</span> platform-minikube <span class="o">||</span> <span class="nb">true
</span><span class="k">fi</span>
</code></pre></div></div>

<p>之后 README 里写一行：</p>

<blockquote>
  <p><em>如果遇到 <code class="language-plaintext highlighter-rouge">Using the docker driver based on existing profile</code> 后报奇怪错误，加 <code class="language-plaintext highlighter-rouge">--clean</code> 重跑。</em></p>
</blockquote>

<p>部落知识 → 工程产物。这是 Principal 和 Senior 的分界线之一。</p>

<hr />

<h2 id="结语三张地图">结语：三张地图</h2>

<p>回到王阳明那句话：</p>

<blockquote>
  <p><strong>“知而不行，只是未知。”</strong></p>
</blockquote>

<p>Alex 这一晚学到的，不是三个 bug fix。是三种看世界的方式：</p>

<table>
  <thead>
    <tr>
      <th>直觉</th>
      <th>含义</th>
      <th>适用范围</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>声明胜于命令式</strong></td>
      <td>当系统给了你一等公民的”等到 X 满足”原语，不要自己写 polling 循环。</td>
      <td>Kubernetes / Docker / Helm / Terraform / 任何带 <code class="language-plaintext highlighter-rouge">wait</code> 概念的工具</td>
    </tr>
    <tr>
      <td><strong>默认行为是历史包袱</strong></td>
      <td><code class="language-plaintext highlighter-rouge">set -e</code> 不够，<code class="language-plaintext highlighter-rouge">-u</code> 不够，<code class="language-plaintext highlighter-rouge">pipefail</code> 不够，<code class="language-plaintext highlighter-rouge">inherit_errexit</code> 才够。默认行为往往为兼容老脚本而留情，新代码要主动开严。</td>
      <td>任何 shell 脚本、任何配置默认值、任何”开箱即用”的框架</td>
    </tr>
    <tr>
      <td><strong>修代码 ≠ 修系统</strong></td>
      <td>持久化状态是独立维度，需要独立的清理动作；脚本里要有 <code class="language-plaintext highlighter-rouge">--clean</code> 逃生口。</td>
      <td>任何带 profile/context/workspace 概念的 CLI 和 IaC 工具</td>
    </tr>
  </tbody>
</table>

<p>这三个直觉<strong>不是关于 Kubernetes、bash 或 minikube</strong>。</p>

<p>它们是关于 <em>声明式 vs 命令式</em>、<em>默认值的政治</em>、<em>状态机 vs 代码</em> 这些跨工具、跨语言的元结构。你写过的每一段 docker、terraform、kubectl、ansible、helm、git、gcloud 代码，都是这三张地图的某个角落。</p>

<hr />

<h2 id="立刻可以做的事">立刻可以做的事</h2>

<ol>
  <li><strong>审计你手上一个最常跑的部署脚本</strong>。找出三类 smell：
    <ul>
      <li>任何 <code class="language-plaintext highlighter-rouge">sleep N</code> 后面跟着 <em>“等待 X 就绪”</em> 的注释 → 换成 <code class="language-plaintext highlighter-rouge">kubectl wait</code> / <code class="language-plaintext highlighter-rouge">--wait</code> / <code class="language-plaintext highlighter-rouge">rollout status</code></li>
      <li>任何 <code class="language-plaintext highlighter-rouge">... | grep ... | wc -l</code> 后面跟着的数值比较 → 用 <code class="language-plaintext highlighter-rouge">kubectl wait</code> 或 jsonpath 查询</li>
      <li>任何 <code class="language-plaintext highlighter-rouge">set -e</code> 没有配 <code class="language-plaintext highlighter-rouge">-u</code>、<code class="language-plaintext highlighter-rouge">pipefail</code>、<code class="language-plaintext highlighter-rouge">inherit_errexit</code> 的 → 一次性升到 strict mode</li>
    </ul>
  </li>
  <li>
    <p><strong>把任何一个有 profile / context / state 概念的 CLI 工具调用，加上 <code class="language-plaintext highlighter-rouge">--clean</code> 或等价开关</strong>。文档里写清楚：什么时候用、为什么要用。</p>
  </li>
  <li><strong>在团队 README 里加一句</strong>：”凡是 ‘Using existing X’ 后面接错误，先尝试 reset/clean。”</li>
</ol>

<p>部落知识写成代码或文档，是 Principal 最常做的事。</p>

<hr />

<h2 id="预告">预告</h2>

<p>下一篇我会写 <strong>“为什么你的 Terraform <code class="language-plaintext highlighter-rouge">apply</code> 在第二次执行时不再做你以为的事”</strong> —— 同样是 <em>修代码 ≠ 修系统</em> 这条主线，但发生在 IaC 的世界里，state drift 和 lock 文件之间的博弈让这个问题更阴险。</p>

<p>如果你觉得这一篇值，欢迎转给那个昨晚被部署脚本的 race condition 折磨到凌晨两点的同事。</p>

<hr />

<blockquote>
  <p><em>Bug 不在终端里，在你和你工具之间的那张地图上。</em></p>
</blockquote>]]></content><author><name></name></author><category term="tech" /><category term="bash" /><category term="kubernetes" /><category term="devops" /><category term="shell-scripting" /><summary type="html"><![CDATA[“知而不行，只是未知。” —— 王阳明]]></summary></entry></feed>