<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>观澜涉川 · 札记</title><description>风行水上 · 利涉大川</description><link>https://engineering.lishechuan.com/</link><language>zh-CN</language><item><title>在 Istio Gateway 中保留客户端传入的 X-Request-ID</title><link>https://engineering.lishechuan.com/preserve-request-id-in-istio-gateway/</link><guid isPermaLink="true">https://engineering.lishechuan.com/preserve-request-id-in-istio-gateway/</guid><description>客户端发送 X-Request-ID，服务端收到另一个值。问题不在业务代码，而在 Envoy 的 edge 判定。</description><pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;客户端发送了 &lt;code&gt;X-Request-ID&lt;/code&gt;，服务端收到的却是另一个值。为什么 Istio Gateway 要改写它？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;本文由 DeepSeek V4.1 Flash 1017 完成搜索和分析。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;原因并不复杂：Istio Gateway 改写 &lt;code&gt;X-Request-ID&lt;/code&gt; 不是 bug，而是 Envoy 在信任边界上的默认行为。&lt;code&gt;preserve_external_request_id&lt;/code&gt; 能解决“整值替换”，但解决不了“UUID 第 13 个十六进制位被改写”。如果要求字节级不变，还需要调整 &lt;code&gt;request_id_extension&lt;/code&gt;；如果只是要业务链路关联，更稳妥的做法是单独使用一个 correlation header，而不是和 Envoy 的 &lt;code&gt;X-Request-ID&lt;/code&gt; 语义硬碰硬。&lt;/p&gt;
&lt;h2 id=&quot;现象是谁改了-x-request-id&quot;&gt;现象：是谁改了 X-Request-ID&lt;/h2&gt;
&lt;p&gt;Istio 的 ingress gateway 本质上是一个 Envoy。&lt;code&gt;X-Request-ID&lt;/code&gt; 由 Envoy 的 HTTP Connection Manager（HCM）管理，Envoy 文档对它的定义是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;x-request-id&lt;/code&gt; header is used by Envoy to uniquely identify a request as well as perform stable access logging and tracing. Envoy will generate an &lt;code&gt;x-request-id&lt;/code&gt; header for all external origin requests (the header is sanitized). It will also generate an &lt;code&gt;x-request-id&lt;/code&gt; header for internal requests that do not already have one.&lt;/p&gt;
&lt;p&gt;—— &lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-request-id&quot;&gt;Envoy HTTP header manipulation: x-request-id&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;HTTP header 名称大小写不敏感（&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110#section-5.1&quot;&gt;RFC 9110 §5.1&lt;/a&gt;），&lt;code&gt;X-Request-ID&lt;/code&gt;、&lt;code&gt;X-Request-Id&lt;/code&gt; 和 &lt;code&gt;x-request-id&lt;/code&gt; 是同一个字段；HTTP/2 在构造消息时要求字段名转为小写（&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9113#section-8.2&quot;&gt;RFC 9113 §8.2&lt;/a&gt;）。本文正文统一写作 &lt;code&gt;X-Request-ID&lt;/code&gt;，Envoy 文档和配置示例中则常见小写 &lt;code&gt;x-request-id&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这段话里有两个关键词：&lt;strong&gt;external origin request&lt;/strong&gt; 和 &lt;strong&gt;sanitized&lt;/strong&gt;。也就是说，对于来自外部客户端的请求，Envoy 默认不信任客户端带来的 &lt;code&gt;X-Request-ID&lt;/code&gt;，会重新生成一个；对于 mesh 内部的请求，如果已经有值，则保留。具体行为可以归纳为四种：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;请求情况&lt;/th&gt;&lt;th&gt;Envoy 默认行为&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;没有 &lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/td&gt;&lt;td&gt;生成新的 UUID&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;有 &lt;code&gt;X-Request-ID&lt;/code&gt;，非 edge 请求&lt;/td&gt;&lt;td&gt;保留原值&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;有 &lt;code&gt;X-Request-ID&lt;/code&gt;，edge 请求，&lt;code&gt;preserve_external_request_id=false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;替换为新 UUID&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;有 &lt;code&gt;X-Request-ID&lt;/code&gt;，edge 请求，&lt;code&gt;preserve_external_request_id=true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;保留原值&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这张表只描述 &lt;code&gt;preserve_external_request_id&lt;/code&gt; 本身；如果 &lt;code&gt;pack_trace_reason&lt;/code&gt; 保持默认的 &lt;code&gt;true&lt;/code&gt;，36 字符的 UUID 还可能被改写第 13 个十六进制位，见后文。&lt;/p&gt;
&lt;p&gt;这里的 edge 指 Envoy 术语中的 &lt;strong&gt;edge request&lt;/strong&gt;：外部客户端进入受信任网络的第一跳代理，通常是 ingress gateway；它不是“边缘计算”的 edge，也不特指某个 CDN 节点。相对于这个 Envoy，后续 mesh 内部的请求都算内部请求。&lt;/p&gt;
&lt;p&gt;这里的“edge 请求”由 &lt;code&gt;use_remote_address&lt;/code&gt; 和地址判断共同决定。Istio 的 gateway HCM 使用 &lt;code&gt;use_remote_address: true&lt;/code&gt;，所以从公网或集群外来的请求会被当作 edge 请求，于是就会出现“客户端明明传了 ID，服务端却收到另一个”的现象。&lt;/p&gt;
&lt;h2 id=&quot;为什么-istio-125-之后更容易遇到&quot;&gt;为什么 Istio 1.25 之后更容易遇到&lt;/h2&gt;
&lt;p&gt;在 Envoy 1.33 之前，RFC 1918 私网地址默认被视为 internal。很多请求经过云负载均衡器或另一层代理后，源地址是 &lt;code&gt;10.x&lt;/code&gt;、&lt;code&gt;192.168.x&lt;/code&gt; 这类私网地址，Envoy 会把它当成内部请求，于是 &lt;code&gt;X-Request-ID&lt;/code&gt; 被保留。看起来“一直都没问题”。&lt;/p&gt;
&lt;p&gt;Envoy 1.33 改了这个默认值：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;http: RFC1918 addresses are no longer considered to be internal addresses by default. This addresses a security issue for Envoy’s in multi-tenant mesh environments. Please explicitly set &lt;code&gt;internal_address_config&lt;/code&gt; to retain the prior behavior.&lt;/p&gt;
&lt;p&gt;—— &lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/v1.33.0/version_history/v1.33/v1.33.0&quot;&gt;Envoy 1.33 release notes&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Istio 1.25 开始带 Envoy 1.33，因此升级后，同样的私网来源会被重新判定为 edge 请求，&lt;code&gt;X-Request-ID&lt;/code&gt; 就开始被替换了。Istio 的 issue &lt;a href=&quot;https://github.com/istio/istio/issues/57938&quot;&gt;#57938&lt;/a&gt; 里记录了这个升级前后的差异，维护者的解释也是这个原因。&lt;/p&gt;
&lt;p&gt;所以，如果是在升级 Istio 之后才发现这个问题，很可能不是 Istio 单独改了逻辑，而是 Envoy 上游的安全默认值变了。&lt;/p&gt;
&lt;h2 id=&quot;envoy-为什么要这样做&quot;&gt;Envoy 为什么要这样做&lt;/h2&gt;
&lt;p&gt;从“客户端认为 ID 应该端到端不变”的角度看，这个行为确实反直觉；但从网关的信任边界看，它是合理的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;外部 ID 不可信&lt;/strong&gt;。客户端可以伪造、复用、构造超长值，甚至用 ID 影响采样。网关作为 edge，负责签发一个可信的 ID。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要稳定的采样和日志关联&lt;/strong&gt;。Envoy 的默认 UUID 扩展会把 trace sampling 决策编码进 UUID 的第 13 个十六进制位（即 UUID 第三组的第一个字符）。如果直接信任外部 ID，外部就可以携带一个“已采样”的标记进来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;信任边界重启是行业惯例&lt;/strong&gt;。W3C Trace Context 规范明确允许 front gate 重启 trace，用于消除 DoS 攻击面；Envoy 对 &lt;code&gt;X-Request-ID&lt;/code&gt; 的处理是同一类思路。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Envoy 的模型是：&lt;strong&gt;edge 负责签发，mesh 内部负责传播&lt;/strong&gt;。如果诉求是“客户端是可信的，希望端到端保持”，这是另一种同样合理的模型，但需要显式打开开关。&lt;/p&gt;
&lt;h2 id=&quot;方案用-envoyfilter-保留-x-request-id&quot;&gt;方案：用 EnvoyFilter 保留 X-Request-ID&lt;/h2&gt;
&lt;p&gt;解决方式并不复杂：给 gateway 的 HCM 打一个 &lt;code&gt;MERGE&lt;/code&gt; patch，把 &lt;code&gt;preserve_external_request_id&lt;/code&gt; 设为 &lt;code&gt;true&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: gateway-preserve-request-id
  namespace: &quot;&amp;#x3C;gateway-namespace&gt;&quot;
spec:
  workloadSelector:
    labels:
      gateway.networking.k8s.io/gateway-name: &quot;&amp;#x3C;gateway-name&gt;&quot;
  configPatches:
    - applyTo: NETWORK_FILTER
      match:
        context: GATEWAY
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
      patch:
        operation: MERGE
        value:
          typed_config:
            &quot;@type&quot;: type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
            preserve_external_request_id: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段配置有几个关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;context: GATEWAY&lt;/code&gt; 限定只改 gateway，不影响 sidecar。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;applyTo: NETWORK_FILTER&lt;/code&gt; + &lt;code&gt;filter.name: envoy.filters.network.http_connection_manager&lt;/code&gt; 精确匹配到 HCM。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MERGE&lt;/code&gt; 只加字段，不替换整个 HCM，风险最小。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;workloadSelector&lt;/code&gt; 用的 &lt;code&gt;gateway.networking.k8s.io/gateway-name&lt;/code&gt; 是 Istio Gateway controller 自动加到 Deployment、Service、Pod 等资源上的 well-known label，用来选自动创建的 Gateway 没问题；如果是手工部署的 gateway，需要自己确保 pod 上有这个 label。Istio 1.24 起移除了旧的 &lt;code&gt;istio.io/gateway-name&lt;/code&gt;，新版本统一用这个 label。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有一个容易忽略的细节：EnvoyFilter 是 namespace 级的。&lt;code&gt;workloadSelector&lt;/code&gt; 只会匹配&lt;strong&gt;同一 namespace&lt;/strong&gt; 的 pod；如果 Gateway 在 &lt;code&gt;istio-system&lt;/code&gt;，EnvoyFilter 也要放在 &lt;code&gt;istio-system&lt;/code&gt;，或者放到 Istio 的 root namespace 并接受它作用于所有匹配 workload。&lt;/p&gt;
&lt;p&gt;如果 Istio 版本在 1.22 及以上，也可以用 &lt;code&gt;targetRefs&lt;/code&gt; 替代 &lt;code&gt;workloadSelector&lt;/code&gt;，直接指向 Gateway API 的 Gateway，避免依赖生成的 label：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: &quot;&amp;#x3C;gateway-name&gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;targetRefs&lt;/code&gt; 和 &lt;code&gt;workloadSelector&lt;/code&gt; 只能二选一。&lt;/p&gt;
&lt;p&gt;验证时，先看 gateway 的 config dump 里有没有生效：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;istioctl proxy-config listener &amp;#x3C;gateway-pod&gt; -n &amp;#x3C;namespace&gt; -o json \
  | jq &apos;.. | objects | select(has(&quot;preserveExternalRequestId&quot;)) | {preserveExternalRequestId, requestIdExtension}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有任何输出，说明 patch 没匹配到目标 HCM。然后用几种不同格式的 ID 各打一次（UUID4、UUIDv7 形状、32 位十六进制），对比服务端看到的原始值；只测 UUID4 很容易漏掉第 13 个十六进制位的问题。&lt;/p&gt;
&lt;h2 id=&quot;关键坑打开-preserve-后第-13-个十六进制位仍可能变化&quot;&gt;关键坑：打开 preserve 后，第 13 个十六进制位仍可能变化&lt;/h2&gt;
&lt;p&gt;这是最容易漏掉的一点。&lt;code&gt;preserve_external_request_id: true&lt;/code&gt; 确实阻止了 Envoy 把整个值换掉，但 Envoy 的 UUID 请求 ID 扩展还有一个 &lt;code&gt;pack_trace_reason&lt;/code&gt;，默认是 &lt;code&gt;true&lt;/code&gt;。当请求是 edge、选择保留外部 ID、且 &lt;code&gt;pack_trace_reason&lt;/code&gt; 为 true 时，对于 36 字符的 UUID，Envoy 会把第 13 个十六进制位写成 &lt;code&gt;4&lt;/code&gt;，用来清除外部 ID 里的 trace sampling 标记。这一步和是否启用 tracing 无关，只要 &lt;code&gt;pack_trace_reason&lt;/code&gt; 为 true 就会执行。&lt;/p&gt;
&lt;p&gt;Envoy 的 tracing 文档也明确提醒了这一点：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The default implementation will modify the request ID UUID4 to pack the final trace reason into the UUID. … However, trace reason packing may break externally generated request IDs that must be maintained. The &lt;code&gt;pack_trace_reason&lt;/code&gt; field can be used to disable this behavior at the expense of also disabling stable trace reason propagation and associated features.&lt;/p&gt;
&lt;p&gt;—— &lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/observability/tracing&quot;&gt;Envoy tracing overview&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;具体表现取决于 ID 的格式：&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;客户端发送的 ID&lt;/th&gt;&lt;th&gt;只加 &lt;code&gt;preserve_external_request_id&lt;/code&gt;&lt;/th&gt;&lt;th&gt;再加 &lt;code&gt;pack_trace_reason: false&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;标准 UUID4，例如 &lt;code&gt;11111111-1111-4111-8111-111111111111&lt;/code&gt;&lt;/td&gt;&lt;td&gt;不变（该位置本来就是 &lt;code&gt;4&lt;/code&gt;）&lt;/td&gt;&lt;td&gt;不变&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;UUIDv7 形状，例如 &lt;code&gt;11111111-1111-7111-8111-111111111111&lt;/code&gt;&lt;/td&gt;&lt;td&gt;第 13 个十六进制位从 &lt;code&gt;7&lt;/code&gt; 变成 &lt;code&gt;4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;不变&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;被 Envoy 标记过采样的 UUID，例如 &lt;code&gt;11111111-1111-9111-8111-111111111111&lt;/code&gt;&lt;/td&gt;&lt;td&gt;第 13 个十六进制位被清回 &lt;code&gt;4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;不变&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;32 位十六进制、ULID 等非 36 字符 ID&lt;/td&gt;&lt;td&gt;不变（长度不是 36，Envoy 不会改写）&lt;/td&gt;&lt;td&gt;不变&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;任意 36 字符字符串&lt;/td&gt;&lt;td&gt;第 13 个十六进制位被写成 &lt;code&gt;4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;不变&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这解释了一个常见现象：用 UUID4 测试时看起来“完全保留”，换成 UUIDv7 或某些客户端生成的 ID 后，第 13 个十六进制位就变了。如果验证时只用了 UUID4，可能刚好没有暴露这个问题。&lt;/p&gt;
&lt;p&gt;如果要求字节级一致，把 &lt;code&gt;request_id_extension&lt;/code&gt; 一起补上（仍是 HCM &lt;code&gt;typed_config&lt;/code&gt; 的子字段）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;            preserve_external_request_id: true
            request_id_extension:
              typed_config:
                &quot;@type&quot;: type.googleapis.com/envoy.extensions.request_id.uuid.v3.UuidRequestIdConfig
                pack_trace_reason: false
                use_request_id_for_trace_sampling: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意，&lt;code&gt;UuidRequestIdConfig&lt;/code&gt; 必须嵌在 HCM 的 &lt;code&gt;request_id_extension&lt;/code&gt; 字段里，不能把 &lt;code&gt;UuidRequestIdConfig&lt;/code&gt; 单独作为一个 &lt;code&gt;NETWORK_FILTER&lt;/code&gt; patch；它不是网络过滤器，类型对不上，不会得到预期的效果。&lt;/p&gt;
&lt;p&gt;这两个开关的代价要讲清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pack_trace_reason: false&lt;/code&gt; 能保证 ID 字节级一致，但 Envoy 文档说，依赖 &lt;code&gt;X-Request-ID&lt;/code&gt; 的稳定 trace/access log 采样会失效，只剩随机采样。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;use_request_id_for_trace_sampling: false&lt;/code&gt; 让 Envoy 不再用 &lt;code&gt;X-Request-ID&lt;/code&gt; 做采样决策，适合链路上存在非 Envoy 代理、或者不希望客户端 ID 影响采样的场景。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果不需要字节级一致，只保留 &lt;code&gt;preserve_external_request_id&lt;/code&gt; 也完全可以接受：第 13 个十六进制位的清理本质上是 Envoy 在拒绝信任外部采样标记，是一个安全特性，不是 bug。&lt;/p&gt;
&lt;h2 id=&quot;这样做的副作用&quot;&gt;这样做的副作用&lt;/h2&gt;


















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;副作用&lt;/th&gt;&lt;th&gt;说明&lt;/th&gt;&lt;th&gt;建议&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;客户端可伪造&lt;/td&gt;&lt;td&gt;任何外部客户端都能指定 ID。如果下游拿它做鉴权、审计、限流或幂等，风险很高&lt;/td&gt;&lt;td&gt;只从可信上游保留；校验格式和长度；不要用于安全决策&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;影响采样&lt;/td&gt;&lt;td&gt;&lt;code&gt;use_request_id_for_trace_sampling&lt;/code&gt; 默认 true，客户端可以用 ID 影响是否采样；复用同一个 ID 还会让日志和采样关联失真&lt;/td&gt;&lt;td&gt;不需要时设为 false，或保留 Envoy 默认的 trace reason 清理&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;第 13 个十六进制位被改写&lt;/td&gt;&lt;td&gt;&lt;code&gt;pack_trace_reason&lt;/code&gt; 默认 true，36 字符 ID 的固定位置会被写成 &lt;code&gt;4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;要字节级一致就关掉，并接受稳定采样失效&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;日志注入/噪音&lt;/td&gt;&lt;td&gt;客户端提供的值通常会进入访问日志；超长值会污染日志&lt;/td&gt;&lt;td&gt;限制字符集和长度；使用结构化日志并转义&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;EnvoyFilter 维护成本&lt;/td&gt;&lt;td&gt;依赖 Envoy 内部字段和 type URL，Istio 升级后可能失效&lt;/td&gt;&lt;td&gt;升级后复查 config dump；保持 patch 最小&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;作用域是 HCM&lt;/td&gt;&lt;td&gt;选中 gateway 的所有 HTTP listener/filter chain 都会生效，不能按 host 区分&lt;/td&gt;&lt;td&gt;如果多个 Gateway 共享一个 deployment，确认影响范围&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;响应不回显&lt;/td&gt;&lt;td&gt;默认不会把生成的 ID 放到响应头&lt;/td&gt;&lt;td&gt;需要时加 &lt;code&gt;always_set_request_id_in_response: true&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;用内部地址配置绕过&lt;/td&gt;&lt;td&gt;把 LB 网段设为 internal 也能“保留”，但会改变 &lt;code&gt;x-envoy-*&lt;/code&gt; 头的信任逻辑&lt;/td&gt;&lt;td&gt;不要这么做；用 &lt;code&gt;preserve_external_request_id&lt;/code&gt; 更精准&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;特别要提醒的是&lt;strong&gt;幂等&lt;/strong&gt;。&lt;a href=&quot;https://github.com/istio/istio/issues/57938&quot;&gt;Istio issue #57938&lt;/a&gt; 中记录了一个案例：网关改写 &lt;code&gt;X-Request-ID&lt;/code&gt; 后，幂等逻辑失效，产生了重复交易。但 &lt;code&gt;X-Request-ID&lt;/code&gt; 本身不是为幂等设计的：客户端提供的值不保证唯一，也不保证可信，可以重放同一个值。如果业务需要重试去重，应该用独立的 &lt;code&gt;Idempotency-Key&lt;/code&gt;，而不是复用请求 ID。&lt;/p&gt;
&lt;p&gt;另外，不建议用“把负载均衡器网段配置成 internal”来解决这个问题。那会同时改变 Envoy 对 &lt;code&gt;x-envoy-*&lt;/code&gt; 头的信任判断，扩大攻击面；Istio 的 &lt;a href=&quot;https://github.com/istio/istio/issues/53402&quot;&gt;#53402&lt;/a&gt; 讨论的就是这个信任收紧。这里只需要保留一个 header，不应该顺手把信任边界也改掉。&lt;/p&gt;
&lt;h2 id=&quot;x-request-idx-correlation-id-和其他跟踪字段&quot;&gt;X-Request-ID、X-Correlation-ID 和其他跟踪字段&lt;/h2&gt;
&lt;p&gt;这几个 header 经常被混用，但语义并不一样：&lt;/p&gt;




































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;字段&lt;/th&gt;&lt;th&gt;语义&lt;/th&gt;&lt;th&gt;是否标准&lt;/th&gt;&lt;th&gt;谁生成&lt;/th&gt;&lt;th&gt;典型用途&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/td&gt;&lt;td&gt;标识一次 HTTP 请求；在信任边界可能被重置&lt;/td&gt;&lt;td&gt;事实标准，不是 RFC&lt;/td&gt;&lt;td&gt;edge/gateway 或客户端&lt;/td&gt;&lt;td&gt;访问日志、单次请求排障&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;X-Correlation-ID&lt;/code&gt;&lt;/td&gt;&lt;td&gt;标识一笔业务事务，可能跨多个请求和服务&lt;/td&gt;&lt;td&gt;事实标准，不是 RFC&lt;/td&gt;&lt;td&gt;第一次交互处&lt;/td&gt;&lt;td&gt;跨服务日志关联、客服排障&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;traceparent&lt;/code&gt; / &lt;code&gt;tracestate&lt;/code&gt;&lt;/td&gt;&lt;td&gt;分布式 trace 的上下文&lt;/td&gt;&lt;td&gt;W3C 标准&lt;/td&gt;&lt;td&gt;链路起点或各跳&lt;/td&gt;&lt;td&gt;分布式追踪&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;b3&lt;/code&gt; / &lt;code&gt;x-b3-*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Zipkin 的 trace 上下文&lt;/td&gt;&lt;td&gt;事实标准&lt;/td&gt;&lt;td&gt;链路起点或各跳&lt;/td&gt;&lt;td&gt;Zipkin/Jaeger 生态&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x-amzn-trace-id&lt;/code&gt;&lt;/td&gt;&lt;td&gt;AWS X-Ray/ALB 的 trace 上下文&lt;/td&gt;&lt;td&gt;AWS 专有&lt;/td&gt;&lt;td&gt;ALB 或上游&lt;/td&gt;&lt;td&gt;AWS 内追踪&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x-cloud-trace-context&lt;/code&gt;&lt;/td&gt;&lt;td&gt;GCP 旧版 trace 上下文&lt;/td&gt;&lt;td&gt;GCP 专有，旧版&lt;/td&gt;&lt;td&gt;GCP 负载均衡/服务&lt;/td&gt;&lt;td&gt;GCP 追踪，现已建议用 &lt;code&gt;traceparent&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x-ms-request-id&lt;/code&gt; / &lt;code&gt;x-ms-client-request-id&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Azure 的服务端请求 ID / 客户端请求 ID&lt;/td&gt;&lt;td&gt;Azure 专有&lt;/td&gt;&lt;td&gt;服务端 / 客户端&lt;/td&gt;&lt;td&gt;Azure 排障、重试去重&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt;&lt;/td&gt;&lt;td&gt;标识一次可重试的操作&lt;/td&gt;&lt;td&gt;IETF 草案（已过期）&lt;/td&gt;&lt;td&gt;客户端&lt;/td&gt;&lt;td&gt;重试幂等&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;业界的做法大致可以分成几类：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Edge 签发，内部传播（Envoy/Istio 的模型）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;网关负责给外部请求分配 ID，内部服务只负责传播。好处是 ID 可信、唯一、可稳定采样；代价是客户端无法自己指定端到端 ID。这是 Envoy 的默认模型，也是很多 API gateway 的默认模型。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 有则保留，无则生成（ingress-nginx、Kong 的模型）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;ingress-nginx 的模板里有一个很典型的 map：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;map $http_x_request_id $req_id {
    default   $http_x_request_id;
    &quot;&quot;        $request_id;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端带了 &lt;code&gt;X-Request-ID&lt;/code&gt; 就原样传给上游，没带才用 nginx 的 &lt;code&gt;$request_id&lt;/code&gt; 生成。Kong 的 correlation-id 插件也是类似策略：如果请求里已经有同名 header，就尊重它，不覆盖。&lt;/p&gt;
&lt;p&gt;这类做法对客户端友好，但前提是网关信任客户端，或者至少对值做了校验。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 分层 ID（AWS ALB、Azure 的模型）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AWS ALB 的处理很值得借鉴：如果请求里已经有 &lt;code&gt;X-Amzn-Trace-Id&lt;/code&gt; 的 &lt;code&gt;Root&lt;/code&gt; 字段，ALB 保留 &lt;code&gt;Root&lt;/code&gt;，再插入一个 &lt;code&gt;Self&lt;/code&gt; 字段；没有才生成新的 &lt;code&gt;Root&lt;/code&gt;。这样既保留了调用方的 trace 起点，又加上了当前负载均衡器的信息。&lt;/p&gt;
&lt;p&gt;Azure 的部分协议也区分 &lt;code&gt;x-ms-client-request-id&lt;/code&gt;（客户端提供）和 &lt;code&gt;x-ms-request-id&lt;/code&gt;（服务端生成），并可以通过 &lt;code&gt;x-ms-return-client-request-id&lt;/code&gt; 决定是否回显客户端 ID。与其覆盖，不如把“客户端 ID”和“服务端 ID”分开。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 标准追踪上下文（W3C traceparent）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;真正的分布式追踪不应该依赖 &lt;code&gt;X-Request-ID&lt;/code&gt;。W3C Trace Context 定义了 &lt;code&gt;traceparent&lt;/code&gt; 和 &lt;code&gt;tracestate&lt;/code&gt;，把 trace ID、parent span ID、采样标记标准化；Google Cloud 也明确建议优先使用 &lt;code&gt;traceparent&lt;/code&gt;，&lt;code&gt;x-cloud-trace-context&lt;/code&gt; 只作为兼容保留。&lt;/p&gt;
&lt;p&gt;W3C 规范还明确允许 front gate “restart trace”，用于消除 DoS 攻击面。这再次说明：在信任边界重启追踪上下文是正当设计，不是实现错误。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. 业务 correlation ID&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Microsoft 的工程手册对 Correlation ID 的定义是：在第一次交互时分配，传播到所有下游组件，所有组件在日志里使用同一个 ID。它建议优先使用 OpenTelemetry 的 TraceId；如果不用 OTel，就用一个自定义 header（常见的就是 &lt;code&gt;X-Correlation-ID&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;这类 ID 的语义是“业务事务”，不是“单次 HTTP 请求”，所以通常应该跨网关保留，而不是每跳重置。但它同样不是标准，值需要校验，也不能用于安全决策。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. 命名规范&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RFC 6648 从 2012 年起就不建议再给自定义 header 加 &lt;code&gt;X-&lt;/code&gt; 前缀了。现实中 &lt;code&gt;X-Request-ID&lt;/code&gt;、&lt;code&gt;X-Correlation-ID&lt;/code&gt; 已经大量存在，不可能一夜改掉；但新设计的系统应该优先用标准名（如 &lt;code&gt;traceparent&lt;/code&gt;），或者至少把 header 的语义、生成方、是否透传写进接口契约，而不是靠猜。&lt;/p&gt;
&lt;h2 id=&quot;实践建议&quot;&gt;实践建议&lt;/h2&gt;
&lt;p&gt;把“请求 ID”“关联 ID”“trace ID”“幂等键”分开，是最稳妥的做法：&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;场景&lt;/th&gt;&lt;th&gt;建议&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;公网客户端，需要排障&lt;/td&gt;&lt;td&gt;让 gateway 生成 &lt;code&gt;X-Request-ID&lt;/code&gt;，必要时打开 &lt;code&gt;always_set_request_id_in_response&lt;/code&gt;，通过响应头返回给客户端；客户端记录这个 ID&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;可信上游（CDN、另一层网关）已有 ID&lt;/td&gt;&lt;td&gt;用 &lt;code&gt;preserve_external_request_id&lt;/code&gt; 保留，并在网关或应用入口校验字符集和长度&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;需要跨服务业务关联&lt;/td&gt;&lt;td&gt;单独使用 &lt;code&gt;X-Correlation-ID&lt;/code&gt;，在第一次交互处生成，网关只做校验和透传&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;分布式追踪&lt;/td&gt;&lt;td&gt;使用 &lt;code&gt;traceparent&lt;/code&gt;/&lt;code&gt;tracestate&lt;/code&gt;，不要把 &lt;code&gt;X-Request-ID&lt;/code&gt; 当 trace ID&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;重试幂等&lt;/td&gt;&lt;td&gt;使用 &lt;code&gt;Idempotency-Key&lt;/code&gt;，服务端做去重存储，不要复用 &lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;多厂商/多语言&lt;/td&gt;&lt;td&gt;统一到 W3C trace context + 一个自定义 correlation header，并写进契约&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;如果要在网关或应用入口校验，可以先用一个保守的白名单，例如 &lt;code&gt;^[A-Za-z0-9._:-]{1,128}$&lt;/code&gt;，并拒绝多个同名 header；具体规则取决于 ID 的生成方式。&lt;/p&gt;
&lt;p&gt;回到开头的方案：方向正确，可以用。如果只关心“服务端能拿到客户端传的那个 ID”，并且客户端 ID 是标准 UUID4（第三组首位为 &lt;code&gt;4&lt;/code&gt;）或非 36 字符，现有配置就够了；如果要求字节级一致，补上 &lt;code&gt;request_id_extension&lt;/code&gt;；如果目标是长期可维护的端到端关联，建议把 &lt;code&gt;X-Request-ID&lt;/code&gt; 留给网关/Envoy，用一个独立的 &lt;code&gt;X-Correlation-ID&lt;/code&gt; 承载业务语义。&lt;/p&gt;
&lt;p&gt;一句话总结：不要和 Envoy 的 &lt;code&gt;X-Request-ID&lt;/code&gt; 语义硬碰硬。要么显式接管它（&lt;code&gt;preserve_external_request_id&lt;/code&gt;，必要时关闭 trace packing），要么换一个不承载 Envoy 内部语义的 header。&lt;/p&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto&quot;&gt;Envoy HTTP Connection Manager proto&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-request-id&quot;&gt;Envoy HTTP header manipulation: x-request-id&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/request_id/uuid/v3/uuid.proto&quot;&gt;Envoy UUID request ID extension&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/observability/tracing&quot;&gt;Envoy tracing overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.envoyproxy.io/docs/envoy/v1.33.0/version_history/v1.33/v1.33.0&quot;&gt;Envoy 1.33 release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/envoyproxy/envoy/blob/main/source/common/http/conn_manager_utility.cc&quot;&gt;Envoy &lt;code&gt;conn_manager_utility.cc&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/envoyproxy/envoy/blob/main/source/extensions/request_id/uuid/config.cc&quot;&gt;Envoy &lt;code&gt;uuid/config.cc&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/reference/config/networking/envoy-filter/&quot;&gt;Istio EnvoyFilter reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/reference/config/labels/&quot;&gt;Istio resource labels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/istio/istio/issues/10875&quot;&gt;Istio issue #10875: X-Request-Id gets overwritten by Istio&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/istio/istio/issues/57938&quot;&gt;Istio issue #57938: x-request-id not preserved after upgrade&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/istio/istio/issues/53402&quot;&gt;Istio issue #53402: internal Envoy header sanitization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl&quot;&gt;ingress-nginx &lt;code&gt;nginx.tmpl&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.konghq.com/plugins/correlation-id/&quot;&gt;Kong Correlation ID plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-request-tracing.html&quot;&gt;AWS ALB request tracing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ncnbi/817da997-30d2-4cd3-972f-a0073e4e98f7&quot;&gt;Azure &lt;code&gt;x-ms-client-request-id&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Azure/diagnostics-correlation&quot;&gt;Microsoft Diagnostics Correlation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://microsoft.github.io/code-with-engineering-playbook/observability/correlation-id/&quot;&gt;Microsoft Engineering Playbook: Correlation IDs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design&quot;&gt;Azure API design best practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/trace/docs/trace-context&quot;&gt;Google Cloud Trace context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3.org/TR/trace-context/&quot;&gt;W3C Trace Context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6648&quot;&gt;RFC 6648: Deprecating the “X-” Prefix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110&quot;&gt;RFC 9110: HTTP Semantics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9113&quot;&gt;RFC 9113: HTTP/2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header&quot;&gt;The Idempotency-Key HTTP Header Field&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>让 HAProxy 和 Caddy 之后的应用拿到客户端 IP</title><link>https://engineering.lishechuan.com/client-ip-behind-haproxy-caddy/</link><guid isPermaLink="true">https://engineering.lishechuan.com/client-ip-behind-haproxy-caddy/</guid><description>HAProxy 透传 TLS 时加不了 HTTP 头，Caddy 又默认忽略上游的 XFF。用 PROXY protocol 补上这一跳，应用才能拿到真实 IP。</description><pubDate>Tue, 20 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;假定部署结构如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端 → HAProxy → Caddy → 应用&lt;/li&gt;
&lt;li&gt;HAProxy 用 &lt;code&gt;mode tcp&lt;/code&gt; 转发 443&lt;/li&gt;
&lt;li&gt;Caddy 持有证书并终止 TLS&lt;/li&gt;
&lt;li&gt;应用只监听回环&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种结构下，应用默认只能看到 Caddy 的地址（&lt;code&gt;127.0.0.1&lt;/code&gt;）。要拿到真实的客户端 IP，需要把地址分两跳传下去：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;HAProxy 通过 &lt;strong&gt;PROXY protocol&lt;/strong&gt; 把源地址带到 Caddy；&lt;/li&gt;
&lt;li&gt;Caddy 把它写进 &lt;strong&gt;&lt;code&gt;X-Forwarded-For&lt;/code&gt;&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;应用读取这个头。&lt;/li&gt;
&lt;/ol&gt;
&lt;figure style=&quot;margin: 1.6rem auto; max-width: 480px;&quot;&gt;
&lt;svg viewBox=&quot;0 0 400 532&quot; role=&quot;img&quot; aria-labelledby=&quot;cid-title cid-desc&quot; style=&quot;width: 100%; max-width: 480px; height: auto; display: block; margin: 0 auto;&quot;&gt;
  &lt;title id=&quot;cid-title&quot;&gt;客户端 IP 在 HAProxy、Caddy 与应用之间的传递链路&lt;/title&gt;
  &lt;desc id=&quot;cid-desc&quot;&gt;客户端通过 TLS 连接 HAProxy；HAProxy 以 TCP 转发到 Caddy，并在连接开头附加 PROXY protocol 头；Caddy 终止 TLS、采用源地址并写入 X-Forwarded-For；应用读取该头得到真实客户端 IP。&lt;/desc&gt;
  &lt;defs&gt;
    &lt;marker id=&quot;cid-arrow&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
      &lt;path class=&quot;cid-arrow-head&quot; d=&quot;M 0 0 L 10 5 L 0 10 Z&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
    &lt;marker id=&quot;cid-arrow-accent&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
      &lt;path class=&quot;cid-arrow-head-accent&quot; d=&quot;M 0 0 L 10 5 L 0 10 Z&quot;&gt;&lt;/path&gt;
    &lt;/marker&gt;
    &lt;style&gt;
      .cid-box { fill: var(--bg-elev); stroke: var(--border-strong); stroke-width: 1.2; }
      .cid-title { fill: var(--text); font-family: var(--ff-sans); font-size: 18px; font-weight: 600; }
      .cid-sub { fill: var(--muted); font-family: var(--ff-sans); font-size: 13px; }
      .cid-label { fill: var(--text-soft); font-family: var(--ff-sans); font-size: 14px; }
      .cid-strong { fill: var(--text); font-family: var(--ff-sans); font-size: 14px; font-weight: 600; }
      .cid-code { fill: var(--muted); font-family: var(--ff-mono); font-size: 12.5px; }
      .cid-line { stroke: var(--muted); stroke-width: 1.5; fill: none; }
      .cid-line-accent { stroke: var(--accent); stroke-width: 1.8; fill: none; }
      .cid-accent-box { fill: var(--accent-tint); stroke: var(--accent); stroke-opacity: 0.35; }
      .cid-accent-label { fill: var(--accent-strong); font-family: var(--ff-sans); font-size: 14px; font-weight: 600; }
      .cid-arrow-head { fill: var(--muted); }
      .cid-arrow-head-accent { fill: var(--accent); }
    &lt;/style&gt;
  &lt;/defs&gt;
  &lt;rect class=&quot;cid-box&quot; x=&quot;40&quot; y=&quot;20&quot; width=&quot;320&quot; height=&quot;56&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;cid-title&quot; x=&quot;200&quot; y=&quot;44&quot; text-anchor=&quot;middle&quot;&gt;客户端&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;64&quot; text-anchor=&quot;middle&quot;&gt;x.x.x.x&lt;/text&gt;
  &lt;path class=&quot;cid-line&quot; d=&quot;M200 76 V112&quot; marker-end=&quot;url(#cid-arrow)&quot;&gt;&lt;/path&gt;
  &lt;text class=&quot;cid-label&quot; x=&quot;212&quot; y=&quot;98&quot;&gt;TLS 握手 + 加密数据&lt;/text&gt;
  &lt;rect class=&quot;cid-box&quot; x=&quot;40&quot; y=&quot;116&quot; width=&quot;320&quot; height=&quot;88&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;cid-title&quot; x=&quot;200&quot; y=&quot;145&quot; text-anchor=&quot;middle&quot;&gt;HAProxy :443&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;168&quot; text-anchor=&quot;middle&quot;&gt;mode tcp：只转发密文&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;186&quot; text-anchor=&quot;middle&quot;&gt;读不到 HTTP，加不了 XFF&lt;/text&gt;
  &lt;path class=&quot;cid-line-accent&quot; d=&quot;M200 204 V216&quot;&gt;&lt;/path&gt;
  &lt;path class=&quot;cid-line-accent&quot; d=&quot;M200 260 V280&quot; marker-end=&quot;url(#cid-arrow-accent)&quot;&gt;&lt;/path&gt;
  &lt;rect class=&quot;cid-accent-box&quot; x=&quot;60&quot; y=&quot;216&quot; width=&quot;280&quot; height=&quot;44&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;cid-accent-label&quot; x=&quot;200&quot; y=&quot;234&quot; text-anchor=&quot;middle&quot;&gt;PROXY protocol&lt;/text&gt;
  &lt;text class=&quot;cid-code&quot; x=&quot;200&quot; y=&quot;252&quot; text-anchor=&quot;middle&quot;&gt;PROXY TCP4 x.x.x.x …&lt;/text&gt;
  &lt;rect class=&quot;cid-box&quot; x=&quot;40&quot; y=&quot;288&quot; width=&quot;320&quot; height=&quot;88&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;cid-title&quot; x=&quot;200&quot; y=&quot;317&quot; text-anchor=&quot;middle&quot;&gt;Caddy :8453&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;340&quot; text-anchor=&quot;middle&quot;&gt;proxy_protocol 采用源地址&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;358&quot; text-anchor=&quot;middle&quot;&gt;再写入 X-Forwarded-For&lt;/text&gt;
  &lt;path class=&quot;cid-line&quot; d=&quot;M200 376 V412&quot; marker-end=&quot;url(#cid-arrow)&quot;&gt;&lt;/path&gt;
  &lt;text class=&quot;cid-strong&quot; x=&quot;212&quot; y=&quot;394&quot;&gt;X-Forwarded-For&lt;/text&gt;
  &lt;text class=&quot;cid-code&quot; x=&quot;212&quot; y=&quot;410&quot;&gt;x.x.x.x&lt;/text&gt;
  &lt;rect class=&quot;cid-box&quot; x=&quot;40&quot; y=&quot;420&quot; width=&quot;320&quot; height=&quot;88&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;cid-title&quot; x=&quot;200&quot; y=&quot;449&quot; text-anchor=&quot;middle&quot;&gt;应用 :9000&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;472&quot; text-anchor=&quot;middle&quot;&gt;读取 XFF 最右一条&lt;/text&gt;
  &lt;text class=&quot;cid-sub&quot; x=&quot;200&quot; y=&quot;490&quot; text-anchor=&quot;middle&quot;&gt;不要读 TCP 对端（ConnectInfo）&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption style=&quot;margin-top: 0.6rem; font-size: 0.85rem; color: var(--muted); text-align: center;&quot;&gt;图：客户端 IP 的两跳传递&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&quot;地址如何走过两跳&quot;&gt;地址如何走过两跳&lt;/h2&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;跳&lt;/th&gt;&lt;th&gt;传递方式&lt;/th&gt;&lt;th&gt;作用&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;客户端 → HAProxy&lt;/td&gt;&lt;td&gt;TLS&lt;/td&gt;&lt;td&gt;HAProxy 在 TCP 层看到客户端源地址&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HAProxy → Caddy&lt;/td&gt;&lt;td&gt;PROXY protocol&lt;/td&gt;&lt;td&gt;TLS 透传时把源地址带给 Caddy&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Caddy → 应用&lt;/td&gt;&lt;td&gt;X-Forwarded-For&lt;/td&gt;&lt;td&gt;应用从 HTTP 头读取真实 IP&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;HAProxy 工作在 &lt;code&gt;mode tcp&lt;/code&gt;，只看得到 TCP 字节和 TLS ClientHello 里的 SNI，读不到 HTTP，因此无法添加 &lt;code&gt;X-Forwarded-For&lt;/code&gt;。Caddy 终止 TLS 后能看到 HTTP，但它默认不采信上游送来的 XFF（&lt;a href=&quot;https://caddyserver.com/docs/caddyfile/directives/reverse_proxy&quot;&gt;文档&lt;/a&gt;）：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For these X-Forwarded-* headers, by default, the proxy will &lt;strong&gt;ignore their values from incoming requests&lt;/strong&gt;, to prevent spoofing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;所以分工是：PROXY protocol 补上 HAProxy 这一跳，XFF 负责 Caddy 到应用这一跳。&lt;/p&gt;
&lt;h2 id=&quot;配置-haproxy&quot;&gt;配置 HAProxy&lt;/h2&gt;
&lt;p&gt;在 frontend 上绑定 443，在指向 Caddy 的 backend 上加 &lt;code&gt;send-proxy-v2&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;frontend fe_https
    mode tcp
    bind *:443
    default_backend be_caddy

backend be_caddy
    mode tcp
    option tcplog
    server caddy 127.0.0.1:8453 send-proxy-v2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;send-proxy-v2&lt;/code&gt; 只加在 backend 的 server 行上。它会让 HAProxy 在每条连接的开头、TLS 握手之前，写入一段包含客户端源地址的 PROXY v2 头。&lt;/p&gt;
&lt;h2 id=&quot;配置-caddy&quot;&gt;配置 Caddy&lt;/h2&gt;
&lt;p&gt;在 Caddyfile 最上面的 global options 里启用 &lt;code&gt;proxy_protocol&lt;/code&gt; listener wrapper，并把它放在 &lt;code&gt;tls&lt;/code&gt; 之前：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
	servers {
		listener_wrappers {
			proxy_protocol {
				allow 127.0.0.1/32
			}
			tls
		}
	}
}

app.example.com:8453 {
	reverse_proxy 127.0.0.1:9000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个关键点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;proxy_protocol&lt;/code&gt; 必须在 &lt;code&gt;tls&lt;/code&gt; 之前&lt;/strong&gt;。它在 TLS 握手之前读取明文前缀，顺序反了就解析不到。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;allow&lt;/code&gt; 指定可信来源&lt;/strong&gt;。&lt;code&gt;127.0.0.1/32&lt;/code&gt; 表示只采用同机 HAProxy 送来的 PROXY 头；如果 HAProxy 改用 &lt;code&gt;::1&lt;/code&gt; 连接，这里要写 &lt;code&gt;::1/128&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;fallback_policy&lt;/code&gt; 默认是 &lt;code&gt;ignore&lt;/code&gt;&lt;/strong&gt;。不在 &lt;code&gt;allow&lt;/code&gt; 列表里的来源，连接照收，但 PROXY 头里的地址会被丢弃。因此 &lt;code&gt;allow&lt;/code&gt; 不能省略，也不要把默认策略改成 &lt;code&gt;use&lt;/code&gt;，否则任何人都能伪造来源地址。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;servers&lt;/code&gt; 块只对 Caddyfile 中实际存在的站点生效&lt;/strong&gt;，所以上面保留下面的站点块；改完配置后 reload。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reverse_proxy&lt;/code&gt; 默认会向上游写 &lt;code&gt;X-Forwarded-For&lt;/code&gt;、&lt;code&gt;X-Forwarded-Proto&lt;/code&gt; 和 &lt;code&gt;X-Forwarded-Host&lt;/code&gt;&lt;/strong&gt;，不需要额外的 &lt;code&gt;header_up&lt;/code&gt;。其中 &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; 取自 Caddy 收到的请求协议（这里是 &lt;code&gt;https&lt;/code&gt;），&lt;code&gt;X-Forwarded-Host&lt;/code&gt; 取自请求的 &lt;code&gt;Host&lt;/code&gt;；这三个头都由 Caddy 生成，不是从 HAProxy 透传的。如果站点块里手写了 &lt;code&gt;header_up X-Forwarded-For ...&lt;/code&gt;，它会覆盖默认行为，应当删掉。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这里不需要配置 &lt;code&gt;trusted_proxies&lt;/code&gt;。它控制的是是否采信上游通过 HTTP 头送来的地址，而不是是否生成这些头。&lt;code&gt;proxy_protocol&lt;/code&gt; 已经在 TCP 层把连接对端换成了真实客户端，Caddy 直接以这个地址为准；没有 &lt;code&gt;trusted_proxies&lt;/code&gt; 时，入站的头会被忽略并重新生成，正是这条链路想要的结果。&lt;/p&gt;
&lt;h2 id=&quot;配置应用&quot;&gt;配置应用&lt;/h2&gt;
&lt;p&gt;应用读取 &lt;code&gt;X-Forwarded-For&lt;/code&gt;，取&lt;strong&gt;最右边一条&lt;/strong&gt;。以 Rust 为例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;use std::net::IpAddr;

fn client_ip(xff: &amp;#x26;str) -&gt; Option&amp;#x3C;IpAddr&gt; {
    xff.rsplit(&apos;,&apos;).next()?.trim().parse().ok()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条链路上，Caddy 会忽略入站请求里的 XFF，按自己看到的客户端地址重写这个头，所以右起第一条就是真实客户端；最左边那条是调用方自己写的，永远不可信。&lt;/p&gt;
&lt;p&gt;如果应用需要还原原始协议或 Host（例如生成绝对 URL），可以同时读取 &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; 和 &lt;code&gt;X-Forwarded-Host&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果应用读的是 TCP 对端（例如 axum 的 &lt;code&gt;ConnectInfo&lt;/code&gt;），代理之后只能拿到 Caddy 的地址（这里是 &lt;code&gt;127.0.0.1&lt;/code&gt;），必须改成读 XFF。&lt;/p&gt;
&lt;h2 id=&quot;验证配置&quot;&gt;验证配置&lt;/h2&gt;
&lt;p&gt;下面用 &lt;code&gt;203.0.113.7&lt;/code&gt; 作为示例地址；它属于 RFC 5737 保留的文档专用网段，不会与真实主机冲突。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;① 应用层&lt;/strong&gt;——跳过 Caddy，直接打应用，注入一条 XFF：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -s -H &apos;X-Forwarded-For: 203.0.113.7&apos; http://127.0.0.1:9000/api/v1/ip/addresses/self
# {&quot;ip&quot;:&quot;203.0.113.7&quot;}  → 应用读取 XFF 正确
# {&quot;ip&quot;:&quot;127.0.0.1&quot;}    → 应用读的是 ConnectInfo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;② Caddy 层&lt;/strong&gt;——跳过 HAProxy，自己发一个 PROXY 头（&lt;code&gt;curl --haproxy-clientip&lt;/code&gt; 需要 curl ≥ 8.2，旧版可用这段 Python）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import socket, ssl
s = socket.create_connection((&quot;127.0.0.1&quot;, 8453))
s.sendall(b&quot;PROXY TCP4 203.0.113.7 127.0.0.1 40000 8453\r\n&quot;)
c = ssl.create_default_context().wrap_socket(s, server_hostname=&quot;app.example.com&quot;)
c.sendall(b&quot;GET /api/v1/ip/addresses/self HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n&quot;)
print(c.recv(4096).decode(errors=&quot;replace&quot;).split(&quot;\r\n\r\n&quot;)[-1])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里发的是 PROXY v1 头，Caddy 的 wrapper 同时支持 v1 和 v2。&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;输出&lt;/th&gt;&lt;th&gt;含义&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;203.0.113.7&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Caddy 已采用，应用也读取正确；再确认 HAProxy 配置并 reload&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Caddy 解析了头但没采用，检查 &lt;code&gt;allow&lt;/code&gt; / &lt;code&gt;fallback_policy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;报错 / 挂住&lt;/td&gt;&lt;td&gt;&lt;code&gt;listener_wrappers&lt;/code&gt; 没生效，检查顺序和 reload&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;③ 端到端&lt;/strong&gt;——从公网访问：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -s https://app.example.com/api/v1/ip/addresses/self   # 应是真实公网 IP
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;注意事项&quot;&gt;注意事项&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;不要信任 XFF 的最左端&lt;/strong&gt;。那是调用方自己填的，只有最右侧、由可信代理写入的地址才可信。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Caddy 默认不记录访问日志&lt;/strong&gt;，只记错误和警告。需要观察 Caddy 看到的客户端时，可以临时加一个响应头，验完记得删掉：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header X-Caddy-Saw {remote_host}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://caddyserver.com/docs/caddyfile/directives/reverse_proxy&quot;&gt;Caddy: reverse_proxy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://caddyserver.com/docs/caddyfile/options#listener-wrappers&quot;&gt;Caddy: global options — listener_wrappers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.haproxy.org/2.8/configuration.html&quot;&gt;HAProxy Configuration Manual&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt&quot;&gt;PROXY protocol specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For&quot;&gt;MDN: X-Forwarded-For&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://curl.se/docs/manpage.html#--haproxy-clientip&quot;&gt;curl: —haproxy-clientip&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>企业环境下的 Certbot 实战：用 Azure Key Vault 托管私钥签发 SSL 证书</title><link>https://engineering.lishechuan.com/letsencrypt-azure-key-vault/</link><guid isPermaLink="true">https://engineering.lishechuan.com/letsencrypt-azure-key-vault/</guid><description>让 Key Vault 出私钥和 CSR、Certbot 只负责签名，证书再合并回去——签发全程私钥不出 Key Vault。</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在单机场景里，用 Certbot 申请 Let’s Encrypt 证书可以很省事：它自己生成私钥、完成域名验证、保存证书，几乎不需要人工介入。代价是它默认了一件事——运行 Certbot 的那台机器，就是最终使用这张证书的机器。&lt;/p&gt;
&lt;p&gt;域名前面挂了 CDN 或别的反向代理时，还有一层额外的限制：HTTP-01 校验到不了运行 Certbot 的机器，只能改用 DNS-01，在域名的 DNS 里写一条 &lt;code&gt;_acme-challenge&lt;/code&gt; 记录来证明所有权。DNS-01 顺带也更好拿通配符证书，只是要配上 DNS 服务商的插件或自己写的 hook，否则 &lt;code&gt;--manual&lt;/code&gt; 会停下来等你手工加记录。&lt;/p&gt;
&lt;p&gt;到了企业环境，前面那个“同一台机器”的默认就不成立了：CI/CD 的运行器、审计人员、甚至别的团队都可能接触到那台机器；私钥一旦落在构建机的磁盘上，就可能进入构建缓存、日志、镜像层或者备份，而它恰恰是整个 TLS 信任链里最不能泄露的东西。&lt;/p&gt;
&lt;p&gt;本文给出一套可以在企业里落地的 Azure 方案：&lt;strong&gt;域名用 DNS-01 挑战验证，私钥由 Azure Key Vault 生成并保管，Certbot 只负责让 Let’s Encrypt 用这份私钥对应的 CSR 签出一张证书，签名完成后再把证书合并回 Key Vault&lt;/strong&gt;——整个签发过程中，私钥从不离开 Key Vault。&lt;/p&gt;
&lt;p&gt;为什么不干脆用云厂商的托管证书？因为托管证书的私钥拿不到手。以 Azure Front Door 为例，它的托管证书由 DigiCert 签发后存放在 Front Door 自己的 Key Vault 里，你既看不到私钥，也没法把它用到别处；一旦需要通配符，或者同一张证书要同时挂在 Front Door 和源站上，就只能自带证书（BYOC），而 BYOC 要求证书来自你自己的 Key Vault。AWS 的 ACM、GCP 的 Certificate Manager 也提供类似的托管证书，各有自带证书的做法，思路相通，但产品与限制不同，本文不展开。&lt;/p&gt;
&lt;h2 id=&quot;目标与设计&quot;&gt;目标与设计&lt;/h2&gt;
&lt;h3 id=&quot;密钥生成与签名分离&quot;&gt;密钥生成与签名分离&lt;/h3&gt;
&lt;p&gt;先说清楚一个容易混淆的点：我们不用的是 &lt;strong&gt;Certbot 生成的私钥&lt;/strong&gt;，而不是“Certbot 创建的证书”。证书必须由权威 CA 签发，Certbot 在这里的角色是 ACME 客户端，负责完成域名验证并取回签名结果。真正要剥夺的是 Certbot 生成和保存私钥的权力。&lt;/p&gt;
&lt;p&gt;要做到这一点，需要把“生成密钥对”和“请求签名”两件事拆开：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Key Vault 生成密钥对和 CSR&lt;/strong&gt;。私钥创建在 Key Vault 内部，外部只拿到 CSR（Certificate Signing Request，证书签名请求，里面只有公钥和域名信息，没有私钥）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certbot 拿着 CSR 去让 Let’s Encrypt 签名&lt;/strong&gt;。Certbot 通过 DNS-01 挑战证明域名所有权，但签名的是我们给它的 CSR，所以它自始至终看不到私钥。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把签名后的证书链合并回 Key Vault 并保存&lt;/strong&gt;。Key Vault 把它和自己保管的那把私钥配对，完成证书创建并存进库中，供消费端引用。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这正是 Key Vault 的“外部签名证书”能力：只要证书策略里的签发者是 &lt;code&gt;Unknown&lt;/code&gt;，Key Vault 就停在“待处理（pending）”状态，等我们把外部 CA 签好的证书合并进来。&lt;/p&gt;
&lt;h3 id=&quot;三方角色&quot;&gt;三方角色&lt;/h3&gt;
&lt;p&gt;整条链路涉及三方：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Azure Key Vault&lt;/strong&gt;：生成并保管私钥，出具 CSR；收到签名证书后与库内私钥配对，把它保存为证书。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;签发作业&lt;/strong&gt;（CI/CD 或定时任务）：运行 Certbot，只持有 CSR 和签名结果，读不到私钥。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let’s Encrypt&lt;/strong&gt;：通过 DNS-01 挑战验证域名所有权，对 CSR 签名。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;消费端与-exportable&quot;&gt;消费端与 exportable&lt;/h3&gt;
&lt;p&gt;“私钥不离开 Key Vault”这句话有一个前提，就是证书策略里的 &lt;code&gt;exportable&lt;/code&gt; 必须为 &lt;code&gt;false&lt;/code&gt;。这是整个方案里最容易踩的坑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;exportable: false&lt;/code&gt; 时，私钥只是 Key Vault 里的一个密钥对象，即使把它对应的 secret 取出来，内容里也&lt;strong&gt;不包含&lt;/strong&gt;私钥。这才是真正的“私钥不出库”。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exportable: true&lt;/code&gt; 时，任何拿到 &lt;code&gt;secrets/get&lt;/code&gt; 权限的人都能把私钥（连同 PFX）导出走，所谓“不离开 Key Vault”就名存实亡了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代价是：不可导出的私钥，&lt;strong&gt;任何需要私钥材料来完成 TLS 握手的消费端都用不上&lt;/strong&gt;——Key Vault 把密钥锁在库里，服务拿不到密钥本身，也就没法在自己这边终止 TLS。所以 &lt;code&gt;exportable: false&lt;/code&gt; 换来的是“密码学意义上的私钥不出库”，代价是适用面很窄：它基本只能用于把 Key Vault 当签名引擎的场景（比如 mTLS 客户端证书、代码签名），而不是普通的 HTTPS 服务端证书。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个具体的例子：这张证书只给 Application Gateway 用。&lt;/strong&gt; 很多人以为这样就能设成不可导出，其实不行——官方文档写得很明确：&lt;strong&gt;“Key Vault certificates must have an exportable private key in order for the Application Gateway to be able to use them.”&lt;/strong&gt; Application Gateway 是用自己的托管标识，在运行时去 Key Vault 里 &lt;code&gt;Get&lt;/code&gt; 证书的 secret，把私钥装进监听器，所以私钥材料确实从 Key Vault 交到了 Application Gateway 手上。App Service 从 Key Vault 导入证书、Front Door 通过 &lt;code&gt;Get&lt;/code&gt; 取用证书，也都属于这一类——它们都需要私钥。&lt;/p&gt;
&lt;p&gt;所以要分清两种“不离开 Key Vault”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;密码学意义&lt;/strong&gt;：&lt;code&gt;exportable: false&lt;/code&gt;，私钥永远出不了库；代价是消费端只能用 Key Vault 做密钥操作，普通的 TLS 终止做不到。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运营意义&lt;/strong&gt;：&lt;code&gt;exportable: true&lt;/code&gt;，但私钥由消费端（Application Gateway、Front Door、App Service）用托管标识在运行时直接从 Key Vault 取用，&lt;strong&gt;运维和签发流水线从不经手 PFX，也不把它落到别处&lt;/strong&gt;。这才是大多数企业场景里真实可得的收益。&lt;/li&gt;
&lt;/ul&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;消费方式&lt;/th&gt;&lt;th&gt;exportable&lt;/th&gt;&lt;th&gt;说明&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;TLS 终止类服务（Application Gateway、Front Door、App Service）&lt;/td&gt;&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;需要私钥材料，由服务用托管标识从 Key Vault 取用&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;需要证书文件的主机 / 容器 / Nginx / IIS&lt;/td&gt;&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;私钥落到该主机，需另行防护&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;只用 Key Vault 做签名 / 解密（如 mTLS 客户端证书）&lt;/td&gt;&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;&lt;td&gt;私钥真正不出库&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;合规要求密钥托管在 HSM&lt;/td&gt;&lt;td&gt;用 Managed HSM&lt;/td&gt;&lt;td&gt;密钥天然不可导出&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;要注意，签发环节和 &lt;code&gt;exportable&lt;/code&gt; 无关：无论设成哪个值，CSR 都由 Key Vault 生成，私钥都不经过 Certbot 和签发流水线；&lt;code&gt;exportable&lt;/code&gt; 只决定&lt;strong&gt;消费端&lt;/strong&gt;能不能拿到私钥材料。即便设成 &lt;code&gt;true&lt;/code&gt;，也比“在构建机上生成私钥”安全得多——私钥由 Key Vault 生成，签发作业只有证书权限、拿不到 &lt;code&gt;secrets/get&lt;/code&gt;（见下文的最小权限设计），连它自己签出来的私钥都读不到。&lt;/p&gt;
&lt;h2 id=&quot;准备工作&quot;&gt;准备工作&lt;/h2&gt;
&lt;h3 id=&quot;签发作业的身份&quot;&gt;签发作业的身份&lt;/h3&gt;
&lt;p&gt;执行这套流程需要一个 Azure 身份。它至少要做三件事：在 Key Vault 里创建/合并证书、在 DNS 里增删 TXT 记录、以及（首次运行时）注册 ACME 账户。&lt;/p&gt;
&lt;p&gt;推荐用 &lt;strong&gt;用户分配的托管标识（UAI）&lt;/strong&gt;，或 GitHub Actions / Azure DevOps 的 &lt;strong&gt;OIDC 联合凭据&lt;/strong&gt;。两者都能避免在流水线里存放长期的服务主体密码：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;运行在 Azure 计算资源上（VM、Container Apps、AKS）：用 UAI，&lt;code&gt;az login --identity --username &amp;#x3C;client-id&gt;&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;运行在 GitHub Actions / Azure DevOps：用工作负载联合身份，&lt;code&gt;az login&lt;/code&gt; 时使用 federated token，不落盘任何密码。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;最小权限&quot;&gt;最小权限&lt;/h3&gt;
&lt;p&gt;认证和授权是两回事。这里的关键是给这个身份&lt;strong&gt;刚好够用的权限&lt;/strong&gt;——但“刚好够用”不是把权限压到最小，而是在安全和运维成本之间取一个平衡点。下面按“够用”和“更严”两档来看。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Vault 侧&lt;/strong&gt;：内置角色 &lt;code&gt;Key Vault Certificates Officer&lt;/code&gt; 已经贴近需要——它的 data actions 只有 &lt;code&gt;certificatecas/*&lt;/code&gt;、&lt;code&gt;certificates/*&lt;/code&gt;、&lt;code&gt;certificatecontacts/write&lt;/code&gt;，&lt;strong&gt;不包含 &lt;code&gt;secrets/*&lt;/code&gt;&lt;/strong&gt;，即便签发作业被攻破，它也读不到私钥的私密部分（读私钥要走 secret 接口，需要 &lt;code&gt;secrets/get&lt;/code&gt;）。日常直接用它就是合理的：签发流程和“读取私钥”本来就是两套权限。&lt;/p&gt;
&lt;p&gt;如果还想再收一点，可以自定义一个只保留证书读、创建、更新的角色——内置角色除了这些，还包含删除、清除、备份、管理联系人等：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;Name&quot;: &quot;Key Vault Certificate Issuer&quot;,
  &quot;Description&quot;: &quot;仅供外部 CA 证书的创建与合并使用&quot;,
  &quot;Actions&quot;: [],
  &quot;DataActions&quot;: [
    &quot;Microsoft.KeyVault/vaults/certificates/read&quot;,
    &quot;Microsoft.KeyVault/vaults/certificates/create/action&quot;,
    &quot;Microsoft.KeyVault/vaults/certificates/update/action&quot;,
    &quot;Microsoft.KeyVault/vaults/certificates/delete&quot;
  ],
  &quot;NotDataActions&quot;: [],
  &quot;AssignableScopes&quot;: [&quot;/subscriptions/&amp;#x3C;SUBSCRIPTION_ID&gt;&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;DNS 侧&lt;/strong&gt;：这里才是真正要权衡的地方，有三档，宽松程度递减、运维成本递增：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;DNS Zone Contributor&lt;/code&gt;（最省事，也最宽）&lt;/strong&gt;：它能管理整个区的&lt;strong&gt;所有&lt;/strong&gt;记录类型，等于把域名解析交了出去。签发身份一旦出问题，影响面最大。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只写 TXT 的自定义角色（折中）&lt;/strong&gt;：DNS-01 只在一个名字下增删 TXT，所以可以只给 &lt;code&gt;dnsZones/TXT/*&lt;/code&gt;：&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;Name&quot;: &quot;ACME DNS TXT Contributor&quot;,
  &quot;Description&quot;: &quot;仅供 ACME DNS-01 挑战增删 TXT 记录&quot;,
  &quot;Actions&quot;: [
    &quot;Microsoft.Network/dnsZones/read&quot;,
    &quot;Microsoft.Network/dnsZones/TXT/read&quot;,
    &quot;Microsoft.Network/dnsZones/TXT/write&quot;,
    &quot;Microsoft.Network/dnsZones/TXT/delete&quot;
  ],
  &quot;NotActions&quot;: [],
  &quot;DataActions&quot;: [],
  &quot;NotDataActions&quot;: [],
  &quot;AssignableScopes&quot;: [&quot;/subscriptions/&amp;#x3C;SUBSCRIPTION_ID&gt;&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但要注意 &lt;strong&gt;TXT 记录本身也很敏感&lt;/strong&gt;：SPF、DMARC、DKIM、站点验证令牌都是 TXT。拿到主区的 TXT 写权限，就可能在邮件安全上做手脚。&lt;/p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;把 &lt;code&gt;_acme-challenge&lt;/code&gt; 委派到独立的挑战区（最严）&lt;/strong&gt;：在主区里为每个要签发的域名放一条 CNAME，指向一个单独的挑战区，只给这个身份写那个区的权限。这样它连主区的记录都碰不到——这条思路也符合 Let’s Encrypt 官方“使用更细粒度的凭据、在独立服务器上做验证”的建议。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;第 3 档的代价是&lt;strong&gt;每条 CNAME 只覆盖一个主机名&lt;/strong&gt;：子域名越多，主区里的 CNAME 就越多。有两点可以让它划算起来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;改用通配符证书&lt;/strong&gt;：签 &lt;code&gt;*.example.com&lt;/code&gt; 时，DNS-01 只用 &lt;code&gt;_acme-challenge.example.com&lt;/code&gt; 一个名字，一条 CNAME 就够，与子域名的数量无关。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用 IaC 管理这些 CNAME&lt;/strong&gt;：写进 Pulumi / Terraform，新增子域名时随资源一起创建，而不是手工维护。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以它算不上过度设计（over-engineering），关键看取舍：子域名不多、或者能上通配符，第 3 档很划算；子域名成百上千、又只能逐个签发，第 2 档更实际。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;别忘了 CAA 记录&lt;/strong&gt;：如果域名的 DNS 里配置了 CAA，需要确认它允许 &lt;code&gt;letsencrypt.org&lt;/code&gt; 签发，否则请求会被拒绝。&lt;/p&gt;
&lt;h3 id=&quot;网络与审计&quot;&gt;网络与审计&lt;/h3&gt;
&lt;p&gt;企业环境的 Key Vault 通常应该关闭公网访问，用 &lt;strong&gt;Private Endpoint&lt;/strong&gt; 走 Azure 私有链接，只允许签发作业所在的网络访问。同时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;打开 &lt;strong&gt;软删除（soft-delete）与清除保护（purge protection）&lt;/strong&gt;，避免误删导致私钥永久丢失。&lt;/li&gt;
&lt;li&gt;把 Key Vault 的诊断日志送到 Log Analytics，记录每一次证书的创建与合并，作为审计证据。&lt;/li&gt;
&lt;li&gt;订阅 &lt;strong&gt;Event Grid&lt;/strong&gt; 的 &lt;code&gt;Microsoft.KeyVault.CertificateNearExpiry&lt;/code&gt;、&lt;code&gt;Microsoft.KeyVault.CertificateExpired&lt;/code&gt;、&lt;code&gt;Microsoft.KeyVault.CertificateNewVersionCreated&lt;/code&gt; 事件，用它们做到期提醒和新版本通知。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;acme-账户&quot;&gt;ACME 账户&lt;/h3&gt;
&lt;p&gt;先澄清一个常见的误解：即使加上 &lt;code&gt;--register-unsafely-without-email&lt;/code&gt;（也就是常说的“匿名”注册），Certbot 仍然会在 Let’s Encrypt 注册一个 ACME 账户——ACME 协议本身就要求有账户，不存在真正匿名的签发。这个参数只是不提供邮箱联系方式，代价是收不到到期和吊销通知。&lt;/p&gt;
&lt;p&gt;账户对应的是一对密钥，默认放在 &lt;code&gt;--config-dir/accounts/&lt;/code&gt; 下，它是一份敏感凭据，能以你的账户身份申请和吊销证书。所以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不要每次运行都重新注册一个账户——否则既浪费 Let’s Encrypt 的注册额度（每个 IP 每 3 小时最多 10 个新账户），又丢掉了账户连续性。&lt;/li&gt;
&lt;li&gt;用一个&lt;strong&gt;持久化的 &lt;code&gt;--config-dir&lt;/code&gt;&lt;/strong&gt; 保存账户，不要每次运行都删掉整个目录；生产环境里放在受控的持久卷上，并像对待密钥一样保护它。&lt;/li&gt;
&lt;li&gt;邮箱是可选填的，但建议提供（&lt;code&gt;--email&lt;/code&gt;），这样能收到到期和吊销通知。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;在 GitHub Actions 这类临时运行器上怎么办？&lt;/strong&gt; GitHub 托管的 runner 是临时的，没有持久卷，“持久化 &lt;code&gt;--config-dir&lt;/code&gt;”就落不了地。思路是把账户存到运行器&lt;strong&gt;之外&lt;/strong&gt;，每次运行再取回来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一次性注册好账户，把 &lt;code&gt;--config-dir/accounts/&lt;/code&gt; 整个目录打包（&lt;code&gt;tar&lt;/code&gt; + &lt;code&gt;base64&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;把它存进 GitHub Actions secret，或者存进 Azure Key Vault。&lt;/li&gt;
&lt;li&gt;每次 job 开始时解压回 &lt;code&gt;--config-dir&lt;/code&gt;，Certbot 就会复用它，不再注册新账户。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;用 GitHub secret 最省事（账户目录只有几 KB，远小于 secret 的大小上限）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Restore ACME account
  run: |
    mkdir -p &quot;${STATE_DIR}/config&quot;
    printf &apos;%s&apos; &quot;${{ secrets.ACME_ACCOUNT }}&quot; \
      | base64 -d | tar xzf - -C &quot;${STATE_DIR}/config&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首次注册和导出可以放在一次性 job 或本地做：&lt;code&gt;tar czf - -C &quot;${STATE_DIR}/config&quot; accounts | base64&lt;/code&gt;，再把输出写进 secret。&lt;/p&gt;
&lt;p&gt;有一个细节：workflow 默认&lt;strong&gt;没有&lt;/strong&gt;写 secret 的权限（&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; 不能改 secret），所以如果希望 CI 能自动把新账户存回去，用 Key Vault 更顺——但别破坏前面“签发身份读不到私钥”的设计：把这个 ACME 账户密钥放进&lt;strong&gt;独立的 vault&lt;/strong&gt;，或给它&lt;strong&gt;按 secret 作用域&lt;/strong&gt;的角色分配，只授予这一条 secret 的 &lt;code&gt;get&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;另外，不要用 &lt;code&gt;actions/cache&lt;/code&gt; 存账户：cache 不是给敏感数据用的（GitHub 本身也不建议往里面放密钥），而且随时可能被清理。&lt;/p&gt;
&lt;h3 id=&quot;工具与-dns-验证方式&quot;&gt;工具与 DNS 验证方式&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install certbot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Certbot 自带的 &lt;code&gt;--manual&lt;/code&gt; 模式，配合 &lt;code&gt;--manual-auth-hook&lt;/code&gt; / &lt;code&gt;--manual-cleanup-hook&lt;/code&gt; 就能完成 DNS-01 挑战，不需要额外的插件。本文用这种方式：验证逻辑拿 Azure CLI 自己写，&lt;strong&gt;不引入第三方依赖&lt;/strong&gt;——在需要审计、追求依赖最小化的场合，这比省下来的那点代码更重要。&lt;/p&gt;
&lt;p&gt;代价是两个 hook 要自己负责两件事：在 &lt;code&gt;_acme-challenge&lt;/code&gt; 下增删 TXT 记录，以及&lt;strong&gt;等待 DNS 传播&lt;/strong&gt;（&lt;code&gt;--manual&lt;/code&gt; 不会替你等）。&lt;/p&gt;
&lt;p&gt;如果不在意第三方依赖，也有现成的 &lt;code&gt;certbot-dns-azure&lt;/code&gt; 插件，它内部同样是通过 Azure API 增删记录，并且会自己等待传播：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install certbot-dns-azure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它支持托管标识、工作负载身份、CLI 凭据等多种认证方式，都不用把密码写进配置。两种方式二选一即可，下文用前者（manual hook）。&lt;/p&gt;
&lt;h2 id=&quot;完整实操&quot;&gt;完整实操&lt;/h2&gt;
&lt;p&gt;下面用一个目标域名 &lt;code&gt;app.example.com&lt;/code&gt; 走一遍。命令本身都不长，关键在每一步的意图；理解了流程，再按自己的环境把变量填好即可。&lt;/p&gt;
&lt;h3 id=&quot;创建-pending-证书csr&quot;&gt;创建 pending 证书（CSR）&lt;/h3&gt;
&lt;p&gt;用一份证书策略在 Key Vault 里创建证书，&lt;strong&gt;签发者设为 &lt;code&gt;Unknown&lt;/code&gt;&lt;/strong&gt;，表示“由外部 CA 签名”。Key Vault 会生成密钥对，并返回一个 CSR——注意它是 base64 编码的 DER、没有 PEM 头，需要自己补上 &lt;code&gt;-----BEGIN CERTIFICATE REQUEST-----&lt;/code&gt; / &lt;code&gt;-----END CERTIFICATE REQUEST-----&lt;/code&gt; 再落成 &lt;code&gt;.pem&lt;/code&gt; 文件。&lt;/p&gt;
&lt;p&gt;策略里几个要点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;keyProperties.exportable&lt;/code&gt;：私钥是否可导出（前文讨论过的取舍）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;keyType&lt;/code&gt; / &lt;code&gt;keySize&lt;/code&gt;：&lt;code&gt;RSA&lt;/code&gt; 或 &lt;code&gt;EC&lt;/code&gt; 及其长度；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reuseKey: false&lt;/code&gt;：每次续期生成新密钥，满足密钥轮换；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;subject&lt;/code&gt; 与 &lt;code&gt;subjectAlternativeNames.dnsNames&lt;/code&gt;：把域名放进 SAN（现代 TLS 客户端只看 SAN，不看 CN）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;validityInMonths&lt;/code&gt;：只是信息性的，真正的有效期由 CA 决定。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一份最小的策略文件（&lt;code&gt;policy.json&lt;/code&gt;）长这样，把域名和 &lt;code&gt;exportable&lt;/code&gt; 按需要改即可：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;issuerParameters&quot;: { &quot;name&quot;: &quot;Unknown&quot; },
  &quot;keyProperties&quot;: {
    &quot;exportable&quot;: false,
    &quot;keyType&quot;: &quot;RSA&quot;,
    &quot;keySize&quot;: 2048,
    &quot;reuseKey&quot;: false
  },
  &quot;secretProperties&quot;: { &quot;contentType&quot;: &quot;application/x-pkcs12&quot; },
  &quot;x509CertificateProperties&quot;: {
    &quot;subject&quot;: &quot;CN=app.example.com&quot;,
    &quot;subjectAlternativeNames&quot;: { &quot;dnsNames&quot;: [&quot;app.example.com&quot;] },
    &quot;keyUsage&quot;: [&quot;digitalSignature&quot;, &quot;keyEncipherment&quot;],
    &quot;validityInMonths&quot;: 3
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用它创建证书并取回 CSR：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az keyvault certificate create \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; \
  --name &quot;${KEY_VAULT_CERT_NAME}&quot; \
  --policy @policy.json \
  --query csr -o tsv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果这个证书名上还留着上次没走完的 pending 操作，&lt;code&gt;az keyvault certificate pending show&lt;/code&gt; 能看出来；要么先排查，要么清掉再重来：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 查看遗留的 pending 操作
az keyvault certificate pending show \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; \
  --name &quot;${KEY_VAULT_CERT_NAME}&quot;

# 排查后确认可以丢弃，再删除
az keyvault certificate pending delete \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; \
  --name &quot;${KEY_VAULT_CERT_NAME}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;用-certbot-对-csr-签名&quot;&gt;用 Certbot 对 CSR 签名&lt;/h3&gt;
&lt;p&gt;把 CSR 交给 Certbot。域名来自 CSR，所以&lt;strong&gt;不要再传 &lt;code&gt;-d&lt;/code&gt;&lt;/strong&gt;；Certbot 全程看不到私钥：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;certbot certonly \
  --non-interactive --agree-tos \
  --email &quot;${ACME_EMAIL}&quot; \
  --staging \
  --csr request.csr.pem \
  --manual --preferred-challenges dns \
  --manual-auth-hook ./azure-auth.sh \
  --manual-cleanup-hook ./azure-cleanup.sh \
  --cert-path cert.pem \
  --chain-path chain.pem \
  --fullchain-path fullchain.pem \
  --config-dir ./letsencrypt/config \
  --work-dir ./letsencrypt/work \
  --logs-dir ./letsencrypt/logs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上面的命令带 &lt;code&gt;--staging&lt;/code&gt;，也就是 &lt;code&gt;--test-cert&lt;/code&gt; 的别名：它切到 Let’s Encrypt 的测试环境，签发的证书不受信任、只用于跑通流程，也不会消耗生产额度。对应地，证书名要带 &lt;code&gt;-staging&lt;/code&gt; 后缀（见「Staging 环境与速率限制」）。&lt;strong&gt;确认流程无误后，去掉 &lt;code&gt;--staging&lt;/code&gt; 再签发生产证书。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不想提供邮箱就把它换成 &lt;code&gt;--register-unsafely-without-email&lt;/code&gt;（原因见「ACME 账户」一节）。&lt;code&gt;--config-dir&lt;/code&gt; 指向一个持久化目录来保存 ACME 账户；&lt;code&gt;--manual-auth-hook&lt;/code&gt; / &lt;code&gt;--manual-cleanup-hook&lt;/code&gt; 指到下面写的 DNS 脚本。&lt;/p&gt;
&lt;h3 id=&quot;合并证书回-key-vault&quot;&gt;合并证书回 Key Vault&lt;/h3&gt;
&lt;p&gt;Certbot 产出的 &lt;code&gt;fullchain.pem&lt;/code&gt; 顺序是“叶子在前、中间证书在后、不含根”。把它合并进上一步的 pending 证书：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az keyvault certificate pending merge \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; \
  --name &quot;${KEY_VAULT_CERT_NAME}&quot; \
  --file fullchain.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key Vault 会把它和自己保管的私钥配对，完成证书创建。&lt;/p&gt;
&lt;h3 id=&quot;校验&quot;&gt;校验&lt;/h3&gt;
&lt;p&gt;合并后要确认“库里这张证书”和“刚签发的结果”是同一张：用 &lt;code&gt;az keyvault certificate show&lt;/code&gt; 取回指纹，和本地 &lt;code&gt;openssl x509 -fingerprint -sha1&lt;/code&gt; 算出的对比，不一致就说明配错了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;MERGED=$(az keyvault certificate show \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; --name &quot;${KEY_VAULT_CERT_NAME}&quot; \
  --query x509ThumbprintHex -o tsv)
LEAF=$(openssl x509 -in fullchain.pem -noout -fingerprint -sha1 | cut -d= -f2 | tr -d &apos;:&apos;)
[ &quot;${MERGED^^}&quot; = &quot;${LEAF^^}&quot; ] || { echo &quot;指纹不一致&quot;; exit 1; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;dns-验证-hook&quot;&gt;DNS 验证 hook&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;--manual&lt;/code&gt; 模式下，Certbot 会为每个待验证的域名调用一次 auth hook、验证后再调用 cleanup hook，并通过环境变量 &lt;code&gt;CERTBOT_DOMAIN&lt;/code&gt;（正在验证的域名）与 &lt;code&gt;CERTBOT_VALIDATION&lt;/code&gt;（要写进 TXT 的值）传参。本文用两个小脚本承接——&lt;code&gt;./azure-auth.sh&lt;/code&gt; 负责写入，&lt;code&gt;./azure-cleanup.sh&lt;/code&gt; 负责清理。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;./azure-auth.sh&lt;/code&gt;：验证前写入 TXT 并等待传播。&lt;/strong&gt; 它先把域名换算成相对 DNS 区域的记录名（&lt;code&gt;app.example.com&lt;/code&gt; → &lt;code&gt;_acme-challenge.app&lt;/code&gt;，apex 则是 &lt;code&gt;_acme-challenge&lt;/code&gt;），核心就是一条 &lt;code&gt;az&lt;/code&gt; 命令：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az network dns record-set txt add-record \
  --resource-group &quot;${DNS_RESOURCE_GROUP}&quot; --zone-name &quot;${DNS_ZONE_NAME}&quot; \
  --record-set-name &quot;${RECORD_NAME}&quot; --value &quot;${CERTBOT_VALIDATION}&quot; -o none
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写完还要&lt;strong&gt;轮询公共递归 DNS&lt;/strong&gt; 等待生效——&lt;code&gt;--manual&lt;/code&gt; 不会替你等传播。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;./azure-cleanup.sh&lt;/code&gt;：验证后只删自己写入的值。&lt;/strong&gt; 用 &lt;code&gt;--value&lt;/code&gt; 精确删除很重要：apex 和通配符（&lt;code&gt;example.com&lt;/code&gt; 与 &lt;code&gt;*.example.com&lt;/code&gt;）会共用同一个 &lt;code&gt;_acme-challenge&lt;/code&gt;，删整条会把别的挑战值一起删掉。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az network dns record-set txt remove-record \
  --resource-group &quot;${DNS_RESOURCE_GROUP}&quot; --zone-name &quot;${DNS_ZONE_NAME}&quot; \
  --record-set-name &quot;${RECORD_NAME}&quot; --value &quot;${CERTBOT_VALIDATION}&quot; -o none
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个脚本的完整写法（含环境变量校验、记录名换算、&lt;code&gt;for&lt;/code&gt; 轮询等待）如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# azure-auth.sh —— 写入 _acme-challenge 的 TXT 并等待传播
set -euo pipefail

: &quot;${CERTBOT_DOMAIN:?Certbot 注入}&quot;
: &quot;${CERTBOT_VALIDATION:?Certbot 注入}&quot;
: &quot;${DNS_ZONE_NAME:?}&quot;
: &quot;${DNS_RESOURCE_GROUP:?}&quot;

# 记录名相对区域：app.example.com -&gt; _acme-challenge.app
if [ &quot;${CERTBOT_DOMAIN}&quot; = &quot;${DNS_ZONE_NAME}&quot; ]; then
  RECORD_NAME=&quot;_acme-challenge&quot;
else
  RECORD_NAME=&quot;_acme-challenge.${CERTBOT_DOMAIN%.${DNS_ZONE_NAME}}&quot;
fi

az network dns record-set txt add-record \
  --resource-group &quot;${DNS_RESOURCE_GROUP}&quot; --zone-name &quot;${DNS_ZONE_NAME}&quot; \
  --record-set-name &quot;${RECORD_NAME}&quot; --value &quot;${CERTBOT_VALIDATION}&quot; -o none

# --manual 不会替你等传播，这里轮询公共递归 DNS（dig 来自 dnsutils / bind-utils）
for _ in $(seq 1 30); do
  if dig +short TXT &quot;${RECORD_NAME}.${DNS_ZONE_NAME}&quot; @8.8.8.8 \
       | grep -qF &quot;${CERTBOT_VALIDATION}&quot;; then
    exit 0
  fi
  sleep 10
done
echo &quot;ERROR: TXT 记录 ${RECORD_NAME}.${DNS_ZONE_NAME} 未生效&quot; &gt;&amp;#x26;2
exit 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;cleanup hook 只删自己写入的值：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# azure-cleanup.sh —— 只删除本次写入的那个 TXT 值
set -euo pipefail

: &quot;${CERTBOT_DOMAIN:?}&quot;
: &quot;${CERTBOT_VALIDATION:?}&quot;
: &quot;${DNS_ZONE_NAME:?}&quot;
: &quot;${DNS_RESOURCE_GROUP:?}&quot;

if [ &quot;${CERTBOT_DOMAIN}&quot; = &quot;${DNS_ZONE_NAME}&quot; ]; then
  RECORD_NAME=&quot;_acme-challenge&quot;
else
  RECORD_NAME=&quot;_acme-challenge.${CERTBOT_DOMAIN%.${DNS_ZONE_NAME}}&quot;
fi

# apex 与通配符会共用同一个 _acme-challenge，删整条会误伤，所以只删自己写的值
az network dns record-set txt remove-record \
  --resource-group &quot;${DNS_RESOURCE_GROUP}&quot; --zone-name &quot;${DNS_ZONE_NAME}&quot; \
  --record-set-name &quot;${RECORD_NAME}&quot; --value &quot;${CERTBOT_VALIDATION}&quot; -o none || true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关于 cleanup 的一个坑&lt;/strong&gt;：Certbot 会在验证之后调用 cleanup hook，但如果 certbot 进程在调用它之前就异常退出（崩溃、被 &lt;code&gt;kill&lt;/code&gt;，或中途某一步报错），这个 hook 可能不会执行，TXT 记录就留在 DNS 里了。好在残留的 TXT 记录本身无害——下次验证会写入新值，Let’s Encrypt 只匹配本次的预期值。如果在意整洁，可以再挂一个定时任务巡检并清理陈旧的 &lt;code&gt;_acme-challenge&lt;/code&gt; 记录。&lt;/p&gt;
&lt;h3 id=&quot;工程细节&quot;&gt;工程细节&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;staging 隔离&lt;/strong&gt;：用 staging 时把证书名加上 &lt;code&gt;-staging&lt;/code&gt; 后缀并做强制校验，避免不受信任的测试证书被合并进生产证书的版本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;并发保护&lt;/strong&gt;：用 &lt;code&gt;flock&lt;/code&gt; 对同一个证书加锁，避免两次运行同时操作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;幂等&lt;/strong&gt;：签发前先检查有没有遗留的 pending 操作；auth hook 用“追加”而非“覆盖”的方式写 TXT。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自校验&lt;/strong&gt;：合并后按第 4 步比对指纹，失败就报错，别让它悄悄过去。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;运行顺序&quot;&gt;运行顺序&lt;/h3&gt;
&lt;p&gt;先设置环境变量（目标域名、Key Vault 名称与证书名、DNS 区域与资源组），然后先打开 staging 跑一遍完整流程；确认无误后，再切回生产证书名跑一遍。另外，不要每次运行都重新注册 ACME 账户，账户的持久化见「ACME 账户」一节。&lt;/p&gt;
&lt;h2 id=&quot;staging-环境与速率限制&quot;&gt;Staging 环境与速率限制&lt;/h2&gt;
&lt;p&gt;Let’s Encrypt 的证书签发有速率限制，调试阶段最容易撞上的几条是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;相同标识符集（exact set of identifiers）&lt;/strong&gt;：每 7 天最多 5 张。反复用同一组域名申请，第 6 次就会被拒。这是&lt;strong&gt;全局限制&lt;/strong&gt;，所有账户的请求都计入，而且吊销证书并不会返还额度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一注册域名&lt;/strong&gt;：每 7 天最多 50 张，同样是全局限制。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每账户新订单&lt;/strong&gt;：每 3 小时最多 300 个。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这几条限制设计得很不友好的一点，恰好戳中本方案：&lt;strong&gt;每次调用 &lt;code&gt;az keyvault certificate create&lt;/code&gt; 都会开启一个新的订单&lt;/strong&gt;。如果合并失败、又反复重试，额度会很快被吃掉。所以正确的姿势是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用 staging 环境把整条链路跑通。staging 的额度比生产高得多（相同标识符集每周约 30000 张），签出来的证书不受信任，只能用于测试。&lt;/li&gt;
&lt;li&gt;staging 的证书&lt;strong&gt;必须&lt;/strong&gt;用一个带 &lt;code&gt;-staging&lt;/code&gt; 后缀的独立证书名，并在流程里强制校验，否则一旦有人把它合并进生产证书，线上 TLS 立刻失效。&lt;/li&gt;
&lt;li&gt;生产环境只在 staging 演练通过、且确实到达续期窗口时才运行。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;还有两个容易忽略的点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;--csr&lt;/code&gt; 模式拿不到 ARI 豁免。&lt;/strong&gt; 正常模式下，支持 ACME Renewal Info（ARI）的客户端续期可以豁免&lt;strong&gt;全部&lt;/strong&gt;速率限制；但 &lt;code&gt;--csr&lt;/code&gt; 只能配合 &lt;code&gt;certonly&lt;/code&gt; 子命令使用，谈不上由 Certbot 管理的续期谱系，所以这份优惠用不上。这也是为什么续期必须自己调度、并且要珍惜额度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;公信证书会进入证书透明度（CT）日志。&lt;/strong&gt; 签发的域名会被公开记录，内部敏感主机名不适合用公信证书。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;自动化续期&quot;&gt;自动化续期&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;--csr&lt;/code&gt; 模式不能用 &lt;code&gt;certbot renew&lt;/code&gt;，Key Vault 对非合作 CA（Let’s Encrypt 不是 Key Vault 的内置合作 CA）也不提供自动续期。所以必须自己写调度逻辑。&lt;/p&gt;
&lt;p&gt;具体做法是“先判断、再签发”：先用 &lt;code&gt;az keyvault certificate show&lt;/code&gt; 读出证书的到期时间，算出剩余天数；只有剩余低于阈值时才真正走一遍签发流程，否则直接跳过。这样既不会空跑，也避免无谓地消耗 Let’s Encrypt 的额度。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 读出到期时间，再自行换算剩余天数
az keyvault certificate show \
  --vault-name &quot;${KEY_VAULT_NAME}&quot; \
  --name &quot;${KEY_VAULT_CERT_NAME}&quot; \
  --query attributes.expires -o tsv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意，&lt;code&gt;--csr&lt;/code&gt; 模式下 Key Vault 策略里的 &lt;code&gt;validityInMonths&lt;/code&gt; 只是信息性的，真正的有效期由 CA 决定。而且这个有效期正在&lt;strong&gt;变短&lt;/strong&gt;：Let’s Encrypt 目前默认签发 90 天，但已明确会&lt;strong&gt;在 2028 年 2 月前缩短到 45 天&lt;/strong&gt;；6 天有效期的短命证书（160 小时）也已可用（选用 ACME 的 &lt;code&gt;shortlived&lt;/code&gt; 证书配置即可）。更大的背景是行业规则——CA/Browser Forum 的 SC-081 把公开 TLS 证书的最长有效期从 398 天逐步压到 200 天（2026-03-15）、100 天（2027-03-15）、47 天（2029-03-15）。变短的动因之一是安全：压缩误签与密钥泄露的有效窗口；但它同时“倒逼自动化”——有效期只有几十天时，任何手工续期都不可靠。&lt;/p&gt;
&lt;p&gt;对本文方案来说，这意味着续期要比过去更频繁、更可靠：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;续期必须做成定时任务并接入监控（cron、systemd timer、GitHub Actions 的 &lt;code&gt;schedule&lt;/code&gt;、Azure DevOps 的 scheduled pipeline 都可以），每天或每周检查一次。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--csr&lt;/code&gt; 模式拿不到 ARI 续期豁免（见上文），额度更经不起浪费，“只在到期窗口内签发”这个判断不能省。&lt;/li&gt;
&lt;li&gt;不要把阈值写死成 &lt;code&gt;RENEW_BEFORE_DAYS=30&lt;/code&gt;，按有效期比例来设更稳，例如剩余不足 1/3 就续期；证书变短时窗口会自动跟着变短。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;调度之外，还要做监控，否则“续期失败”可能直到证书过期才被发现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;续期任务失败要告警（流水线失败通知，或写入监控系统）。&lt;/li&gt;
&lt;li&gt;订阅 Key Vault 的 &lt;code&gt;CertificateNearExpiry&lt;/code&gt; / &lt;code&gt;CertificateExpired&lt;/code&gt; 事件作为第二道保险。&lt;/li&gt;
&lt;li&gt;签发作业的日志（Certbot 的 &lt;code&gt;--logs-dir&lt;/code&gt;）应被采集，用于审计与排障。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;故障排查&quot;&gt;故障排查&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;合并后证书与私钥不匹配？&lt;/strong&gt; 合并时的 &lt;code&gt;fullchain.pem&lt;/code&gt; 必须来自同一个 CSR 对应的签名结果，且叶子证书在前、中间证书在后、不要带根证书。上文第 4 步的指纹校验就是为了拦住这种错配。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;验证一直失败（DNS-01）？&lt;/strong&gt; 确认 &lt;code&gt;_acme-challenge.&amp;#x3C;域名&gt;&lt;/code&gt; 的 TXT 记录确实被创建、并且能被公网递归 DNS 解析到。本文的 auth hook 会轮询公共递归 DNS 等待传播，但前提是签发作业能访问公共解析——如果所在网络启用了私有 DNS 区域，它可能查不到刚刚写入的记录。别忘了 CAA 记录。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;卡在 pending 状态？&lt;/strong&gt; 说明证书已创建但始终没有合并。脚本默认会直接报错而不是悄悄删掉，就是为了让这种异常可见。排查后确认可以丢弃时，再用 &lt;code&gt;DELETE_PENDING=true&lt;/code&gt; 重跑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;撞上速率限制？&lt;/strong&gt; 看错误信息里的 &lt;code&gt;Retry-After&lt;/code&gt;，按提示时间等待即可，没有提前重置的办法。开发测试请改用 staging；生产环境要避免“每次部署都重新签发”，只按到期窗口续期。&lt;/p&gt;
&lt;h2 id=&quot;安全清单&quot;&gt;安全清单&lt;/h2&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;项目&lt;/th&gt;&lt;th&gt;做法&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;私钥保管&lt;/td&gt;&lt;td&gt;默认 &lt;code&gt;exportable: false&lt;/code&gt;；TLS 终止类消费端需 &lt;code&gt;true&lt;/code&gt;，由服务用托管标识取用；合规场景用 Managed HSM&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;密钥轮换&lt;/td&gt;&lt;td&gt;&lt;code&gt;reuseKey: false&lt;/code&gt;，每次续期生成新密钥&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;签发身份&lt;/td&gt;&lt;td&gt;UAI 或 OIDC 联合凭据，不用长期密钥&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Key Vault 权限&lt;/td&gt;&lt;td&gt;只给证书相关角色，不含 &lt;code&gt;secrets/*&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DNS 权限&lt;/td&gt;&lt;td&gt;自建只写 TXT 的角色，或用 CNAME 委派到独立挑战区&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;网络&lt;/td&gt;&lt;td&gt;Private Endpoint + 关闭公网访问&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;数据保护&lt;/td&gt;&lt;td&gt;开启软删除与清除保护&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;审计监控&lt;/td&gt;&lt;td&gt;诊断日志入 Log Analytics，订阅证书事件&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ACME 账户&lt;/td&gt;&lt;td&gt;用持久化的 &lt;code&gt;--config-dir&lt;/code&gt; 保存；邮箱可选但建议填&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;测试隔离&lt;/td&gt;&lt;td&gt;staging 用 &lt;code&gt;-staging&lt;/code&gt; 后缀的独立证书名&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;到期与续期&lt;/td&gt;&lt;td&gt;自带调度 + 失败告警，不依赖 Key Vault 自动续期&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;
&lt;p&gt;企业环境里最值钱、也最容易在自动化流程中被忽视的东西，是私钥。把私钥的生成与保管交给 Azure Key Vault、让 Certbot 退回到“ACME 客户端”的本分，再用最小权限和 staging 演练把整条链路管起来，就能在保留 Let’s Encrypt 免费与自动化的同时，满足企业最基本的安全与审计要求。剩下的取舍——私钥到底能不能导出、证书落在哪里——取决于谁来消费它，这需要在动手之前就想清楚。&lt;/p&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/docs/rate-limits/&quot;&gt;Let’s Encrypt Rate Limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/docs/staging-environment/&quot;&gt;Let’s Encrypt Staging Environment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/docs/challenge-types/&quot;&gt;Challenge Types (DNS-01)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/docs/cert-lifetimes/&quot;&gt;Let’s Encrypt: Certificate Lifetime Rationale and Plans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability&quot;&gt;Let’s Encrypt: 6-day and IP Address Certificates are Generally Available&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/&quot;&gt;CA/Browser Forum Ballot SC-081v3: Schedule of Reducing Validity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/key-vault/certificates/create-certificate&quot;&gt;Azure Key Vault: Certificate creation methods&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/key-vault/certificates/about-certificates&quot;&gt;Azure Key Vault: About certificates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide&quot;&gt;Grant permission to applications to access an Azure key vault using Azure RBAC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/application-gateway/key-vault-certs&quot;&gt;Application Gateway: Key Vault certificates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/end-to-end-tls&quot;&gt;Azure Front Door: End-to-end TLS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.certbot-dns-azure.co.uk/en/latest/&quot;&gt;certbot-dns-azure documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://eff-certbot.readthedocs.io/en/stable/using.html&quot;&gt;Certbot User Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Azure Front Door 配置路由指南</title><link>https://engineering.lishechuan.com/setup-routing-in-front-door/</link><guid isPermaLink="true">https://engineering.lishechuan.com/setup-routing-in-front-door/</guid><description>从自定义域名、托管证书到源组与 WAF，一步步走通一条 Front Door 路由，附 SNI 与 Host 的避坑提示。</description><pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Azure Front Door 的 Standard/Premium 里，一条能对外服务的路由，背后是几个资源按顺序拼起来的：自定义域名、源组与源、路由，必要时再加 WAF（Web Application Firewall，Web 应用防火墙）。顺序错了，或者某个字段理解偏了，就会出现「域名配好了却打不开」「健康探针一直 Unhealthy」之类的问题。&lt;/p&gt;
&lt;p&gt;本文按实际配置顺序把整条链路走一遍，希望能让刚接触 Front Door 的人照着做就能跑通。文中说的 Front Door 均指 Standard/Premium（classic 版本正在退役）。&lt;/p&gt;
&lt;h2 id=&quot;azure-上的几个入口服务&quot;&gt;Azure 上的几个入口服务&lt;/h2&gt;
&lt;p&gt;Azure 上有几个容易混淆的流量入口服务，定位差别很大：&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;服务&lt;/th&gt;&lt;th&gt;工作层次&lt;/th&gt;&lt;th&gt;范围&lt;/th&gt;&lt;th&gt;典型用途&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Azure Front Door&lt;/td&gt;&lt;td&gt;L7（HTTP/HTTPS）&lt;/td&gt;&lt;td&gt;&lt;strong&gt;全局&lt;/strong&gt;（location 就是 Global，接入点遍布各地）&lt;/td&gt;&lt;td&gt;全球入口：加速、缓存、TLS 卸载、WAF&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Application Gateway&lt;/td&gt;&lt;td&gt;L7（HTTP/HTTPS）&lt;/td&gt;&lt;td&gt;&lt;strong&gt;区域&lt;/strong&gt;（部署在某个区域 / VNet 里）&lt;/td&gt;&lt;td&gt;区域内的 L7 负载均衡，可带 WAF&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Azure Load Balancer&lt;/td&gt;&lt;td&gt;L4（TCP/UDP）&lt;/td&gt;&lt;td&gt;区域&lt;/td&gt;&lt;td&gt;四层负载均衡&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Azure Traffic Manager&lt;/td&gt;&lt;td&gt;DNS&lt;/td&gt;&lt;td&gt;全局&lt;/td&gt;&lt;td&gt;基于 DNS 的流量调度&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;关键区别在&lt;strong&gt;范围&lt;/strong&gt;：Front Door 是全局服务（它的 location 就叫 Global），用户就近接入边缘节点；Application Gateway 是区域服务，部署在具体的 VNet 里。所以两者定位不同，也常常配合使用——Front Door 放最外层做全球入口，Application Gateway 放在区域内的入口。（顺带一提，Azure CDN from Microsoft (classic) 正在退役，其缓存能力已并入 Front Door。）&lt;/p&gt;
&lt;h2 id=&quot;先认识几个资源&quot;&gt;先认识几个资源&lt;/h2&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;资源&lt;/th&gt;&lt;th&gt;作用&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Profile&lt;/td&gt;&lt;td&gt;Front Door 本体，也是日志、安全策略的作用域&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Endpoint&lt;/td&gt;&lt;td&gt;每个 endpoint 都有一个默认域名 &lt;code&gt;xxx.z01.azurefd.net&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Custom domain&lt;/td&gt;&lt;td&gt;对外的自定义域名（含通配符）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Origin group / Origin&lt;/td&gt;&lt;td&gt;源组和源，即后端服务&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Route&lt;/td&gt;&lt;td&gt;把自定义域名和源组连起来&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Rule set / Rule&lt;/td&gt;&lt;td&gt;规则集与规则：匹配条件 + 动作，挂在路由上（URL 重写、改请求/响应头、缓存覆盖等）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Security policy&lt;/td&gt;&lt;td&gt;把自定义域名和 WAF policy 连起来&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;依赖关系上，通常是：先把域名建好并验证，再建源组和源，然后建路由把两者连起来，最后按需挂规则集、接 WAF。&lt;/p&gt;
&lt;h2 id=&quot;第-1-步创建自定义域名&quot;&gt;第 1 步：创建自定义域名&lt;/h2&gt;
&lt;p&gt;Front Door 支持三种自定义域名：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;类型&lt;/th&gt;&lt;th&gt;示例&lt;/th&gt;&lt;th&gt;典型用途&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;子域名&lt;/td&gt;&lt;td&gt;&lt;code&gt;www.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;td&gt;最常见&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;顶级域名（apex）&lt;/td&gt;&lt;td&gt;&lt;code&gt;contoso.com&lt;/code&gt;&lt;/td&gt;&lt;td&gt;裸域名&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;通配符&lt;/td&gt;&lt;td&gt;&lt;code&gt;*.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;td&gt;多租户 / SaaS，一个通配符覆盖所有一级子域名&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;通配符最省事：配好一条路由，&lt;code&gt;customer1.contoso.com&lt;/code&gt;、&lt;code&gt;customer2.contoso.com&lt;/code&gt; 这类一级子域名就都被覆盖了。新增租户时，只要在 DNS 里加一条 CNAME 指过来即可——完全不用碰 Front Door，也不用逐个签发证书。换句话说，以后新增域名只动 DNS，不动 Front Door。&lt;/p&gt;
&lt;p&gt;关于域名，有一点要特别提醒：&lt;strong&gt;一个自定义域名同一时间只能关联一个 endpoint&lt;/strong&gt;。不同 profile 可以用同一个域名的不同子域名，但不能用同一个完整域名。&lt;/p&gt;
&lt;p&gt;这一点比较坑，值得展开说：它意味着把一个已经上线的域名从 profile A 挪到 profile B，只能先把它从 A 删掉、再加到 B，这中间必然有中断，因为 Front Door 不支持在 B 里先把同一个域名配好。官方给的两条「无中断」路子是——临时把流量切到源站或另一个 CDN 做中转，或者走 tier 升级（classic → Standard/Premium、Standard → Premium）。再叠加下面要说的配置传播时间，这个窗口会比预想的更长，迁移前记得留够维护窗口。同样的坑在从 Azure CDN 迁过来时也会遇到，可参考 &lt;a href=&quot;https://engineering.lishechuan.com/migrate-front-door-from-azure-cdn/&quot;&gt;《迁移 Azure CDN (Classic) 到 Azure Front Door》&lt;/a&gt;。&lt;/p&gt;
&lt;h2 id=&quot;第-2-步用-_dnsauth-的-txt-记录验证所有权&quot;&gt;第 2 步：用 _dnsauth 的 TXT 记录验证所有权&lt;/h2&gt;
&lt;p&gt;域名所有权验证是必须做的。默认用 &lt;code&gt;_dnsauth&lt;/code&gt; 的 TXT 记录完成，这也是选择 Azure 托管证书时的固定方式；如果自带证书（BYOC）且证书的 CN/SAN 与域名匹配，所有权会自动通过，就不需要再加 TXT（不匹配时仍然要加）。&lt;/p&gt;
&lt;p&gt;添加域名时，Front Door 会给出一个验证令牌（validation token），需要在 DNS 里建一条 TXT 记录来证明域名归属。&lt;/p&gt;
&lt;p&gt;TXT 记录名的规律是 &lt;code&gt;_dnsauth.&lt;/code&gt; 加上去掉开头 &lt;code&gt;*.&lt;/code&gt; 的域名，最终以门户给出的记录名为准：&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;自定义域名&lt;/th&gt;&lt;th&gt;TXT 记录名（FQDN）&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;www.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;_dnsauth.www.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;contoso.com&lt;/code&gt;（apex）&lt;/td&gt;&lt;td&gt;&lt;code&gt;_dnsauth.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;*.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;_dnsauth.contoso.com&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;记录值就是门户上显示的 token。验证状态会经历 Submitting → Pending → Approved，通常几分钟内完成。如果一直不通过，检查记录名和值是否完全一致、TTL 是否过大；验证用的 TXT 记录有一定时效，失败时可以重新生成一个。&lt;/p&gt;
&lt;p&gt;验证通过后，就可以给域名启用 HTTPS 了，两种方式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Azure 托管证书&lt;/strong&gt;：免费，Front Door 负责签发和部署，推荐。选择它时，所有权继续用上面的 TXT 记录验证。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自带证书（BYOC）&lt;/strong&gt;：证书放在 Key Vault，记得给 Front Door 的托管标识授予 Key Vault 的读取权限。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;托管证书会自动轮换，但轮换时是否需要重新验证域名所有权，取决于 Azure 当前的最新策略，不同域名类型可能不同：&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;域名类型&lt;/th&gt;&lt;th&gt;是否支持托管证书&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;子域名&lt;/td&gt;&lt;td&gt;支持&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;apex（顶级域名）&lt;/td&gt;&lt;td&gt;支持&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;通配符&lt;/td&gt;&lt;td&gt;支持&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;无论哪种域名，轮换时都有可能进入 Pending revalidation，此时需要重新生成并更新 &lt;code&gt;_dnsauth&lt;/code&gt; 的 TXT 记录。保险的做法是关注域名的验证状态，看到 Pending revalidation 就及时处理，别等到证书到期。&lt;/p&gt;
&lt;p&gt;最后，还要把域名的 CNAME 指向 Front Door 的 endpoint 域名，流量才会过来。apex 域名不支持 CNAME，需要用 Azure DNS 的 alias 记录，或提供商的 CNAME flattening。&lt;/p&gt;
&lt;h2 id=&quot;第-3-步创建源组和源&quot;&gt;第 3 步：创建源组和源&lt;/h2&gt;
&lt;h3 id=&quot;先分清-snihost-和-x-forwarded-host&quot;&gt;先分清 SNI、Host 和 X-Forwarded-Host&lt;/h3&gt;
&lt;p&gt;这是最容易踩坑的地方。Front Door 到源之间，有三个不同的概念：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;名称&lt;/th&gt;&lt;th&gt;出现在哪&lt;/th&gt;&lt;th&gt;由谁决定&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;SNI&lt;/td&gt;&lt;td&gt;TLS 握手&lt;/td&gt;&lt;td&gt;源的 &lt;strong&gt;Host name&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Host 头&lt;/td&gt;&lt;td&gt;HTTP 请求&lt;/td&gt;&lt;td&gt;源的 &lt;strong&gt;Origin host header&lt;/strong&gt;；留空则透传客户端请求的原始 Host&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;X-Forwarded-Host&lt;/td&gt;&lt;td&gt;HTTP 请求&lt;/td&gt;&lt;td&gt;Front Door 自动添加，值 = 客户端请求的原始 Host（会覆盖已有值）&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;要点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Front Door 与源建立 TLS 连接时，SNI 用的是源的 Host name，而不是客户端请求的域名。&lt;/li&gt;
&lt;li&gt;转发给源的 Host 头由 Origin host header 决定；把它留空，Front Door 就会把客户端请求的原始 Host 透传下去，这就是所谓的 Host 名保留。&lt;/li&gt;
&lt;li&gt;无论 Host 头是什么，源都能从 X-Forwarded-Host 拿到客户端最初请求的域名。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;由此有个常见结论：如果源要求 Host 头与证书一致（比如 App Service、Blob Storage），就把 Origin host header 设成源主机名；如果源依赖原始域名做路由，就把它留空。&lt;/p&gt;
&lt;p&gt;还有一个开关：&lt;strong&gt;证书主体名称校验（Certificate subject name validation）&lt;/strong&gt;，默认开启。开启时，源返回的证书的 CN/SAN 必须匹配源的 Host name，否则连接会被拒绝。启用 Private Link 时这个校验是必须的。&lt;/p&gt;
&lt;h3 id=&quot;当-sni-与-host-不同时&quot;&gt;当 SNI 与 Host 不同时&lt;/h3&gt;
&lt;p&gt;有时候需要让 SNI 与 Host 是两回事：用 A 域名（源主机名）和源的证书建立 TLS，再用 B 域名（面向用户的域名）做路由。这时源必须同时接受两者——用 SNI 选证书，用 Host（或 X-Forwarded-Host）匹配路由。&lt;/p&gt;
&lt;p&gt;这在 SaaS 场景里很常见：租户带着自己的域名（B）接进来，证书由 Front Door 统一管理，源站则用内部域名（A）和内部证书接住 Front Door 的 TLS，再靠 Host 头把请求分给对应的租户。&lt;/p&gt;
&lt;p&gt;Kubernetes 的 Gateway API 是个典型例子。默认它要求 TLS 的 SNI 与 HTTP 的 Host 一致，或至少有交集，否则返回 404。解决办法是在 Gateway 上不设置 hostname、只配置 TLS 证书，把域名匹配交给 HTTPRoute 的 hostnames。这样一来，即使 Front Door 用源的 Host name 作为 SNI 建连、而 Host 头是另一个域名，也能正常路由。具体可参考 &lt;a href=&quot;https://engineering.lishechuan.com/single-cert-for-multiple-hostnames-in-k8s-gateway-api/&quot;&gt;《在 K8s Gateway API 中为多个 Hostname 使用单个证书》&lt;/a&gt; 和 &lt;a href=&quot;https://engineering.lishechuan.com/migrate-ingress-nginx-to-gateway-api-on-azure-kubenetes-service/&quot;&gt;《将 Ingress NGINX 迁移到 Azure Kubernetes Service 上的 Gateway API》&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;配好后，可以在本地直接验证源是否接受 SNI 与 Host 不一致：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 用一个 SNI 建 TLS，检查证书是否匹配源的 Host name
openssl s_client -connect &amp;#x3C;origin-host&gt;:443 -servername &amp;#x3C;origin-host-name&gt;

# TLS 用源的 Host name，但 HTTP Host 用另一个域名
curl -sv --resolve &amp;#x3C;origin-host-name&gt;:443:&amp;#x3C;origin-ip&gt; -H &quot;Host: &amp;#x3C;public-domain&gt;&quot; https://&amp;#x3C;origin-host-name&gt;/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意，这种「容忍 SNI 与 Host 不一致」是 Front Door 对&lt;strong&gt;源&lt;/strong&gt;的要求；换成 Front Door 自己做入口，情况就反过来了。Front Door 有域名前置（domain fronting）防护：请求的 SNI 与 Host 不匹配时会被拒绝，返回 &lt;code&gt;421 Misdirected Request&lt;/code&gt;，日志里 &lt;code&gt;Error Info&lt;/code&gt; 记为 &lt;code&gt;SSLMismatchedSNI&lt;/code&gt;；只有两个域名同属一个订阅、且都配置在路由里时才放行。&lt;/p&gt;
&lt;p&gt;这也解释了一个实际限制：&lt;strong&gt;Front Door 不能套 Front Door&lt;/strong&gt;。官方 FAQ 明确说，把一个 Front Door profile/endpoint 放在另一个后面会共用同一批边缘 POP，导致路由冲突和性能问题，所以不要把多个 Front Door 串起来。&lt;/p&gt;
&lt;h3 id=&quot;源在私有网络里用-private-link&quot;&gt;源在私有网络里：用 Private Link&lt;/h3&gt;
&lt;p&gt;如果源在内网 VNet 里，Front Door「看不到」它，可以让源通过 Private Link Service 暴露出来，Front Door 侧作为一个 private endpoint 接入。几个要点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;启用 Private Link 后，Host name 用于 SNI，要与服务端证书匹配；Origin host header 只用于 HTTP 请求头。&lt;/li&gt;
&lt;li&gt;必须开启证书主体名称校验。&lt;/li&gt;
&lt;li&gt;需要在源侧 Private Link Service 里 approve 这个 private endpoint connection（也可以配置自动 approve）。&lt;/li&gt;
&lt;li&gt;同一个源组里不能混用公有源和私有源。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;源组的其他设置&quot;&gt;源组的其他设置&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;健康探针（协议、路径、间隔）：HTTPS 探针同样用源的 Host name 建连，所以源的证书必须匹配它，否则探针会报 Unhealthy。&lt;/li&gt;
&lt;li&gt;负载均衡：按延迟或按样本。&lt;/li&gt;
&lt;li&gt;优先级（Priority）与权重（Weight）：用于主备或多活。&lt;/li&gt;
&lt;li&gt;会话保持（Session affinity）：见下。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;会话保持session-affinity&quot;&gt;会话保持（Session affinity）&lt;/h3&gt;
&lt;p&gt;同一个域名、应用在多个源上做分布式部署时，Front Door 默认会把同一客户端的不同请求分发到不同源。对无状态应用没问题，但对有状态应用（本机 session、内存缓存、要求固定实例的鉴权令牌等）就会出问题。开启源组级的 Session affinity 后，Front Door 会给客户端下发会话 cookie（&lt;code&gt;ASLBSA&lt;/code&gt; 和 &lt;code&gt;ASLBSACORS&lt;/code&gt;），后续请求据此回到同一个源。&lt;/p&gt;
&lt;p&gt;几个容易踩的点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它在源组级别开启（Standard/Premium），作用于与该源组关联的域名。&lt;/li&gt;
&lt;li&gt;它是&lt;strong&gt;尽力而为，不是硬绑定&lt;/strong&gt;。Front Door 先按延迟筛出候选源（最佳 RTT + 延迟灵敏度窗口），再从候选里优先选 cookie 指向的那个。如果固定的源不健康，或掉出延迟窗口，请求仍会被切走。延迟灵敏度越大越稳，但不是硬绑定。&lt;/li&gt;
&lt;li&gt;只有在&lt;strong&gt;不可缓存的响应&lt;/strong&gt;上才会建立会话 cookie：响应要带 &lt;code&gt;Cache-Control: no-store&lt;/code&gt;（注意 &lt;code&gt;no-cache&lt;/code&gt; 不算），或是带未过期 &lt;code&gt;Authorization&lt;/code&gt; 头、或 302 响应，并且源必须健康。源回可缓存内容时 cookie 不会下发，粘性也就无从谈起。&lt;/li&gt;
&lt;li&gt;它是会话 cookie，浏览器关闭即失效；通配符域名下，同一浏览器会话可在子域名之间共享。&lt;/li&gt;
&lt;li&gt;它替代不了共享状态。真正需要跨实例共享的 session 或缓存，还是应该放到共享存储里，不要依赖粘性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;源响应超时origin-response-timeout&quot;&gt;源响应超时（Origin response timeout）&lt;/h3&gt;
&lt;p&gt;Front Door 等待源返回的时间是有上限的。这个值在 profile 的 &lt;strong&gt;Overview&lt;/strong&gt; 页设置（不在源组里），作用于该 profile 的所有 endpoint，可填范围是 16–240 秒，最长 4 分钟。接口偶尔会慢（比如报表生成、长轮询）时，记得先把这个值调大，否则会收到 5xx。&lt;/p&gt;
&lt;p&gt;注意 4 分钟就是天花板。真有需要更长时间才返回的请求，Front Door 满足不了，得换个入口放在源前面，可能就要改用 Application Gateway。&lt;/p&gt;
&lt;h2 id=&quot;第-4-步创建路由&quot;&gt;第 4 步：创建路由&lt;/h2&gt;
&lt;p&gt;路由把自定义域名和源组连起来，主要配：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;域名：选择一个或多个自定义域名。&lt;/li&gt;
&lt;li&gt;路径模式：通常用 &lt;code&gt;/*&lt;/code&gt;。注意各条路由之间的路径模式要一致，否则可能出现域名命中了、路径却没命中，客户端直接失败。&lt;/li&gt;
&lt;li&gt;转发协议：到源用 HTTP 还是 HTTPS。&lt;/li&gt;
&lt;li&gt;缓存、压缩、规则集（Rule set）等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;域名匹配遵循最具体优先：同时存在通配符 &lt;code&gt;*.contoso.com&lt;/code&gt; 和具体子域名 &lt;code&gt;image.contoso.com&lt;/code&gt; 时，请求 &lt;code&gt;image.contoso.com&lt;/code&gt; 会走具体子域名的那条路由。&lt;/p&gt;
&lt;h3 id=&quot;缓存与压缩&quot;&gt;缓存与压缩&lt;/h3&gt;
&lt;p&gt;在路由上可以开启缓存，对静态资源（图片、CSS、JS）收益最大；反过来，动态或需要鉴权的接口不要缓存，以免串数据——建议把静态和动态拆成不同路由。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;查询字符串缓存行为&lt;/strong&gt;决定带 &lt;code&gt;?a=1&lt;/code&gt; 的请求算不算同一个缓存对象，有四种：Ignore Query String、Use Query String、Ignore specified query strings、Include specified query strings。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;压缩&lt;/strong&gt;（动态压缩）也在这里开，可以指定要压缩的 content type。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存时长&lt;/strong&gt;优先看源返回的 &lt;code&gt;Cache-Control&lt;/code&gt;；源没给时，Front Door 默认随机缓存 1–3 天。&lt;code&gt;private&lt;/code&gt;、&lt;code&gt;no-cache&lt;/code&gt;、&lt;code&gt;no-store&lt;/code&gt; 的响应不会被缓存。&lt;/li&gt;
&lt;li&gt;需要按条件覆盖缓存行为或时长时，可以在规则集里做；规则集的缓存配置会覆盖路由的。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;规则集rule-set挂在路由上的小逻辑&quot;&gt;规则集（Rule set）：挂在路由上的小逻辑&lt;/h3&gt;
&lt;p&gt;规则集由「匹配条件 + 动作」组成，建好后要关联到路由才会生效；一个规则集可以关联多条路由。常用动作有：URL 重定向、URL 重写、修改请求头、修改响应头，以及缓存相关的覆盖。&lt;/p&gt;
&lt;p&gt;一个典型用法是加安全响应头：新建规则集，动作用「修改响应头（Response header）」、操作选 Append，头名写 &lt;code&gt;Content-Security-Policy&lt;/code&gt;、值填策略，再挂到对应的路由上——这样匹配该路由的响应都会带上 CSP。HSTS、X-Frame-Options、CORS 的 &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; 同理。&lt;/p&gt;
&lt;p&gt;注意：Front Door 有一批保留头不能由规则修改，&lt;code&gt;X-Forwarded-Host&lt;/code&gt; 等就在其中。&lt;/p&gt;
&lt;h3 id=&quot;路由数量不是无限的composite-route-limit&quot;&gt;路由数量不是无限的：composite route limit&lt;/h3&gt;
&lt;p&gt;Front Door 对路由的约束不是「一个 endpoint 最多几条」这么简单，而是按 profile 算一个综合值（composite route metric），&lt;strong&gt;上限 5000&lt;/strong&gt;（完整规则和示例见 &lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-routing-limits&quot;&gt;Routing limits in Azure Front Door&lt;/a&gt;）。算法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单条路由：&lt;code&gt;HTTP 域名数 × HTTP 路径数&lt;/code&gt; 加上 &lt;code&gt;HTTPS 域名数 × HTTPS 路径数&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;如果这条路由通过规则集做了路由覆盖（route override），还要再加 &lt;code&gt;域名数 × 覆盖次数&lt;/code&gt;（HTTP、HTTPS 分别算）；&lt;/li&gt;
&lt;li&gt;把所有路由的这些值加起来，就是该 profile 的综合值。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;几点提醒：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它是&lt;strong&gt;按 profile&lt;/strong&gt; 汇总的；origin group、origin、endpoint 的数量不影响这个值。&lt;/li&gt;
&lt;li&gt;域名和路径是&lt;strong&gt;相乘&lt;/strong&gt;的关系，域名一多就很容易撞上限——SaaS 场景尤其要注意。&lt;/li&gt;
&lt;li&gt;同时开 HTTP 和 HTTPS，等于把路径算了两遍；接入只留 HTTPS，同样配置的占用能减半。&lt;/li&gt;
&lt;li&gt;另外还有一条硬性数量限制：每个 profile 最多 100（Standard）/ 200（Premium）条路由。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真撞上限时，官方建议：把路由拆到多个 profile、多用通配符域名、接入只开 HTTPS。&lt;/p&gt;
&lt;p&gt;顺带一提，源的名字建好后无法就地修改；真要改名又不能中断服务，可参考 &lt;a href=&quot;https://engineering.lishechuan.com/azure-front-door-tips--modifying-the-origin-name/&quot;&gt;《Azure Front Door 技巧：修改 Origin Name》&lt;/a&gt;。&lt;/p&gt;
&lt;h2 id=&quot;第-5-步把域名关联到-waf&quot;&gt;第 5 步：把域名关联到 WAF&lt;/h2&gt;
&lt;p&gt;WAF 不是直接挂在 Front Door 上，而是通过 &lt;strong&gt;security policy&lt;/strong&gt; 把域名和 WAF policy 关联起来。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;作用域与优先级：route 级 &gt; domain 级 &gt; profile 级，命中时用最具体的一层。可以先用 profile 级做基线，再对特定域名或路由叠加。&lt;/li&gt;
&lt;li&gt;WAF policy 有 Classic/Standard/Premium 之分，要与 Front Door profile 的 tier 对应。自定义规则两层都能用；托管规则集（默认规则集 DRS、Bot 保护）和 Private Link 只有 Premium 有。&lt;/li&gt;
&lt;li&gt;费用要留意：Front Door 的底价 Standard 约 $35/月、Premium 约 $330/月（以官方定价为准，因区域而异）；WAF 和 Private Link 已包含在 Premium 里，Standard 的自定义 WAF 规则也不额外收费。只需要自定义规则就选 Standard，要用托管规则集再上 Premium。&lt;/li&gt;
&lt;li&gt;通配符域名可以直接挂 WAF，用一条 policy 覆盖所有子域名。Standard/Premium 下，子域名默认继承通配符域名的 WAF policy；如果某个子域名要用不同的 policy，就显式关联它（具体优先）。&lt;/li&gt;
&lt;li&gt;如果不想让某个子域名跑 WAF，可以给它关联一个没有规则的空 policy。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;第-6-步验证配置并切换-dns&quot;&gt;第 6 步：验证配置并切换 DNS&lt;/h2&gt;
&lt;p&gt;正式切域名前，最好先验证整条链路是否通。此时域名还没指向 Front Door，有两种办法。&lt;/p&gt;
&lt;p&gt;可以修改本地 hosts 文件，把域名临时指向 Front Door endpoint 的 IP；更推荐用 curl 的 &lt;code&gt;--connect-to&lt;/code&gt;，让 SNI 和 Host 都保持为目标域名，但把连接打到 Front Door 的 endpoint：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 连接到 endpoint，但 SNI 与 Host 都保持为 www.contoso.com
curl -sv --connect-to www.contoso.com:443:contoso.z01.azurefd.net:443 https://www.contoso.com/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也可以用 &lt;code&gt;--resolve&lt;/code&gt; 指定 endpoint 的 IP：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -sv --resolve www.contoso.com:443:&amp;#x3C;endpoint-ip&gt; https://www.contoso.com/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-v&lt;/code&gt; 会打印 TLS 握手里真正的 SNI、证书的 CN/SAN 以及响应头，方便确认证书是否已为这个域名签发、命中了哪条路由。更完整的说明见 &lt;a href=&quot;https://engineering.lishechuan.com/test-https-website-with-dns-not-configured/&quot;&gt;《测试已启用 HTTPS 但尚未配置域名的站点》&lt;/a&gt;。&lt;/p&gt;
&lt;h2 id=&quot;其他全局设置与运维&quot;&gt;其他全局设置与运维&lt;/h2&gt;
&lt;h3 id=&quot;诊断日志&quot;&gt;诊断日志&lt;/h3&gt;
&lt;p&gt;访问日志、健康探针日志、WAF 日志默认都不开，需要在 Front Door profile 的 Diagnostic settings 里显式开启，发送到 Log Analytics、存储账户或 Event Hub：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FrontDoorAccessLog&lt;/code&gt;：所有经过 Front Door 的请求。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FrontDoorHealthProbeLog&lt;/code&gt;：健康探针的结果。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FrontDoorWebApplicationFirewallLog&lt;/code&gt;：被 WAF 规则命中的请求。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;排障时的查询示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kusto&quot;&gt;// 访问日志
AzureDiagnostics
| where ResourceProvider == &quot;MICROSOFT.CDN&quot; and Category == &quot;FrontDoorAccessLog&quot;

// 被 WAF 拦截的请求
AzureDiagnostics
| where ResourceProvider == &quot;MICROSOFT.CDN&quot; and Category == &quot;FrontDoorWebApplicationFirewallLog&quot;
| where action_s == &quot;Block&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;除了日志，Front Door 还提供指标（包括 WAF 请求计数），可以在 Metrics 里直接看。&lt;/p&gt;
&lt;h3 id=&quot;清除缓存purge-cache&quot;&gt;清除缓存（Purge cache）&lt;/h3&gt;
&lt;p&gt;改了内容但 TTL 还没到，可以手动清除（purge）：在 profile 的 &lt;strong&gt;Overview&lt;/strong&gt; 页点 &lt;strong&gt;Purge cache&lt;/strong&gt;，选 endpoint 和域名，然后要么清除全部，要么填具体路径。路径支持单个文件（如 &lt;code&gt;/pictures/strasbourg.png&lt;/code&gt;），也支持通配符（如文件夹 &lt;code&gt;/scripts/*&lt;/code&gt;、整个 endpoint &lt;code&gt;/*&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;几个注意点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一次 purge 最多 &lt;strong&gt;100 个 URL&lt;/strong&gt;（域名 + 路径）；更多要分批，且必须等上一批完成再提交，否则会被拒。&lt;/li&gt;
&lt;li&gt;purge &lt;strong&gt;大小写不敏感、且忽略查询字符串&lt;/strong&gt;：清一个 URL 会连带清掉它所有 &lt;code&gt;?query&lt;/code&gt; 变体。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;通配符域名不能直接 purge&lt;/strong&gt;，要指定具体子域名。&lt;/li&gt;
&lt;li&gt;purge 是全局操作，在所有 POP 生效最长约 &lt;strong&gt;10 分钟&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比起频繁 purge，更好的做法是给静态资源加版本号（每次更新换新 URL），Front Door 下次请求自然就取到新的了。&lt;/p&gt;
&lt;h3 id=&quot;耐心等待变更生效&quot;&gt;耐心等待变更生效&lt;/h3&gt;
&lt;p&gt;Front Door 的配置变更是一次全局操作，改完不会马上生效。按官方 FAQ，单次创建/更新/删除/WAF 操作最长可能要等约 15 分钟，缓存清除约 10 分钟；连续改好几处时，后面的会排队，总时间可能拉到约 30 分钟。自定义 TLS 证书更慢，最长约一小时。&lt;/p&gt;
&lt;p&gt;这事也有来历：2025 年 10 月 Front Door 那次多区域故障出在配置传播上，官方随后给传播流水线加了额外的安全阶段和 bake time，传播时间一度被拉长到 45 分钟，之后才逐步压回 15 分钟。&lt;/p&gt;
&lt;p&gt;所以，改完配置或切完 DNS，泡杯茶回来再看结果也不迟——别一刷新就怀疑自己配错了。配个东西等半小时，心态要稳。&lt;/p&gt;
&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;
&lt;p&gt;按顺序走下来其实不复杂：先建域名并用 &lt;code&gt;_dnsauth&lt;/code&gt; 的 TXT 记录验证，启用托管证书；再建源组和源，想清楚 SNI、Host 与 X-Forwarded-Host 各自由谁决定；然后用路由把域名和源组连起来；按需用 security policy 接上 WAF；最后别急着切 DNS，先用 &lt;code&gt;curl --connect-to&lt;/code&gt; 验证通过再正式切换。日志和 WAF 日志记得打开，排障时会省很多事。&lt;/p&gt;
&lt;h2 id=&quot;参考资料&quot;&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/domain&quot;&gt;Domains in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-add-custom-domain&quot;&gt;Add a custom domain to Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-wildcard-domain&quot;&gt;Wildcard domains in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/apex-domain&quot;&gt;Apex domains in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/origin&quot;&gt;Origins and origin groups in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/how-to-configure-origin&quot;&gt;How to configure origins in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-http-headers-protocol&quot;&gt;Protocol support for HTTP headers in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-enable-private-link-internal-load-balancer&quot;&gt;Enable Private Link for an internal load balancer as an origin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-route-matching&quot;&gt;How requests are matched to a routing rule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-routing-limits&quot;&gt;Routing limits in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-caching&quot;&gt;Caching with Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/cache-purge&quot;&gt;Cache purge in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-rules-engine&quot;&gt;What is a rule set in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-add-security-headers&quot;&gt;Add security headers with a rules engine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/routing-methods&quot;&gt;Traffic routing methods in Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview&quot;&gt;Azure Web Application Firewall on Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/waf-front-door-policy-settings&quot;&gt;WAF policy settings for Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-logs&quot;&gt;Configure Azure Front Door logs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/front-door-faq&quot;&gt;Azure Front Door frequently asked questions (FAQ)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/frontdoor/monitor-front-door-reference&quot;&gt;Monitoring data reference for Azure Front Door&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>在本地运行 Pulumi 管理 GCP 资源</title><link>https://engineering.lishechuan.com/run-pulumi-locally-on-gcp/</link><guid isPermaLink="true">https://engineering.lishechuan.com/run-pulumi-locally-on-gcp/</guid><description>用 gcloud 打印一个临时访问令牌并导出为环境变量，本地运行的 Pulumi 就能直接管理 GCP 资源，无需配置 ADC。</description><pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;用 CI/CD 流水线部署 GCP 基础设施，是 Cloud Engineering 的正道：比如 GitHub Actions 配合 Workload Identity Federation，把身份、权限、审计都收敛到流水线里，本地不必持有长期凭据。但在学习阶段，或者线上出了故障需要紧急手工修一把时，从本地跑 Pulumi 依然很实用。&lt;/p&gt;
&lt;p&gt;麻烦往往出在本地认证上。Pulumi 的 GCP Provider 在没有显式配置时，会回退到 Application Default Credentials（ADC）；而在企业环境里，这条路径经常被组织策略挡住，需要 Google Workspace 或 Cloud 管理员放行。本文记录一个更轻的技巧：复用 &lt;code&gt;gcloud&lt;/code&gt; 已经登录的身份，打印一个临时访问令牌并导出成环境变量，就能让本地 Pulumi 直接跑起来。&lt;/p&gt;
&lt;h2 id=&quot;常规的本地认证方式&quot;&gt;常规的本地认证方式&lt;/h2&gt;
&lt;p&gt;Pulumi 的 GCP Provider 支持几种认证方式。本地开发时官方推荐的是先登录 ADC：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcloud auth application-default login
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会把用户凭据写进 ADC 文件，供 Pulumi 等客户端库读取。另一条路是创建服务账号密钥，再通过 &lt;code&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/code&gt; 指向密钥文件。若没有配置任何凭据，Provider 最后才会回退到 ADC。&lt;/p&gt;
&lt;p&gt;在企业环境里，这两条路都可能走不通：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建服务账号密钥通常被组织策略禁用，例如约束 &lt;code&gt;constraints/iam.disableServiceAccountKeyCreation&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;ADC 登录、以及在组织中放行某些认证方式，往往需要管理员操作。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是就出现了”本地跑 Pulumi 还得找管理员开权限”的尴尬。下面这个技巧绕开了这些步骤：既不创建密钥，也不单独登录 ADC，只是复用 &lt;code&gt;gcloud&lt;/code&gt; 已登录的用户身份。&lt;/p&gt;
&lt;h2 id=&quot;技巧用-gcloud-的临时访问令牌&quot;&gt;技巧：用 gcloud 的临时访问令牌&lt;/h2&gt;
&lt;p&gt;前提是本机已经用 &lt;code&gt;gcloud auth login&lt;/code&gt; 登录过，并且该账号本身能访问目标项目。&lt;/p&gt;
&lt;h3 id=&quot;打印访问令牌&quot;&gt;打印访问令牌&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcloud auth print-access-token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会打印当前 gcloud 活跃账号的 OAuth 2.0 访问令牌。&lt;/p&gt;
&lt;h3 id=&quot;导出为-pulumi-读取的环境变量&quot;&gt;导出为 Pulumi 读取的环境变量&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export GOOGLE_OAUTH_ACCESS_TOKEN=&quot;$(gcloud auth print-access-token)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有两个容易混淆的环境变量，值得单独说清楚：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GOOGLE_OAUTH_ACCESS_TOKEN&lt;/code&gt;：&lt;strong&gt;Pulumi 的 GCP Provider&lt;/strong&gt; 读取的变量，对应 provider 的 &lt;code&gt;accessToken&lt;/code&gt; 配置。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CLOUDSDK_AUTH_ACCESS_TOKEN&lt;/code&gt;：&lt;strong&gt;gcloud CLI 自身&lt;/strong&gt;读取的变量。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果导出的是后者，&lt;code&gt;gcloud&lt;/code&gt; 命令会正常使用它，但 Pulumi 依然认不出——这也是我一开始记不清”到底是哪个变量”的原因。&lt;/p&gt;
&lt;h3 id=&quot;指定项目并执行&quot;&gt;指定项目并执行&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export GOOGLE_PROJECT=&quot;your-gcp-project-id&quot;
pulumi up
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GOOGLE_PROJECT&lt;/code&gt; 是 Provider 读取项目的环境变量之一；按其优先级依次还有 &lt;code&gt;GOOGLE_CLOUD_PROJECT&lt;/code&gt;、&lt;code&gt;GCLOUD_PROJECT&lt;/code&gt;、&lt;code&gt;CLOUDSDK_CORE_PROJECT&lt;/code&gt;。当然，也可以在 &lt;code&gt;Pulumi.&amp;#x3C;stack&gt;.yaml&lt;/code&gt; 里用 &lt;code&gt;gcp:project&lt;/code&gt; 指定项目。&lt;/p&gt;
&lt;p&gt;这样一来，Pulumi 就用自己的用户身份调用 GCP API，不再需要单独配置 ADC 或服务账号密钥。&lt;code&gt;pulumi preview&lt;/code&gt;、&lt;code&gt;pulumi refresh&lt;/code&gt;、&lt;code&gt;pulumi import&lt;/code&gt; 等命令同理，都能直接用这套凭据。&lt;/p&gt;
&lt;h3 id=&quot;注意事项&quot;&gt;注意事项&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;令牌有有效期，默认 1 小时。Pulumi 命令要在有效期内跑完；超时就重新执行 &lt;code&gt;gcloud auth print-access-token&lt;/code&gt; 并重新导出即可。&lt;/li&gt;
&lt;li&gt;Pulumi 不会自动刷新这个令牌，它和 Terraform 的 &lt;code&gt;access_token&lt;/code&gt; 一样，只是拿来即用、过期作废。&lt;/li&gt;
&lt;li&gt;不要把令牌写进代码或提交到 Git。临时会话用环境变量最省事；若一定要落到 Pulumi 配置里，至少用 &lt;code&gt;pulumi config set --secret&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;本文的环境变量技巧适用于 &lt;code&gt;pulumi-gcp&lt;/code&gt;（classic）Provider。若你用的是较新的 &lt;code&gt;pulumi-google-native&lt;/code&gt;，它不读 &lt;code&gt;GOOGLE_OAUTH_ACCESS_TOKEN&lt;/code&gt;，需要把令牌通过 &lt;code&gt;pulumi config set&lt;/code&gt; 放进 Provider 配置里。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;先提权用-pam-临时拿权限&quot;&gt;先提权：用 PAM 临时拿权限&lt;/h2&gt;
&lt;p&gt;在权限收紧的组织里，日常账号通常没有部署所需的角色，需要先提权。Google Cloud 的 Privileged Access Manager（PAM）提供”按需、限时、可审批”的临时授权，正好适合这种场景。&lt;/p&gt;
&lt;p&gt;先搜索自己可以申请的 entitlement：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcloud pam entitlements search \
  --caller-access-type=grant-requester \
  --location=global \
  --project=&quot;your-gcp-project-id&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再发起授权申请，例如申请 1 小时：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcloud pam grants create \
  --entitlement=&quot;ENTITLEMENT_ID&quot; \
  --requested-duration=3600s \
  --justification=&quot;emergency hotfix&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果 entitlement 配置了审批，还需要审批人执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gcloud pam grants approve GRANT_NAME --reason=&quot;approved&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;授权生效后，再用上面的方式打印令牌并运行 Pulumi 命令（例如 &lt;code&gt;pulumi up&lt;/code&gt;）。GCP 在每次调用 API 时都会按当前 IAM 角色鉴权，因此 PAM 临时授予的角色会一并生效。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;提示：&lt;code&gt;gcloud pam&lt;/code&gt; 的部分子命令目前仍处于 beta / alpha，参数可能变化，以本机 &lt;code&gt;gcloud pam --help&lt;/code&gt; 为准。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;CI/CD 流水线仍是默认路径，Workload Identity Federation 能免去本地长期凭据。&lt;/li&gt;
&lt;li&gt;需要在本地临时操作时，用 &lt;code&gt;gcloud auth print-access-token&lt;/code&gt; 配合 &lt;code&gt;GOOGLE_OAUTH_ACCESS_TOKEN&lt;/code&gt; 最省事，无需 ADC 或服务账号密钥。&lt;/li&gt;
&lt;li&gt;权限不够就先用 PAM 做限时提权。&lt;/li&gt;
&lt;li&gt;令牌短命、用完即弃；事情处理完，回到流水线。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;参考文档&quot;&gt;参考文档&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.pulumi.com/registry/packages/gcp/installation-configuration/&quot;&gt;Pulumi Google Cloud (GCP) Provider 安装与配置&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/sdk/gcloud/reference/auth/print-access-token&quot;&gt;gcloud auth print-access-token&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/sdk/docs/authorizing&quot;&gt;Authorize the gcloud CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/iam/docs/pam-overview&quot;&gt;Privileged Access Manager 概览&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/iam/docs/workload-identity-federation&quot;&gt;Authenticating to Google Cloud with Workload Identity Federation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>AI 时代的 Cloud Engineering：纪律与修养</title><link>https://engineering.lishechuan.com/cloud-engineering-discipline-and-craft-in-the-ai-era/</link><guid isPermaLink="true">https://engineering.lishechuan.com/cloud-engineering-discipline-and-craft-in-the-ai-era/</guid><description>AI 很可能一次给出最终形态，真实世界的环境却得一级级攒——从 dev 到 prod，攒的是信心。</description><pubDate>Sat, 10 Oct 2026 20:20:20 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;写在前面&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;本文由 AI 执笔，人类立意——生成交给黑盒，把关留给人。&lt;/em&gt;
&lt;em&gt;Written by AI, imagined by a human.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;软件工程里最老、也最少被遵守的一句忠告，&lt;a href=&quot;https://blog.codinghorror.com/the-best-code-is-no-code-at-all&quot;&gt;Jeff Atwood&lt;/a&gt; 说得最直白：&lt;strong&gt;最好的代码，就是没有代码&lt;/strong&gt;。AI 没让它过时，反而让忽略它变得更贵——它让写代码变快了，却没让“改对了”变简单：&lt;strong&gt;未经核实的变更照样打挂集群，嘈杂的 PR 照样耗尽注意力，散落的部落知识照样带来混乱&lt;/strong&gt;。何况，写代码的人正越来越依赖 AI，也越来越少经历那种把纪律磨成本能的重复；代码生成越来越像黑盒，每一步怎么来的，都难以看清。于是那个老问题又冒了出来：这些老纪律还成立吗？答案是肯定的：它们不但成立，反而更值钱了——生成可以交给黑盒，而黑盒外那几道由人把守的关卡，才守得住质量、压得住混乱，甚至让流程更快。&lt;/p&gt;
&lt;p&gt;把经验收拢起来，值得守的习惯大多落在三个地方——版本控制（Git）、基础设施管理（IaC，Pulumi）、应用部署（ArgoCD）。都是些“古法”：不新，不酷，甚至有点老古董；可偏偏是它们，在出事的时候最靠得住。工具和语法从不稀缺，AI 还能现写；稀缺的，是判断和节制。目的不是立规矩，而是让 AI 加速之后的工作流，依然安全、确定、可扩展。&lt;/p&gt;
&lt;p&gt;下面不按这三个地方来分——同一条原则，往往同时管着 Git、Pulumi 和 ArgoCD，硬按工具切开反而割裂。所以这里分成三组：&lt;strong&gt;四条流程原则&lt;/strong&gt;，讲变更怎么从成形走到落地；&lt;strong&gt;一条设计原则&lt;/strong&gt;，讲代码与配置怎么组织；外加&lt;strong&gt;两条底线&lt;/strong&gt;，不属于任何单个阶段，却是每条原则都得守的。每条都先讲清它想守住什么，再给能照做的做法。老实说，没有一条是新的。这些原则，说到底都在回答同一个问题：生成变便宜之后，&lt;strong&gt;判断与节制&lt;/strong&gt;该落向哪里——答案或许是，落到一条&lt;strong&gt;轨迹&lt;/strong&gt;上，而不只是一个&lt;strong&gt;终态&lt;/strong&gt;。这也是展开它们之前，需要先摆清楚的前提。&lt;/p&gt;
&lt;h2 id=&quot;终态是图轨迹是路&quot;&gt;终态是图，轨迹是路&lt;/h2&gt;
&lt;p&gt;先说一个前提：&lt;strong&gt;真实世界里的环境，是有时间维度的&lt;/strong&gt;——不管它由代码搭建，还是靠手工一步步搭起来。让 AI 一口气把 dev、test、staging、prod 四个环境的配置都生成齐，命名一致、结构齐整，一眼就是“最终形态”；可现实里它从来不是这样长出来的——先在 dev 和 test 里搭起来、验证，再提升到 staging 观察一段时间，确认无事，才轮到 prod。四个环境最终都有这套配置，但它是一级一级攒出来的，不是一次铺满的。&lt;/p&gt;
&lt;p&gt;AI 很可能看到的、给出的，都是&lt;strong&gt;终态&lt;/strong&gt;；而工程真正要交付的，却是一条&lt;strong&gt;轨迹&lt;/strong&gt;。前者横着铺开：一句 prompt，就能把终态一次画全，还天然倾向于把能写的都写上；后者竖着走：变更沿一条&lt;strong&gt;风险递增的阶梯&lt;/strong&gt;向上挪，dev → test → staging → prod，每一级都是一次曝光、一轮观察、一次“还能重来”的尝试——所谓“提升”，本质是&lt;strong&gt;信心在逐级累积&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这一点在复刻现成的环境时尤其隐蔽：仓库现成、配置现成，一键部署，看上去一步到位。可那套“终态”是从一条轨迹里长出来的，轨迹却几乎无法复制——于是你拿到了结果，却没拿到结果背后的证据。&lt;/p&gt;
&lt;p&gt;跳过这条轨迹，等于拿着终态的图去盖生产环境那张“已验收”的章——章是真的，却没人体检过。&lt;strong&gt;AI 很可能把终态画得出来，却画不出通往终态的那条路；而判断要不要往上走的依据，全在这条路上。&lt;/strong&gt; 接下来的四条流程原则，做的正是同一件事：把 AI 折叠掉的那条时间维度重新展开，给“快”重新铺一条能逐级验证、也能逐级回头的轨道。&lt;/p&gt;
&lt;h2 id=&quot;流程四原则&quot;&gt;流程四原则&lt;/h2&gt;
&lt;p&gt;先看变更本身——从成形到落地，该怎么走。&lt;/p&gt;
&lt;h3 id=&quot;原则一小步且可验证&quot;&gt;原则一：小步且可验证&lt;/h3&gt;
&lt;p&gt;把变更拆小、每步都验证，听起来像常识，作用却很实际：真出问题时，你能一眼看出是哪一小步，也能单独回退，而不必在一坨几百行的改动里大海捞针。&lt;/p&gt;
&lt;h4 id=&quot;主干开发&quot;&gt;主干开发&lt;/h4&gt;
&lt;p&gt;先澄清一点：主干开发（Trunk-Based Development）&lt;strong&gt;并不等于&lt;/strong&gt;直接往 main 推代码——对 main 的直接提交是受限制的。&lt;/p&gt;
&lt;p&gt;它的意思是：main（旧仓库里可能是 master）是唯一的事实来源，大家用短、快的节奏迭代。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;短命特性分支&lt;/strong&gt;：从 main 切一个分支，完成一个具体的小任务，提 PR，然后尽快合回 main（理想情况下一两天内）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;避开长命分支&lt;/strong&gt;：特性分支最好别开上几周。它和 main 分离越久，集成越难；如果确实要长期存在，就经常把 main 合并回自己的分支。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;从最新的 main 切分支&lt;/strong&gt;：动手前先 &lt;code&gt;git checkout main &amp;#x26;&amp;#x26; git pull&lt;/code&gt;。从陈旧的代码或已存在的特性分支切出去，会把无关的 diff 带进新 PR。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个任务 = 一个分支 = 一个 PR = 删除&lt;/strong&gt;：PR 一合并，这个分支的使命就结束了，别再复用。复用会带来“脏状态”（残留的本地提交、旧的 PR 评论历史、跟踪错配）——删掉、从 main 重开，每次都从一张干净的白纸开始。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CODEOWNERS 要求&lt;/strong&gt;：合入 main 至少需要一位指定 CODEOWNERS 成员批准，且不能是自己。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;小而原子的-pr&quot;&gt;小而原子的 PR&lt;/h4&gt;
&lt;p&gt;拆一个大特性，本质上也是一种表达——只不过介质是代码，而不是自然语言。既然是表达，就该让读的人一眼看懂：一个 PR 只讲一件事，讲完就结束。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;拆成小的、单一职责的 PR&lt;/strong&gt;：每个 PR 最好都能独立验证，且合进去不会破坏现网、也不会让系统卡在不一致状态。小而安全的改动，审阅者能更快、更有信心地批准——这是加快评审与合并最有效的办法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂特性早点开 Draft PR&lt;/strong&gt;：如果特性复杂或有架构影响，在开发早期就开一个 Draft PR，让团队先对高层方案做一次“预评审”，免得临近合并时才发现要推倒重来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让改动可追踪、可复现&lt;/strong&gt;：先过流水线再合，能消掉藏在个人脑中的隐性知识，让任何人都能顺着记录追踪、理解，并在各环境复现。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;原则二为审阅者着想&quot;&gt;原则二：为审阅者着想&lt;/h3&gt;
&lt;p&gt;快速获批的关键在&lt;strong&gt;发起方的准备&lt;/strong&gt;。评审需要专注，每一个 PR 请求都是一次上下文切换。发起方把 PR 自审干净、背景写清楚，审阅者就能把精力花在架构和设计上，而不是基础修复或猜意图。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;代码评审是反馈，不是评判&lt;/strong&gt;：它的目的是分享知识、拉齐质量标准、一起发现盲点，而不是针对作者本人。评论尽量具体、建设性，收到反馈也当作一次协作打磨。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先自审，再请人审&lt;/strong&gt;：AI 工具和代码片段能提速，但它们只是起点。发出去之前快速过一遍——有没有残留的占位符、逻辑对不对、上下文是否自洽——省的是所有人的时间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让审阅者省点心&lt;/strong&gt;：作为作者，在 PR 描述里交代改了什么、为什么改、怎么验证的。准备到位，审阅者两三分钟就能看完并批准。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;紧急改动走快速通道&lt;/strong&gt;：对紧急、简单、或只有合并后才能完整验证的改动，在描述里写明背景，审阅者才知道该审到什么深度，周转自然更快。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR 模板也要守（AI 助手同理）&lt;/strong&gt;：如果用 AI 工具（如 Claude Code）起草 PR 描述或提 PR，让它遵守仓库的 &lt;code&gt;.github/pull_request_template.md&lt;/code&gt;——包括结构、章节标题和 Markdown 格式。AI 生成的描述应当补充团队的标准格式，而不是绕过它，除非有充分理由。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;原则三用环境说话&quot;&gt;原则三：用环境说话&lt;/h3&gt;
&lt;p&gt;一句话：别只在自己机器上“感觉没问题”，就让流水线和真实环境来作证。&lt;/p&gt;
&lt;h4 id=&quot;验证别只说本地能跑&quot;&gt;验证：别只说“本地能跑”&lt;/h4&gt;
&lt;p&gt;Kent Beck 有句名言：&lt;em&gt;make it work, make it right, make it fast&lt;/em&gt;（&lt;a href=&quot;https://tidyfirst.substack.com/p/chapter-make-it-run-make-it-right&quot;&gt;出处&lt;/a&gt;）。这里只取它的前半程：&lt;strong&gt;先保证结果正确，再谈代码是否优雅&lt;/strong&gt;。PR 描述里的“验证”小节，做的就是把验证的&lt;strong&gt;过程和结果&lt;/strong&gt;摆出来，而不只是一句“我本地跑过了”。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;给出结果&lt;/strong&gt;：附上可核实的凭据——流水线运行链接、终端输出，或已创建云资源的截图。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;交代过程&lt;/strong&gt;：说明是在哪个中立环境里验证的（CI 流水线或沙箱），而不是只靠本地，免得上线时踩到意外。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先对齐，再动手&lt;/strong&gt;：先对齐交付物与验收标准，再动手写代码；一个特性只有真实环境端到端验证通过、并交付了预期价值，才算真正完成。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;在-argocd-中做隔离验证&quot;&gt;在 ArgoCD 中做隔离验证&lt;/h4&gt;
&lt;p&gt;引入新 add-on、修改现有服务，或验证结构性 manifest 变更时，如果直接把代码合进 main，ArgoCD 会&lt;strong&gt;立刻&lt;/strong&gt;把未经核实的变更应用到目标集群（前提是 Application 启用了自动同步 &lt;code&gt;syncPolicy.automated&lt;/code&gt;；否则合并后只会显示 OutOfSync，需要人工 sync）。&lt;/p&gt;
&lt;p&gt;其实没必要为了“试试能不能跑”就抢着合 main。未验证就合并，换来的往往是试错式的提交噪声、跨多个碎片化 PR 的范围蔓延，以及大段只存在于个人脑子里的隐性知识。&lt;/p&gt;
&lt;p&gt;对非平凡的变更，更划算的做法是让每个 PR 或基础设施改动&lt;strong&gt;自包含、可验证&lt;/strong&gt;，在真实环境端到端验证后再合入 main；至于简单的取值更新，走轻量评审、快速合并就够了。&lt;/p&gt;
&lt;p&gt;隔离验证的工作流如下：&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;阶段&lt;/th&gt;&lt;th&gt;&lt;code&gt;targetRevision&lt;/code&gt;&lt;/th&gt;&lt;th&gt;操作&lt;/th&gt;&lt;th&gt;效果&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;① 铺路（PR 1）&lt;/td&gt;&lt;td&gt;→ &lt;code&gt;feature/my-addon-test&lt;/code&gt;&lt;/td&gt;&lt;td&gt;合入 main&lt;/td&gt;&lt;td&gt;ArgoCD 改为从特性分支同步到集群&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;② 迭代验证&lt;/td&gt;&lt;td&gt;&lt;code&gt;feature/my-addon-test&lt;/code&gt;&lt;/td&gt;&lt;td&gt;在特性分支上 push、测试&lt;/td&gt;&lt;td&gt;在集群里安全地验证真实行为&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;③ 恢复（PR 2）&lt;/td&gt;&lt;td&gt;→ &lt;code&gt;main&lt;/code&gt;&lt;/td&gt;&lt;td&gt;合入 main&lt;/td&gt;&lt;td&gt;回到干净的生产同步&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;分步说明：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;铺好路由（PR 1）&lt;/strong&gt;：对 main 提一个小 PR，把 ArgoCD Application 定义里的 &lt;code&gt;targetRevision&lt;/code&gt; 从 main（或 HEAD）改指向你的特性分支（如 &lt;code&gt;feature/my-addon-test&lt;/code&gt;），然后合并。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;安全地迭代与验证&lt;/strong&gt;：直接在特性分支上开发、推送、测试你的 manifest。ArgoCD 会从该分支同步，让你能在集群里完整观察真实行为。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;恢复并干净合并（PR 2）&lt;/strong&gt;：测试通过、准备晋升时，把 &lt;code&gt;targetRevision&lt;/code&gt; 改回 main（或 tag / release），再把特性分支的代码合入 main。&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;注意两点：修改 &lt;code&gt;targetRevision&lt;/code&gt; 本身也要等一次 sync 才会生效；如果这个 Application 由 ApplicationSet 或 app-of-apps 管理，该字段可能被上层模板覆盖。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;基础设施变更只走流水线&quot;&gt;基础设施变更只走流水线&lt;/h4&gt;
&lt;p&gt;本地用 &lt;code&gt;pulumi preview&lt;/code&gt; 看改动没问题。但除非是极端紧急，&lt;code&gt;pulumi up&lt;/code&gt; 都该交给流水线，而不是在自己机器上跑。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;身份与权限漂移&lt;/strong&gt;：本地用户和流水线服务主体通常挂着不同的 IAM 角色与权限范围。本地部署容易带来意外失败、权限错配，或云资源归属不当。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态归属锁（特定项目）&lt;/strong&gt;：在后端存储账户 ACL 较严的仓库里，本地开发者账户反而权限更高。本地 &lt;code&gt;pulumi up&lt;/code&gt; 可能顺手把 state 对象的归属改写成你的个人身份，把自动化的 runner 锁在门外，之后再也更新不了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;审计与可观测&lt;/strong&gt;：本地的 &lt;code&gt;pulumi preview&lt;/code&gt; 和 &lt;code&gt;pulumi up&lt;/code&gt; 不留集中、持久的日志。走 CI/CD，每一次部署的日志、执行上下文和 stdout/stderr 才会被永久归档，日后排障、合规、审计都用得上。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;竞态：重新 preview。&lt;/strong&gt; 像 Terraform 这类工具，部署是严格的两阶段（CI 生成 plan 文件，CD 再 apply），以保证 CI 里审过的就是 CD 里应用的。Pulumi 也有对应机制——&lt;strong&gt;Update Plans&lt;/strong&gt;（&lt;code&gt;pulumi preview --save-plan&lt;/code&gt; 生成、&lt;code&gt;pulumi up --plan&lt;/code&gt; 应用），但它目前仍是实验特性（需 &lt;code&gt;PULUMI_EXPERIMENTAL&lt;/code&gt;），未必都已启用。&lt;strong&gt;在没有锁定 plan 的情况下&lt;/strong&gt;，&lt;code&gt;pulumi up&lt;/code&gt; 会在部署时重新评估实时代码，所以尤其要留心最终到底会应用什么。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;覆盖风险&lt;/strong&gt;：两位工程师几乎同时从不同特性分支对同一个 stack 执行 &lt;code&gt;pulumi up&lt;/code&gt;，或者你的 PR 还没合、另一次部署先完成了，都可能不小心覆盖别人的改动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;中间有任何动静就重跑 preview&lt;/strong&gt;：如果在初次 &lt;code&gt;pulumi preview&lt;/code&gt; 与最终 &lt;code&gt;pulumi up&lt;/code&gt; 之间发生过其他流水线运行或部署，值得重新 &lt;code&gt;pulumi preview&lt;/code&gt;，重新核对 diff。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尽快合并以免阻塞&lt;/strong&gt;：为避免竞态、状态冲突或阻塞队友，基础设施 PR 一经批准并验证就尽快合并。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;原则四敬畏生产&quot;&gt;原则四：敬畏生产&lt;/h3&gt;
&lt;p&gt;生产是唯一一个“改错要道歉、甚至要熬夜”的地方。凡是可能落到它身上的改动，都值得多看一眼。&lt;/p&gt;
&lt;h4 id=&quot;pr-里附上-preview-运行&quot;&gt;PR 里附上 preview 运行&lt;/h4&gt;
&lt;p&gt;IaC 的代码逻辑，并不总能让人看清它对现网云资源的实际影响。一处两行的改动、一个属性的微调，都可能悄悄触发资源的销毁重建（&lt;code&gt;+- replace&lt;/code&gt;）。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;桥接代码与云的现实&lt;/strong&gt;：开 PR 时，让 CI 工作流跑一次 &lt;code&gt;pulumi preview&lt;/code&gt;，把输出粘贴或链接到 PR 描述里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把意图摆到台面上&lt;/strong&gt;：这样审阅者一眼就能看到云侧影响（&lt;code&gt;+ Create&lt;/code&gt;、&lt;code&gt;~ Update&lt;/code&gt;、&lt;code&gt;+- Replace&lt;/code&gt;、&lt;code&gt;- Delete&lt;/code&gt;），不必在脑子里推演 Pulumi 会怎么评估代码。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;还要留意两类意外的 diff：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;意外的更新（&lt;code&gt;~&lt;/code&gt;）&lt;/strong&gt;：preview 里冒出了你并不打算动的属性，停下来查一查。这通常意味着底层状态漂移，或云厂商有未处理的默认属性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;销毁性替换（&lt;code&gt;+-&lt;/code&gt;）&lt;/strong&gt;：数据库、存储桶、静态 IP、有状态服务一旦被替换，就是数据丢失或停机。preview 报出替换时，先确认这个属性是否真的需要重建，或者是否需要显式加 &lt;code&gt;protect: true&lt;/code&gt;、&lt;code&gt;ignoreChanges&lt;/code&gt;，再往下走。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;gitops-与自动纠偏&quot;&gt;GitOps 与自动纠偏&lt;/h4&gt;
&lt;p&gt;对 Kubernetes 应用与 add-on，用 ArgoCD 做严格的 GitOps：Git 仓库是集群状态的&lt;strong&gt;唯一事实来源&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;别手动改集群&lt;/strong&gt;：任何在集群里直接做的改动（&lt;code&gt;kubectl edit&lt;/code&gt;、&lt;code&gt;kubectl apply&lt;/code&gt;，或 UI 覆盖）都会被识别为漂移，并被 ArgoCD 覆盖 / 自动纠偏回 Git 定义的状态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;改环境，先改 Git&lt;/strong&gt;：想让环境变，就把改动提交到 Git。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;自动纠偏有个前提：Application 需要配置 &lt;code&gt;syncPolicy.automated.selfHeal: true&lt;/code&gt;。而且漂移检测依赖 refresh，并非实时触发。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;及时增量发布&quot;&gt;及时、增量发布&lt;/h4&gt;
&lt;p&gt;审批可能要花时间，但合并后就要尽快部署到目标环境。与其把多个 PR 攒成一次大发布，不如一小步一小步地走——小部署爆炸半径小，bug 好定位，好的代码也不会被连累回滚。&lt;/p&gt;
&lt;h2 id=&quot;设计原则&quot;&gt;设计原则&lt;/h2&gt;
&lt;p&gt;再看一层：代码与配置该怎么组织。&lt;/p&gt;
&lt;h3 id=&quot;原则五用重复换取解耦&quot;&gt;原则五：用重复换取解耦&lt;/h3&gt;
&lt;p&gt;DRY 是一种本能，但在跨环境、跨项目的 IaC 里，它常被用过头。判据不该是“两段东西长得像不像”，而是“它们是不是同一份知识、会不会朝同一个方向变”：会一起变，才值得收敛成一份；会各自演化，重复反而更解耦。&lt;/p&gt;
&lt;p&gt;真正必须只存一份的，是&lt;strong&gt;知识&lt;/strong&gt;——命名、结构这类所有环境都要一字不差的东西；“哪个环境要建哪些资源”这种会随环境分叉的意图，宁可各写各的。把“看起来一样”的东西过早抽成共享代码，换来的往往是改一处、牵动全环境：独立演化与爆炸半径隔离同时失效。&lt;/p&gt;
&lt;h4 id=&quot;资源取舍分环境资源形状一处定义&quot;&gt;资源取舍分环境，资源形状一处定义&lt;/h4&gt;
&lt;p&gt;非生产与生产有差异是常态，处理原则就一条：&lt;strong&gt;要建哪些资源，归各环境自己的取舍；资源长什么样，在共享代码里只定义一次&lt;/strong&gt;——让一处改动尽可能只落在单个环境里。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;取舍分环境&lt;/strong&gt;：要建哪些资源、用什么规格，写在各环境自己的配置里（如 &lt;code&gt;Pulumi.dev.yaml&lt;/code&gt;、&lt;code&gt;Pulumi.prod.yaml&lt;/code&gt;）——这些本就会随环境演进、各不相同。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;形状一处定义&lt;/strong&gt;：命名、结构、父子关系、通用标签这类必须处处一致的知识，放进共享代码，只写一次。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别拿逻辑分支代替取舍&lt;/strong&gt;：把 &lt;code&gt;if env == &quot;prod&quot;&lt;/code&gt; 塞进共享代码，等于把“该独立演化”的东西焊死在一起——动一处，全局跟着变。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;这条取舍的完整论证，见《&lt;a href=&quot;https://engineering.lishechuan.com/embrace-duplication-over-dry-for-better-decoupling/&quot;&gt;Duplication over DRY：用重复换取解耦&lt;/a&gt;》。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;两条底线&quot;&gt;两条底线&lt;/h2&gt;
&lt;p&gt;最后两条不属于任何单个阶段，而是每条原则都得守住的底线。&lt;/p&gt;
&lt;h3 id=&quot;原则六责任在人不在工具&quot;&gt;原则六：责任在人，不在工具&lt;/h3&gt;
&lt;p&gt;工具能提速，也会出错，但从不会替你负责——最后一道关，永远是人。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;作者负责&lt;/strong&gt;：审阅者是第二双眼睛，但代码的安全与质量，最终还由作者兜底。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工具提速，不背锅&lt;/strong&gt;：AI 起草的代码、CI 跑绿的流水线，都只是辅助；它们没覆盖到的边界，仍然要由你判断——绿灯不等于没问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;原则七保护密钥&quot;&gt;原则七：保护密钥&lt;/h3&gt;
&lt;p&gt;密钥一旦暴露（API key、云凭据、密码、私钥），代价往往比想象中大——不管它落在 main、临时特性分支，还是 AI 的上下文里。这道底线有两半：&lt;strong&gt;别提交进 Git，也别让 AI 读到&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;安全工具在盯着&lt;/strong&gt;：Aikido、GitHub Secret Scanning、Trufflehog 这类平台会自动扫描每次 push。密钥一进任何远程分支，立刻被记录并标记。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;删分支删不掉密钥&lt;/strong&gt;：删掉特性分支或 force-push 一个干净提交，都无法把密钥从 GitHub 抹掉。GitHub 会在后端历史里保留陈旧提交一段时间，泄露的值往往还会长期留在 PR 提交历史、时间线日志和审计记录里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;真泄露了怎么办&lt;/strong&gt;：如果密钥被误提交并推送——哪怕只在短命分支上待了几秒——第一时间通知团队。从 Git 里删掉代码不够，泄露的凭据要立即吊销并轮换。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提前防御（Git Hooks）&lt;/strong&gt;：可以在本地配上 pre-commit hook（如 git-secrets、detect-secrets），提交前在机器上先扫一遍，把泄露拦在到达 GitHub 之前。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别让 AI 读到密钥（尤其重要）&lt;/strong&gt;：AI 编码助手（如 Cursor、Claude Code）会把工作区文件、打开的上下文和终端输出送进模型，所以“没提交”并不等于“没泄露”——&lt;code&gt;.env&lt;/code&gt;、本地凭据文件，甚至 shell 里 &lt;code&gt;export&lt;/code&gt; 的 token，只要落进上下文，就可能被处理或留存。
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;把密钥挡在上下文之外&lt;/strong&gt;：用工具自己的忽略配置（如 Cursor 的 &lt;code&gt;.cursorignore&lt;/code&gt;）把 &lt;code&gt;.env&lt;/code&gt;、凭据目录排除出 AI 的可读范围。但它只是尽力而为——官方也承认 LLM 行为难以完全预测，别把它当成唯一防线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别留明文，交给 secret manager&lt;/strong&gt;：Vault、云 KMS、1Password 之类按需注入，工作区里不落明文。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用短时、最小权限凭据&lt;/strong&gt;：能用 OIDC 换临时 token，就别用长期 key——窗口越短，真出事时代价越小。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别把生产密钥贴进 AI 对话&lt;/strong&gt;：为了“帮忙排障”把密钥粘进聊天框，和提交进 Git 一样危险。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;结语&quot;&gt;结语&lt;/h2&gt;
&lt;p&gt;回头看，这些“古法”既不高明，也不时髦，更拦不住 AI 写得比你快。它们守的，其实是一件很朴素的事——让“快”别变成“乱”：黑盒越黑，外面那几道由人把守的关卡就越要紧，而这些关卡得沿着环境一级级抬高，信心才有时间慢慢攒起来。&lt;strong&gt;AI 很可能给出的是一张终态的图，工程要补上的，是一条能走、能验、也能回头的路。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;主干开发（Trunk-Based Development）— &lt;a href=&quot;https://trunkbaseddevelopment.com/&quot;&gt;https://trunkbaseddevelopment.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitOps 原则 — &lt;a href=&quot;https://opengitops.dev/&quot;&gt;https://opengitops.dev/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub · 关于 CODEOWNERS — &lt;a href=&quot;https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners&quot;&gt;https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pulumi · &lt;code&gt;pulumi preview&lt;/code&gt; — &lt;a href=&quot;https://www.pulumi.com/docs/iac/cli/commands/pulumi_preview/&quot;&gt;https://www.pulumi.com/docs/iac/cli/commands/pulumi_preview/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pulumi · Update Plans（实验特性，锁定 preview 与 up）— &lt;a href=&quot;https://www.pulumi.com/docs/iac/operations/stack-management/update-plans&quot;&gt;https://www.pulumi.com/docs/iac/operations/stack-management/update-plans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pulumi · &lt;code&gt;protect&lt;/code&gt; — &lt;a href=&quot;https://www.pulumi.com/docs/iac/concepts/resources/options/protect/&quot;&gt;https://www.pulumi.com/docs/iac/concepts/resources/options/protect/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pulumi · &lt;code&gt;ignoreChanges&lt;/code&gt; — &lt;a href=&quot;https://www.pulumi.com/docs/iac/concepts/resources/options/ignorechanges/&quot;&gt;https://www.pulumi.com/docs/iac/concepts/resources/options/ignorechanges/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Argo CD · 自动同步（&lt;code&gt;syncPolicy.automated&lt;/code&gt; / &lt;code&gt;selfHeal&lt;/code&gt;）— &lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/&quot;&gt;https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Terraform · &lt;code&gt;plan&lt;/code&gt; — &lt;a href=&quot;https://developer.hashicorp.com/terraform/cli/commands/plan&quot;&gt;https://developer.hashicorp.com/terraform/cli/commands/plan&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub · 密钥扫描（secret scanning）— &lt;a href=&quot;https://docs.github.com/en/code-security/secret-scanning&quot;&gt;https://docs.github.com/en/code-security/secret-scanning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Cursor · Ignore file（&lt;code&gt;.cursorignore&lt;/code&gt;，把密钥挡在 AI 上下文之外）— &lt;a href=&quot;https://cursor.com/docs/reference/ignore-file&quot;&gt;https://cursor.com/docs/reference/ignore-file&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;git-secrets — &lt;a href=&quot;https://github.com/awslabs/git-secrets&quot;&gt;https://github.com/awslabs/git-secrets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;detect-secrets — &lt;a href=&quot;https://github.com/Yelp/detect-secrets&quot;&gt;https://github.com/Yelp/detect-secrets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;TruffleHog — &lt;a href=&quot;https://github.com/trufflesecurity/trufflehog&quot;&gt;https://github.com/trufflesecurity/trufflehog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Aikido — &lt;a href=&quot;https://www.aikido.dev/&quot;&gt;https://www.aikido.dev/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Duplication over DRY：用重复换取解耦</title><link>https://engineering.lishechuan.com/embrace-duplication-over-dry-for-better-decoupling/</link><guid isPermaLink="true">https://engineering.lishechuan.com/embrace-duplication-over-dry-for-better-decoupling/</guid><description>DRY 并非第一原则：为什么现代架构宁可容忍重复，也要换取服务之间的彻底解耦。</description><pubDate>Sat, 10 Oct 2026 19:19:19 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;写在前面&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;本文由 AI 执笔，人类立意。&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Written by AI, imagined by a human.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;过去十几年里，软件架构的审美发生了一次静悄悄却很彻底的转向。曾经 DRY（Don’t Repeat Yourself）几乎是不可挑战的第一原则，代码里但凡出现一点相似，我们的手就痒，非要把它抽出来复用；而在今天的现代软件架构、尤其是微服务架构里，“Duplication over DRY” 正逐渐成为一种共识——&lt;strong&gt;宁可容忍一点重复，也不轻易引入一个抽象。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我不打算假装中立。这篇文章就是来给这个立场站台的：我认为，在大多数现代系统里，&lt;strong&gt;“重复优先” 是一种更成熟、更诚实的审美&lt;/strong&gt;。下面我先讲这股共识是怎么形成的、它到底买到了什么、代价又是什么，再把它延伸到 Cloud Engineering：多环境 IaC（基础设施即代码），以及多个 IaC 项目之间的依赖。&lt;/p&gt;
&lt;h3 id=&quot;为什么重复优先会形成共识&quot;&gt;为什么”重复优先”会形成共识&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;一、DRY 的本意被长期误读了。&lt;/strong&gt; 《The Pragmatic Programmer》里的原始表述是：“Every piece of knowledge must have a single, authoritative representation within a system.” 关键词是 &lt;strong&gt;knowledge&lt;/strong&gt;——它约束的是”知识”的单点，不是”代码文本”的单点。两段长得像的代码，背后未必是同一份知识；强行合并，反而是把两件不同的知识焊死在一起。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二、错误的抽象比重复更贵。&lt;/strong&gt; 这是这股共识的核心论据，Sandi Metz 把它总结成了一句几乎可以直接贴在墙上的名言：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“Duplication is far cheaper than the wrong abstraction.” —— Sandi Metz&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;在我看来，这句话之所以成立，是因为两者的成本结构完全不同：重复是&lt;strong&gt;局部的、可见的、随时可消除的&lt;/strong&gt;；而错误的抽象是&lt;strong&gt;全局的、隐蔽的、复利式增长的&lt;/strong&gt;。一旦两个本应独立演化的东西被合并，日后每次需求分叉，你都只能往里塞参数、加开关，最后它变成谁都不敢碰的泥球。我愿意把话再说过头一点：&lt;strong&gt;好的抽象难求，烂的抽象难逃。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三、微服务把”独立部署”抬成了黄金法则。&lt;/strong&gt; Sam Newman 在《Building Microservices》里把 &lt;strong&gt;Independent Deployability（独立部署性）&lt;/strong&gt; 视为第一原则，并明确提醒：跨服务共享一个库，会把多个服务锁死在同一条发布节奏上——一次改动要让所有消费方一起升级。那等于把分布式系统又收回成”分布式单体”。为了独立部署，宁可容忍一点重复。&lt;/p&gt;
&lt;p&gt;同一取舍在分布式架构里还有几个更标准的说法，值得一提：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data Redundancy / Data Replication（数据冗余 / 数据复制）&lt;/strong&gt;：订单服务里保存一份&lt;strong&gt;客户资料&lt;/strong&gt;的副本（名称、联系方式等），并随用户服务的事件保持最终一致，这样下单、查单时都不必在运行时回查用户服务——用一点数据的重复，换服务之间的解耦。（注意区分：这里复制的是”用户那份数据”的副本，会随上游更新；如果存的是&lt;strong&gt;下单那一刻&lt;/strong&gt;的联系方式，那其实是订单自己的数据，属于快照、不算这个模式——正如订单的收货电话未必等于用户当前的手机号。）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Share Nothing Architecture（无共享架构）&lt;/strong&gt;：服务之间宁可各自拥有一整套代码、逻辑甚至数据库，也绝不共享任何资产，以此换取极致的解耦。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相比之下，&lt;em&gt;“A little copying is better than a little dependency.”&lt;/em&gt;（Rob Pike, Go Proverbs）——这句话我几乎想印在工位上。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;四、服务与团队的边界是真实的。&lt;/strong&gt; 共享代码一旦跨越团队边界，任何改动都要所有消费方协调；而”各复制一份”把协调成本直接降到零。这背后有 Conway 定律的影子：系统的结构终会映射组织的沟通结构。与其和现实对抗，我更愿意顺着边界切。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;五、领域驱动设计（DDD）给了它理论外壳。&lt;/strong&gt; 在 &lt;strong&gt;限界上下文（Bounded Context）&lt;/strong&gt; 之下，同一个”商品”在仓储系统和销售系统里长得再像，也属于不同上下文，就该是两套模型，而不是共用一个类。这一点我年轻时不信，现在深信。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;六、还有一条朴素的经验法则：Rule of Three。&lt;/strong&gt; 第一次照抄、第二次照抄，等到第三次再考虑抽象——因为到那时你才真正看清”共同点”到底是什么。&lt;/p&gt;
&lt;h3 id=&quot;重复买到了什么&quot;&gt;重复买到了什么&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;切断变更耦合&lt;/strong&gt;：改一处不必改另一处。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;切断部署耦合&lt;/strong&gt;：各自发布、各自回滚，互不阻塞。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;局部可读、无间接层&lt;/strong&gt;：直接看代码就知道发生了什么，不必在层层抽象之间跳来跳去。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缩小爆炸半径&lt;/strong&gt;：一份坏了，不牵连另一份。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更低的协调成本&lt;/strong&gt;：跨团队不必为了改一行代码而开会同步。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;代价重复不是免费的&quot;&gt;代价：重复不是免费的&lt;/h3&gt;
&lt;p&gt;我不想把话说得太满。重复有它真实的账单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;漂移&lt;/strong&gt;：两份实现会逐渐发散，甚至走向不一致。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成倍维护&lt;/strong&gt;：缺陷修复、安全补丁、依赖升级，每多一份实现就多一份。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;认知负担&lt;/strong&gt;：面对多份实现，先得回答”哪份才是对的？”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;失去单一来源&lt;/strong&gt;：全局一致性与可追溯性更难保证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以真正的问题从来不是”要不要重复”，而是——&lt;strong&gt;重复的到底是不是同一份知识。&lt;/strong&gt; 是知识，就必须单点；只是碰巧长得像的工件，重复往往更划算。既然代价真实存在，就需要纪律与护栏来兜底。&lt;/p&gt;
&lt;h3 id=&quot;延伸到-cloud-engineering&quot;&gt;延伸到 Cloud Engineering&lt;/h3&gt;
&lt;p&gt;前面这套取舍，落到云上的基础设施同样成立——甚至更明显：在 IaC 里，“必须独立演化、独立部署”的边界，被环境天然地切好了。下面我以&lt;strong&gt;用 Pulumi 管理基础设施&lt;/strong&gt;为例，把它拆成两件事来讲（换成 Terraform、CDK 之类，道理完全一样）。&lt;/p&gt;
&lt;h4 id=&quot;多环境-iac资源取舍分环境资源形状单点&quot;&gt;多环境 IaC：资源取舍分环境，资源形状单点&lt;/h4&gt;
&lt;p&gt;维护 dev / test / staging / prod 多套环境时，我的分工原则很简单——按”要创建哪些资源”和”资源长什么样”这两件事来切：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;要创建哪些资源&lt;/strong&gt;，在每个环境自己的部署栈里定义——需要什么、要不要建，都由这份配置说了算。不同环境可以不一样；就算暂时长得一样，那也是”各自声明”的结果，而不是从一份共享模板派生出来的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;资源长什么样&lt;/strong&gt;（命名、结构、父子关系、通用标签），放进共享的 Python 代码——这是必须保持一致的&lt;strong&gt;知识&lt;/strong&gt;，所以单点。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;示意一下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# environments/dev.yaml
storage:
  - name: logs
    sku: Standard_LRS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python 侧读取这份 YAML，按统一命名规范把 &lt;code&gt;name&lt;/code&gt; 拼成 &lt;code&gt;stdevlogs...&lt;/code&gt;，再创建资源。于是”重复”发生在&lt;strong&gt;资源声明层&lt;/strong&gt;：每个环境各写各的。&lt;/p&gt;
&lt;p&gt;为什么这么切？因为这两半的”命运”正好相反——&lt;strong&gt;“资源长什么样”必须始终一致，“要创建哪些资源”却会随环境演进分叉。&lt;/strong&gt; 举个真实的例子：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;项目成长期&lt;/strong&gt;：部署与放行顺序天然是 dev → test → staging → prod，一路验证、一路提升。哪怕四个环境最终的状态非常接近，它们在开发过程中也是在&lt;strong&gt;不同的时机&lt;/strong&gt;被验证和部署的——dev 先上，prod 最后。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;维护阶段（开发进入低活跃期）&lt;/strong&gt;：需求变少，方向反转，我们可能反过来收缩——逐步下线 dev / test，只保留 staging / prod。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，“哪个环境还有哪些资源”本来就是会变、且各不相同的。把它预先抽成一份共享模板，只会让你不断往模板里打补丁。而命名、结构这类东西，我们希望所有环境一字不差，那正是该收敛到单点的知识。&lt;/p&gt;
&lt;p&gt;反过来最容易踩的坑，是把”要创建哪些资源”写进共享代码，比如 &lt;code&gt;if env == &quot;prod&quot;: ...&lt;/code&gt;。我反对的正是这个：这不是”代码还是配置”的问题，而是把”应该独立演化的东西”当成了”必须保持一致的知识”——结果改一处、牵连全环境，独立演化与爆炸半径隔离同时失效。（如果代码是纯数据驱动的——一套机制配一张”每环境有哪些资源”的表——那还好，本质和 YAML 没区别；真正的坑是&lt;strong&gt;逻辑分支&lt;/strong&gt;。）&lt;/p&gt;
&lt;p&gt;有人会说：既然 Python 生成逻辑本来就是共享的，那为什么不把”每个环境要哪些资源”也一并抽象掉，写一份”基础配置”，再由各环境派生？答案还是那句：&lt;strong&gt;会分叉的东西，别共享。&lt;/strong&gt; 四个环境跑在各自的部署栈上，这本来就是常态；该共享的是”&lt;strong&gt;资源长什么样&lt;/strong&gt;”的知识（Python），不是”&lt;strong&gt;每个环境要建哪些资源&lt;/strong&gt;”这份意图。后者一旦抽象，四个环境就又被焊在一起——动一处模板，四个环境一起变。&lt;/p&gt;
&lt;p&gt;好处也很直接：dev 里可以随手新建、改配置、跑一段时间验证，稳定后再提升到更高环境；每个环境都是一个能独立折腾的”雪花”，而不是被一份共享模板绑在一起。&lt;/p&gt;
&lt;h4 id=&quot;多个-iac-项目之间把依赖变成显式契约&quot;&gt;多个 IaC 项目之间：把依赖变成显式契约&lt;/h4&gt;
&lt;p&gt;大型项目往往会被拆成多个 IaC 项目，项目之间必然有依赖。常见做法是用 &lt;code&gt;StackReference&lt;/code&gt; / &lt;code&gt;terraform_remote_state&lt;/code&gt; / 共享模块，在部署时自动读取上游的输出。它”方便”，却把两个项目的&lt;strong&gt;部署时序、状态、生命周期&lt;/strong&gt;绑在了一起——上游没部署完下游就起不来，上游输出一改名下游就静默炸掉。在我看来，这恰恰是”错误的抽象”在基础设施层的翻版，而且更难排查。&lt;/p&gt;
&lt;p&gt;我更偏爱另一种做法：&lt;strong&gt;不引入任何运行时/部署时的自动引用，而是手动把上游的稳定标识（资源标识、名字、端点）复制到下游项目里，作为显式输入。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这里要诚实：这&lt;strong&gt;并没有消除依赖&lt;/strong&gt;。资源确实存在，也确实被引用。改变的是依赖的形态——从”隐式、自动、会牵连部署顺序”，变成”显式、单向、可读”。好处随之而来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;独立部署&lt;/strong&gt;：下游只依赖一个已经落地的值，不需要上游在线。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;执行顺序一眼可读&lt;/strong&gt;：先后关系写在配置里，而不是藏在部署引擎的依赖图里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;爆炸半径可控&lt;/strong&gt;：一个项目出问题，不会顺着引用链扩散。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;边界清晰&lt;/strong&gt;：这就是限界上下文在基础设施层的翻版。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代价同样存在，所以我会配几条护栏：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;尽量让接口稳定：与其复制不透明的资源标识，不如复制&lt;strong&gt;稳定命名&lt;/strong&gt;——此时命名规范本身就是公开契约。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;契约校验&lt;/strong&gt;：持续集成里加一步，检查下游引用的上游值是否存在、是否符合约定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;明确所有权&lt;/strong&gt;：谁拥有上游，变更时如何通知下游。&lt;/li&gt;
&lt;li&gt;只在”变更/部署耦合”确实带来痛苦时重复；否则回到抽象。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;一个简单的判断清单&quot;&gt;一个简单的判断清单&lt;/h3&gt;
&lt;p&gt;想抽象还是想重复？我会先问自己四个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;两边是&lt;strong&gt;同一份知识&lt;/strong&gt;，还是碰巧长得像？（同一份 → 抽象）&lt;/li&gt;
&lt;li&gt;它们需要&lt;strong&gt;独立演化 / 独立部署&lt;/strong&gt;吗？（需要 → 重复）&lt;/li&gt;
&lt;li&gt;合并之后，需求一旦分叉，会不会在抽象里反复打补丁？（会 → 别合并）&lt;/li&gt;
&lt;li&gt;重复带来的漂移风险，有没有可承受的护栏？（没有 → 别轻易重复）&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;结语&quot;&gt;结语&lt;/h3&gt;
&lt;p&gt;“Duplication over DRY” 之所以在微服务与云架构里逐渐成为共识，是因为大家终于算清了这笔账：&lt;strong&gt;重复的成本是局部的、可见的、可控的，而错误抽象的成本是全局的、隐蔽的、复利的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但它不是教条，而是一种需要勇气的权衡——因为它逆着我们”看到重复就想消除”的直觉。要守住的底线只有一条：&lt;strong&gt;知识必须单点。&lt;/strong&gt; 在这条底线之外，为了独立部署、独立演化、清晰的边界与可读的执行顺序，主动接受一份重复，往往比硬凑一个错误的抽象更便宜，也更好维护。&lt;/p&gt;
&lt;p&gt;Duplication is a design decision, not a sin.&lt;/p&gt;
&lt;h3 id=&quot;参考&quot;&gt;参考&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;The Pragmatic Programmer&lt;/em&gt; — DRY（知识的单一权威表示）&lt;/li&gt;
&lt;li&gt;Sandi Metz, &lt;em&gt;The Wrong Abstraction&lt;/em&gt; (2016)&lt;/li&gt;
&lt;li&gt;Rob Pike, &lt;em&gt;Go Proverbs&lt;/em&gt; (2015)&lt;/li&gt;
&lt;li&gt;Sam Newman, &lt;em&gt;Building Microservices&lt;/em&gt; — Independent Deployability&lt;/li&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design&lt;/em&gt; — Bounded Context&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>在 2026 年把静态站点从 Zola 迁移到 Astro</title><link>https://engineering.lishechuan.com/zola-to-astro-in-2026/</link><guid isPermaLink="true">https://engineering.lishechuan.com/zola-to-astro-in-2026/</guid><description>把 Zola 换成 Astro 到底值不值？从内容 schema、组件生态到中文锚点与依赖漂移，一次真实迁移的账本。</description><pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;2026 年做静态站点，工具大致收敛成了几条清晰的路线。下面先横向看一圈，再聊聊把一次 Zola 站点迁移到 Astro 之后，实际赢下了什么，以及过程中那些具体的坑——代码高亮与语言支持、中文标题锚点、中文标签转拼音，以及 Tailwind 的接入。&lt;/p&gt;
&lt;h2 id=&quot;横向看2026-年的四种形态&quot;&gt;横向看：2026 年的四种形态&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://gohugo.io/&quot;&gt;Hugo&lt;/a&gt;、&lt;a href=&quot;https://www.getzola.org/&quot;&gt;Zola&lt;/a&gt;、&lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt;、&lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt; 基本覆盖了内容站的四种形态：&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;工具&lt;/th&gt;&lt;th&gt;运行时&lt;/th&gt;&lt;th&gt;内容 / 模板模型&lt;/th&gt;&lt;th&gt;构建速度&lt;/th&gt;&lt;th&gt;定位&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Hugo&lt;/td&gt;&lt;td&gt;Go 单二进制&lt;/td&gt;&lt;td&gt;Go 模板&lt;/td&gt;&lt;td&gt;极快&lt;/td&gt;&lt;td&gt;大站、主题生态&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Zola&lt;/td&gt;&lt;td&gt;Rust 单二进制&lt;/td&gt;&lt;td&gt;Tera 模板（类 Jinja）&lt;/td&gt;&lt;td&gt;很快&lt;/td&gt;&lt;td&gt;个人站、零依赖&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Eleventy&lt;/td&gt;&lt;td&gt;Node&lt;/td&gt;&lt;td&gt;多模板语言可选&lt;/td&gt;&lt;td&gt;中等&lt;/td&gt;&lt;td&gt;JS 生态、极简&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Astro&lt;/td&gt;&lt;td&gt;Node + Vite&lt;/td&gt;&lt;td&gt;组件 + islands&lt;/td&gt;&lt;td&gt;中等&lt;/td&gt;&lt;td&gt;内容 + 组件&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Hugo 和 Zola 是&lt;strong&gt;给内容套模板&lt;/strong&gt;：单二进制、零依赖、构建飞快，代价是模板语言表达力有限，生态（尤其 Zola）偏薄。Eleventy 是&lt;strong&gt;JS 版的 Zola&lt;/strong&gt;：模板语言随便挑、默认零客户端 JS，但构建更慢、也不自带电池。Astro 则是另一条路——&lt;strong&gt;用编程语言去构建内容站点&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;再往外还有两条分叉支线：Docusaurus / VitePress / Starlight 偏&lt;strong&gt;文档&lt;/strong&gt;，Next.js / Nuxt / SvelteKit 是&lt;strong&gt;应用框架&lt;/strong&gt;（SSG 只是其中一种输出模式），都不在本文的比较范围内。&lt;/p&gt;
&lt;h2 id=&quot;从-zola-到-astro赢在哪&quot;&gt;从 Zola 到 Astro，赢在哪&lt;/h2&gt;
&lt;h3 id=&quot;1-内容有了-schema错误在构建时就暴露&quot;&gt;1. 内容有了 schema，错误在构建时就暴露&lt;/h3&gt;
&lt;p&gt;Astro 的内容集合用 Zod 定义 schema：字段类型、是否可选、默认值全部写死在 &lt;code&gt;src/content.config.ts&lt;/code&gt; 里。文章少写个引号、&lt;code&gt;tags&lt;/code&gt; 写成了字符串，构建直接报错，而不是等到页面上渲染出 &lt;code&gt;undefined&lt;/code&gt;。Zola 的 TOML 头是”写啥是啥”，校验全靠自觉。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;schema: z.object({
  title: z.string(),
  slug: z.string(),
  date: z.coerce.date(),
  tags: z.array(z.string()).default([]),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;2-页面逻辑终于是一门真正的语言&quot;&gt;2. 页面逻辑终于是一门真正的语言&lt;/h3&gt;
&lt;p&gt;标签聚合、按年归档、分页、取”最近用过的 30 个标签”——在 Zola 里要靠 Tera 的 &lt;code&gt;filter&lt;/code&gt;/&lt;code&gt;group_by&lt;/code&gt; 拼，在 Astro 里就是普通 TypeScript：&lt;code&gt;getCollection()&lt;/code&gt; 拿数组，&lt;code&gt;sort&lt;/code&gt;、&lt;code&gt;Map&lt;/code&gt;、&lt;code&gt;slice&lt;/code&gt; 随便用，逻辑还能抽成 &lt;code&gt;src/utils/*.ts&lt;/code&gt; 复用。&lt;/p&gt;
&lt;h3 id=&quot;3-组件化以及能直接借用的生态&quot;&gt;3. 组件化，以及能直接借用的生态&lt;/h3&gt;
&lt;p&gt;页头、侧边栏、文章列表都是可复用的 &lt;code&gt;.astro&lt;/code&gt; 组件；代码高亮、RSS、Sitemap 各装一个官方或社区集成即可。需要交互时再用 islands 局部水合，其余部分保持零 JS——这是模板型 SSG 给不了的。&lt;/p&gt;
&lt;h3 id=&quot;4-心智模型从挑一个主题到拥有一个前端项目&quot;&gt;4. 心智模型：从”挑一个主题”到”拥有一个前端项目”&lt;/h3&gt;
&lt;p&gt;Zola 有&lt;strong&gt;一等公民的 theme&lt;/strong&gt;：&lt;code&gt;config.toml&lt;/code&gt; 里 &lt;code&gt;theme = &quot;...&quot;&lt;/code&gt;，theme 通常是放在 &lt;code&gt;themes/&lt;/code&gt; 的 git submodule，本质是另一个提供 templates / static / sass 的 Zola 项目，你能用同名文件覆盖它。Astro 没有这套”换装”机制——它是一个&lt;strong&gt;你完全拥有的前端项目&lt;/strong&gt;；社区所谓的 “themes” 其实是整套 starter，clone 下来就是你的代码。好处是彻底掌控、能直接接组件与 npm 生态；代价是升级不再”一键换主题”。&lt;/p&gt;
&lt;h2 id=&quot;迁移手记&quot;&gt;迁移手记&lt;/h2&gt;
&lt;h3 id=&quot;代码高亮与语言支持&quot;&gt;代码高亮与语言支持&lt;/h3&gt;
&lt;p&gt;Zola 的高亮内置、开箱即用（syntect），主题是 &lt;code&gt;config.toml&lt;/code&gt; 里的 &lt;code&gt;highlight_theme&lt;/code&gt;（tmTheme）。Astro 默认用 &lt;a href=&quot;https://shiki.style/&quot;&gt;Shiki&lt;/a&gt;（VS Code 主题）；&lt;strong&gt;如果默认的不够用&lt;/strong&gt;，可以再叠加 &lt;a href=&quot;https://expressive-code.com&quot;&gt;Expressive Code&lt;/a&gt;，实现浅色/深色双主题（如 &lt;code&gt;github-light&lt;/code&gt; / &lt;code&gt;github-dark&lt;/code&gt; 跟随 &lt;code&gt;[data-theme]&lt;/code&gt;）、长行自动换行、行标记等需求。&lt;/p&gt;
&lt;p&gt;语言支持上不必担心：Shiki 基于 TextMate 语法，覆盖面很广。像 &lt;code&gt;bash&lt;/code&gt;、&lt;code&gt;yaml&lt;/code&gt;、&lt;code&gt;sql&lt;/code&gt;、&lt;code&gt;go&lt;/code&gt;、&lt;code&gt;powershell&lt;/code&gt;、&lt;code&gt;nginx&lt;/code&gt;、&lt;code&gt;kusto&lt;/code&gt;、&lt;code&gt;regexp&lt;/code&gt;、&lt;code&gt;dotenv&lt;/code&gt;、&lt;code&gt;csv&lt;/code&gt;、&lt;code&gt;cs&lt;/code&gt; 这类常见（以及不少冷门）语言都能直接高亮；真遇到未内置的，再往 Shiki 的 &lt;code&gt;langs&lt;/code&gt; 里补即可。&lt;/p&gt;
&lt;h3 id=&quot;中文标题锚点&quot;&gt;中文标题锚点&lt;/h3&gt;
&lt;p&gt;Zola 会把中文标题转成拼音锚点，迁移时这个行为不能丢。做法是写一个 rehype 插件，用 &lt;code&gt;unidecode&lt;/code&gt; + &lt;code&gt;github-slugger&lt;/code&gt; 生成 id，并让它&lt;strong&gt;在 Astro 自己的标题 id 收集之前&lt;/strong&gt;执行——这样目录（TOC）和实际锚点自动保持一致：&lt;code&gt;安装 uv&lt;/code&gt; → &lt;code&gt;#an-zhuang-uv&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&quot;中文标签--拼音-slug&quot;&gt;中文标签 → 拼音 slug&lt;/h3&gt;
&lt;p&gt;同理，标签和分类也要能变成 URL 安全的路径。做法是抽一个 slug 工具函数，用 &lt;code&gt;unidecode&lt;/code&gt; + &lt;code&gt;github-slugger&lt;/code&gt; 把中文转成拼音，于是 &lt;code&gt;/tags/画图/&lt;/code&gt; 变成 &lt;code&gt;/tags/hua-tu/&lt;/code&gt;；若想保持旧链接不变，可以让它对齐 Zola 原本生成的 slug。&lt;/p&gt;
&lt;h3 id=&quot;接入-tailwind&quot;&gt;接入 Tailwind&lt;/h3&gt;
&lt;p&gt;用官方 Vite 插件即可：在 &lt;code&gt;astro.config.mjs&lt;/code&gt; 的 &lt;code&gt;vite.plugins&lt;/code&gt; 里挂 &lt;code&gt;@tailwindcss/vite&lt;/code&gt;，样式入口写一行 &lt;code&gt;@import &apos;tailwindcss&apos;&lt;/code&gt;。深色模式不依赖系统偏好，而是绑定自己控制的 &lt;code&gt;[data-theme]&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@import &apos;tailwindcss&apos;;
@custom-variant dark (&amp;#x26;:where([data-theme=&apos;dark&apos;], [data-theme=&apos;dark&apos;] *));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;站内链接&quot;&gt;站内链接&lt;/h3&gt;
&lt;p&gt;Zola 的 &lt;code&gt;[文字](@/2024/foo.md)&lt;/code&gt; 会在构建时校验目标是否存在，且移动文件不改链接。迁到 Astro 后一般改成朴素的绝对路径 &lt;code&gt;[文字](/slug/)&lt;/code&gt;——更符合直觉、也更好移植，但&lt;strong&gt;代价是失去了构建期的链接校验&lt;/strong&gt;，需要的话得另加 &lt;code&gt;astro check&lt;/code&gt; 或 link checker 补回。&lt;/p&gt;
&lt;h2 id=&quot;代价&quot;&gt;代价&lt;/h2&gt;
&lt;p&gt;换到 Astro 不是没有成本，得诚实说：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不再是单二进制&lt;/strong&gt;。Zola / Hugo 一个可执行文件就能构建，Astro 需要 Node 工具链和 &lt;code&gt;node_modules&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;构建更慢&lt;/strong&gt;。这点上 Go / Rust 的 SSG 有天然优势。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;依赖会漂移&lt;/strong&gt;。有 &lt;code&gt;package.json&lt;/code&gt; 就有升级、锁版本、偶尔踩坑的日常。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;供应链与合规成本，企业里尤其重要&lt;/strong&gt;。npm 依赖树庞大、CVE 频发；而且&lt;strong&gt;构建期恰恰是 CI 密钥所在之处&lt;/strong&gt;——Shai-Hulud 这类攻击正是靠投毒包的 install 脚本窃取云与代码仓库凭证，所以”只在构建时运行”并不等于安全。更麻烦的是，审计与合规（如 SOC 2 / ISO 27001）通常要求 SBOM 里不存在已知高危 CVE，&lt;strong&gt;无论它能否在运行时被利用&lt;/strong&gt;。相比之下，Zola / Hugo 是单二进制，应用级依赖图几乎为空，维护面小得多。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这不是”Astro 全面碾压”，而是&lt;strong&gt;需求匹配&lt;/strong&gt;：只想把 Markdown 铺成一个极简、构建飞快的站点，Zola、Hugo、Eleventy 依然是极佳选择；但当站点开始长出逻辑、组件和交互，Astro 那套”内容 + 组件 + 类型系统”的组合就明显更趁手。&lt;/p&gt;
&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;
&lt;p&gt;2026 年，静态站点这件事本身没变，变的是我们对它的期待：它不再只是渲染 Markdown，而是一个可以编程、可以复用、可以校验的工程。这次从 Zola 到 Astro 的迁移，真正买到的不是”更炫的框架”，而是把一篇篇内容重新纳入了软件工程该有的约束与实践里。&lt;/p&gt;</content:encoded></item><item><title>OpenWrt 二级路由开启 IPv6 中继（Relay）模式：让内网设备拿到公网 IPv6</title><link>https://engineering.lishechuan.com/openwrt-ipv6-relay-mode/</link><guid isPermaLink="true">https://engineering.lishechuan.com/openwrt-ipv6-relay-mode/</guid><description>拿不到 PD 前缀时，把 OpenWrt 二级路由的 LAN 与 WAN6 全设为 relay，内网设备就能直接用上公网 IPv6。</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近中国联通使用断网逼迫的手段，硬给家里换了一台光猫，之后发现再也无法通过 Tailscale 连接到家里的内网设备了（偶尔可以用过 Relay 的方式建立连接，但是太慢了）。最近 AI模型都降价了，原本可以在外边连回家里，蹬各种 AI 模型来干活，结果被联通的搞坏了。投诉无门，只能自求多福。后来偶尔发现，居然可以用手机通过 4G/5G 网络访问家里的内网设备。经过排查发现，Tailscale 可使用 IPv6 建立 Direct Connection。所以，只要让内网设备拿到公网 IPv6 地址，也许就从外部访问了。遗憾的是，工作电脑在二级路由上，并没有开启 IPv6。最后发现，可以通过在二级路由上开启 IPv6 中继（Relay）模式，让内网设备获取公网 IPv6 地址。&lt;/p&gt;
&lt;h2 id=&quot;案情梳理谁偷走了我的-ipv6&quot;&gt;案情梳理：谁偷走了我的 IPv6&lt;/h2&gt;
&lt;p&gt;先交代一下家里的网络结构，方便各位看官理解这场闹剧：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一级路由（家用路由）：IPv6 设置里的“前缀授权”只有“自动”和“关闭”两个选项，没有“手动指定长度”或“下发至下级”。于是它就从运营商拿到 PD 前缀，自己独吞，绝不往下发。&lt;/li&gt;
&lt;li&gt;OpenWrt 二级路由：接在一级路由下面，想要前缀，要不到。&lt;/li&gt;
&lt;li&gt;工作电脑：接在二级路由下面，嗷嗷待哺，只有一个 fe80:: 链路本地地址。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;换光猫之前，一切岁月静好。换完之后，Tailscale 从大概率可以建立成功的 Direct Connection 退化成 Relay，再退化成直接连不上。排查过程就不细说了，无非是 ping6、traceroute、看日志、骂运营商、再看日志。最终定位到问题：一级路由不下发 IPv6 前缀，导致二级路由无法获取到公网 IPv6，进而内网设备也无法获取公网 IPv6。&lt;/p&gt;
&lt;h2 id=&quot;解法让二级路由放弃治疗改做中继&quot;&gt;解法：让二级路由放弃治疗，改做中继&lt;/h2&gt;
&lt;p&gt;既然从一级路由那里要不到前缀，那就换个思路：二级路由不做独立的 IPv6 路由器了，改行当快递员，把一级路由的 RA / DHCPv6 / NDP 消息原样透传给内网设备。&lt;/p&gt;
&lt;p&gt;这就是 IPv6 中继（Relay）模式。设备拿到的地址直接来自一级路由，网关也指向一级路由，二级路由只在中间当一根透明的管子。&lt;/p&gt;
&lt;p&gt;准确地说：IPv4 流量由二级路由做 NAT 转发，IPv6 流量则绕过二级路由的三层路由，由一级路由统一分配地址并担任网关。二级路由在 IPv6 这一层退化成了二层交换机。&lt;/p&gt;
&lt;h2 id=&quot;配置步骤luci-界面&quot;&gt;配置步骤（LuCI 界面）&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;配置 LAN 接口：进入“网络” → “接口” → “LAN” → “编辑” → “DHCP 服务器” → “IPv6 设置”，将“路由通告服务（RA-Service）”，“DHCPv6 服务（DHCPv6-Service）”和“NDP 代理（NDP-Proxy）”都设置为“中继模式 (relay mode)”。&lt;/li&gt;
&lt;li&gt;配置 WAN6 接口：进入“网络” → “接口” → “WAN6” → “编辑” → “DHCP 服务器” → “IPv6 设置”，保持“忽略此接口”为勾选状态，将“路由通告服务”“DHCPv6 服务”“NDP 代理”都设置为“中继模式”，并勾选“指定的主接口”。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;重连客户端&quot;&gt;重连客户端&lt;/h2&gt;
&lt;p&gt;断开 Wi-Fi 重连，或者重启网络接口，或者就是等几秒。别问，问就是玄学。&lt;/p&gt;
&lt;h2 id=&quot;见证奇迹的时刻&quot;&gt;见证奇迹的时刻&lt;/h2&gt;
&lt;p&gt;中继配置完成后，先确认你的设备到底有没有拿到公网 IPv6：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ifconfig | grep inet6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;成功的话，你会看到类似这样的输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;inet6 2408:xxxx:xxxx:xxxx:3df1:6840:829a:d770/64 scope global temporary dynamic
       valid_lft 86234sec preferred_lft 14234sec
inet6 2408:xxxx:xxxx:xxxx:8b7a:eda:506c:ce6c/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 86234sec preferred_lft 14234sec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重点是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;2408:&lt;/code&gt; 开头：说明是联通公网 IPv6 段，不是 fe80:: 链路本地，也不是 fd00:: ULA。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scope global&lt;/code&gt;：全球可路由。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;temporary dynamic&lt;/code&gt;：隐私扩展地址，对外通信用的就是它，会定期更换。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mngtmpaddr&lt;/code&gt;：稳定模板地址，系统用它来生成临时地址。&lt;/li&gt;
&lt;li&gt;注意 &lt;code&gt;preferred_lft&lt;/code&gt; 是首选期，过了之后系统会生成新的临时地址，旧地址逐渐弃用。这就是临时地址会变的原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果只看到 &lt;code&gt;fe80::&lt;/code&gt; 开头的地址，说明中继没生效，回去检查配置。&lt;/p&gt;
&lt;h2 id=&quot;测试连通性&quot;&gt;测试连通性&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ping6 -c 3 240c::6666
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是联通公共 DNS。通了就说明 IPv6 出海正常。&lt;/p&gt;
&lt;p&gt;再访问 &lt;a href=&quot;https://test-ipv6.com&quot;&gt;https://test-ipv6.com&lt;/a&gt; 看评分。如果不再是 &lt;code&gt;0/10&lt;/code&gt;，恭喜，可以从外网直连回家了。&lt;/p&gt;
&lt;h2 id=&quot;原理简析&quot;&gt;原理简析&lt;/h2&gt;
&lt;p&gt;中继模式下，二级路由不自己分配地址，而是把一级路由的 RA、DHCPv6、NDP 消息原样转发到 LAN 侧。设备收到的 RA 里，前缀来自一级路由，网关也指向一级路由的链路本地地址。&lt;/p&gt;
&lt;p&gt;二级路由的角色从“路由器”退化成了“二层交换机”，只不过它还保留了防火墙和 IPv4 路由的功能。某种意义上，这是一种降级，但在拿不到 PD 前缀的情况下，这是最优雅的解法。&lt;/p&gt;
&lt;h2 id=&quot;注意事项&quot;&gt;注意事项&lt;/h2&gt;
&lt;p&gt;中继打破了 NAT 隔离，运营商和一级路由都能看到每台设备的公网 IPv6 地址。介意的话，对不需要外网的设备用 ULA &lt;code&gt;fd00::/8&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;不过说实话，在联通硬塞光猫的前提下，你的隐私早就不是你能控制的了。呵呵。&lt;/p&gt;
&lt;h2 id=&quot;结果tailscale-复活&quot;&gt;结果：Tailscale 复活&lt;/h2&gt;
&lt;p&gt;配置完成后，在 4G/5G 网络下，Tailscale 从 Relay 切换回 Direct Connection，延迟从 Relay 时的几百毫秒甚至上千毫秒，降到直连时的几十毫秒以内。AI 模型终于可以顺畅地蹬了，钱包表示压力很大。&lt;/p&gt;
&lt;h2 id=&quot;一句话总结&quot;&gt;一句话总结&lt;/h2&gt;
&lt;p&gt;OpenWrt 二级路由拿不到 PD 前缀时，把 LAN 和 WAN6 的 RA / DHCPv6 / NDP 全部设为 relay mode，并在 WAN6 勾选“指定的主接口”，即可让内网设备直接获得上游分配的公网 IPv6 地址。&lt;/p&gt;</content:encoded></item><item><title>在同一台开发机上隔离多个 Git 账号</title><link>https://engineering.lishechuan.com/isolate-multiple-git-accounts/</link><guid isPermaLink="true">https://engineering.lishechuan.com/isolate-multiple-git-accounts/</guid><description>同一台机器上放着多个 Git 身份，怎么保证 push 时用的是对应账号的密钥？</description><pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;同一台开发机上经常会有多个 Git 身份：自己的账号、开源组织的账号，或者不同代码托管平台上的账号。每个身份都有自己的 SSH 密钥，一旦混用，轻则被服务器拒绝，重则把提交推到不该推的账号下。&lt;/p&gt;
&lt;p&gt;与其每次手动切换，不如把目录和密钥的对应关系固定下来，让 Git 按路径自动选择。&lt;/p&gt;
&lt;h2 id=&quot;生成密钥每个身份一把&quot;&gt;生成密钥：每个身份一把&lt;/h2&gt;
&lt;p&gt;先给每套身份生成独立的 Ed25519 密钥，不要复用默认的 &lt;code&gt;id_ed25519&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t ed25519 -C &quot;alpha@example.com&quot; -f ~/.ssh/id_ed25519_alpha
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-t ed25519&lt;/code&gt;：推荐的算法，密钥短、签名快，主流平台都支持&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-f&lt;/code&gt;：指定输出路径，避免覆盖已有的默认密钥&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-C&lt;/code&gt;：注释，写清用途，方便在平台上辨认&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;执行过程中会提示设置 passphrase。建议设置，并把它加入 ssh-agent，免得每次推送都要输入（如果 ssh-agent 尚未运行，先执行 &lt;code&gt;eval &quot;$(ssh-agent -s)&quot;&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-add ~/.ssh/id_ed25519_alpha
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接着收紧权限：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519_alpha
chmod 644 ~/.ssh/id_ed25519_alpha.pub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看公钥，添加到对应平台或服务器：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat ~/.ssh/id_ed25519_alpha.pub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;私钥（没有 &lt;code&gt;.pub&lt;/code&gt; 后缀的那个文件）不要外传，也不要放进同步盘或仓库。另外，同一个公钥不能同时添加到两个 GitHub 账号，每套身份都要单独生成一对。&lt;/p&gt;
&lt;h2 id=&quot;按目录加载不同配置&quot;&gt;按目录加载不同配置&lt;/h2&gt;
&lt;p&gt;前提是不同身份的代码放在不同目录，比如 &lt;code&gt;~/workspace/alpha/&lt;/code&gt; 和 &lt;code&gt;~/workspace/beta/&lt;/code&gt;。Git 的 &lt;code&gt;includeIf&lt;/code&gt; 可以按仓库路径加载不同的配置文件（需要 Git 2.13 及以上）。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;~/.gitconfig&lt;/code&gt; 末尾加入：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[includeIf &quot;gitdir:~/workspace/alpha/&quot;]
    path = ~/.gitconfig-alpha
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后创建 &lt;code&gt;~/.gitconfig-alpha&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[core]
    sshCommand = ssh -i ~/.ssh/id_ed25519_alpha -o IdentitiesOnly=yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gitdir:~/workspace/alpha/&lt;/code&gt; 末尾的 &lt;code&gt;/&lt;/code&gt; 不能省，它表示“该目录下的所有仓库”；不带 &lt;code&gt;/&lt;/code&gt; 只匹配该路径本身。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;IdentitiesOnly=yes&lt;/code&gt; 让 SSH 只使用 &lt;code&gt;-i&lt;/code&gt; 指定的钥匙，不再把 ssh-agent 里的其他密钥逐个递给服务器。没有它，agent 里恰好有另一把被授权的钥匙时，Git 可能以错误的身份通过认证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;之后这个目录下的仓库自动使用 &lt;code&gt;alpha&lt;/code&gt; 的密钥，其他地方不受影响。需要几套身份，就重复几组 &lt;code&gt;includeIf&lt;/code&gt; 和对应的配置文件。&lt;/p&gt;
&lt;h3 id=&quot;只对单个仓库生效&quot;&gt;只对单个仓库生效&lt;/h3&gt;
&lt;p&gt;如果不想按目录管理，进入仓库后直接设置局部配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git config core.sshCommand &quot;ssh -i ~/.ssh/id_ed25519_alpha -o IdentitiesOnly=yes&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个值会写进该仓库的 &lt;code&gt;.git/config&lt;/code&gt;，优先级高于全局配置。&lt;/p&gt;
&lt;h3 id=&quot;顺带切换提交身份&quot;&gt;顺带切换提交身份&lt;/h3&gt;
&lt;p&gt;SSH 密钥决定“以什么身份连接”，&lt;code&gt;user.name&lt;/code&gt; 和 &lt;code&gt;user.email&lt;/code&gt; 决定“以什么身份提交”，两者要一起改。在 &lt;code&gt;~/.gitconfig-alpha&lt;/code&gt; 里再加一段：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[user]
    name = Alpha
    email = alpha@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样该目录下仓库的提交作者也不会串。&lt;/p&gt;
&lt;h3 id=&quot;验证&quot;&gt;验证&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;~/workspace/alpha/&lt;/code&gt; 下任意仓库里查看配置来源：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git config --show-origin --get core.sshCommand
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--show-origin&lt;/code&gt; 会显示这条配置来自哪个文件，能确认 &lt;code&gt;includeIf&lt;/code&gt; 确实命中了。再验证密钥本身：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -i ~/.ssh/id_ed25519_alpha -o IdentitiesOnly=yes -T git@github.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这条命令直接调用 ssh，不会读取 Git 的 &lt;code&gt;core.sshCommand&lt;/code&gt;，所以要手动带上同样的参数。若要连真实仓库测试：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git ls-remote git@github.com:your-org/your-repo.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;每个身份单独生成一对 Ed25519 密钥，私钥不外传，公钥只加到对应账号。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;includeIf&lt;/code&gt; 按目录加载配置，用 &lt;code&gt;core.sshCommand&lt;/code&gt; 指定密钥，并加上 &lt;code&gt;IdentitiesOnly=yes&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;密钥之外，别忘了同目录下的 &lt;code&gt;user.name&lt;/code&gt; 和 &lt;code&gt;user.email&lt;/code&gt;，否则连接身份对了，提交作者还是会串。&lt;/li&gt;
&lt;li&gt;临时切换可以只对单个仓库设置 &lt;code&gt;core.sshCommand&lt;/code&gt;，不必动全局配置。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;参考&quot;&gt;参考&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-config#_conditional_includes&quot;&gt;git-config: Conditional includes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://git-scm.com/docs/git-config#Documentation/git-config.txt-coresshCommand&quot;&gt;git-config: core.sshCommand&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent&quot;&gt;Generating a new SSH key and adding it to the ssh-agent&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>远程访问 TP-LINK 路由器管理控制台</title><link>https://engineering.lishechuan.com/remote-access-to-tplink-router-console/</link><guid isPermaLink="true">https://engineering.lishechuan.com/remote-access-to-tplink-router-console/</guid><description>本地端口转发被强制跳转，改用 SSH 动态转发建立 SOCKS5 代理后成功打开管理页面。</description><pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;TP-LINK 路由器大部分管理操作都可以在手机客户端操作，但某些高级设置，必须连接路由器的局域网才可以修改。如果可以通过远程访问路由器中的某个机器，那么就可以通过这台机器进行相关的高级设置操作。&lt;/p&gt;
&lt;p&gt;如果能登录远程的局域网中的 Windows 机器，那很简单，在远程桌面里打开浏览器访问路由器的管理界面即可。对于没有桌面的 Linux 机器而言，可以通过 SSH 登录到这台机器，然后使用命令行工具或者通过端口转发的方式访问路由器的管理界面。&lt;/p&gt;
&lt;h2 id=&quot;方法一本地端口转发失败&quot;&gt;方法一：本地端口转发（失败）&lt;/h2&gt;
&lt;p&gt;假定路由器的局域网 IP 为 &lt;code&gt;192.168.1.1&lt;/code&gt;，期望通过本地的 &lt;code&gt;8888&lt;/code&gt; 端口访问路由器的管理界面，可以使用如下命令进行本地端口转发：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -L 8888:192.168.1.1:80 用户名@IP
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浏览器访问 &lt;a href=&quot;http://localhost:8888&quot;&gt;http://localhost:8888&lt;/a&gt;，会被 路由器的强制跳转到 &lt;a href=&quot;http://tplogin.cn&quot;&gt;http://tplogin.cn&lt;/a&gt;，并返回公网的页面。失败！&lt;/p&gt;
&lt;h2 id=&quot;方法二ssh-动态转发--socks5-代理成功&quot;&gt;方法二：SSH 动态转发 / SOCKS5 代理（成功）&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -D 1080 用户名@IP
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在浏览器中设置 SOCKS5 代理为 &lt;code&gt;localhost:1080&lt;/code&gt;（可使用 Chrome 扩展 Zero Omega）。配置好后直接访问 &lt;a href=&quot;http://192.168.1.1&quot;&gt;http://192.168.1.1&lt;/a&gt; ，管理页面正常打开。成功！&lt;/p&gt;
&lt;p&gt;成功原因：浏览器所有请求都通过 SSH 隧道由远程主机代为发出。路由器前端看到的请求主机名为 &lt;code&gt;192.168.1.1&lt;/code&gt;，因此不会触发强制跳转，管理页面可以正常访问。&lt;/p&gt;</content:encoded></item><item><title>Tailscale 因 DHCPv6 地址导致无法直连排查记录</title><link>https://engineering.lishechuan.com/tailscale-direct-connection-dhcpv6-source-address-armbian/</link><guid isPermaLink="true">https://engineering.lishechuan.com/tailscale-direct-connection-dhcpv6-source-address-armbian/</guid><description>为什么 Tailscale 始终走 DERP 中继？根因是 DHCPv6 分配的 /128 地址被内核选作了默认源地址。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在手机上通过 tailscale 访问 Armbian 设备，始终无法建立 Direct Connection。最终定位到根因是 DHCPv6 分配的 /128 地址被内核选为默认源地址，导致 IPv6 回包无法送达。在 Netplan 中添加 &lt;code&gt;ethernets&lt;/code&gt; 段并指定 &lt;code&gt;ipv6-address-generation: stable-privacy&lt;/code&gt; 后解决。&lt;/p&gt;
&lt;h2 id=&quot;问题&quot;&gt;问题&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;设备：Armbian ARM 盒子（ab01）&lt;/li&gt;
&lt;li&gt;网络管理：Netplan + NetworkManager&lt;/li&gt;
&lt;li&gt;问题：从手机访问 ab01 时，Tailscale 连接始终走 DERP 中继（欧洲节点，~450ms），&lt;code&gt;tailscale netcheck&lt;/code&gt; 显示 &lt;code&gt;IPv6: no, unavailable in OS&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;对比：同局域网的 Linux x86桌面电脑（w01）却能正常直连&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;排查过程&quot;&gt;排查过程&lt;/h2&gt;
&lt;h3 id=&quot;确认-ipv6-地址和路由都正常&quot;&gt;确认 IPv6 地址和路由都正常&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;$ ip -6 addr show eth0 | grep inet6
    inet6 2408:xxxx:xxxx:xxxx::1001/128 scope global dynamic noprefixroute
    inet6 2408:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx/64 scope global dynamic noprefixroute
    inet6 fe80::xxxx:xxxx:xxxx:xxxx/64 scope link noprefixroute

$ ip -6 route show default
default via fe80::xxxx:xxxx:xxxx:xxxx dev eth0 proto ra metric 100 pref medium
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设备同时拿到 &lt;code&gt;/128&lt;/code&gt;（DHCPv6）和 &lt;code&gt;/64&lt;/code&gt;（SLAAC）两个公网地址。路由和防火墙均无异常。&lt;/p&gt;
&lt;h3 id=&quot;定位到能发不能收&quot;&gt;定位到“能发不能收”&lt;/h3&gt;
&lt;p&gt;先用 &lt;code&gt;nc -6 -u&lt;/code&gt; 测试 UDP 发送：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ nc -6 -u -z -w 3 2400:3200::1 53 &amp;#x26;&amp;#x26; echo &quot;UDP OK&quot; || echo &quot;UDP FAIL&quot;
Connection to 2400:3200::1 53 port [udp/domain] succeeded!
UDP OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但这只能证明“包发出去了”，不能证明“能收到回包”。UDP 是无连接协议，-z 只是尝试发送，没有握手确认。&lt;/p&gt;
&lt;p&gt;于是用一个 Python 脚本做更完整的测试：向 IPv6 的公共 DNS 服务器（&lt;code&gt;2400:3200::1&lt;/code&gt;）发送一个 DNS 查询包，然后等待接收服务器返回的结果。如果收到回应，说明 IPv6 收发都正常；如果超时，说明回包进不来。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ python3 -c &quot;
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(5)
query = b&apos;\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05baidu\x03com\x00\x00\x01\x00\x01&apos;
try:
    s.sendto(query, (&apos;2400:3200::1&apos;, 53))
    data, addr = s.recvfrom(512)
    print(&apos;收到回应，长度:&apos;, len(data))
except Exception as e:
    print(&apos;未收到回应:&apos;, e)
finally:
    s.close()
&quot;
未收到回应: timed out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果是超时，定位到问题是“能发不能收”。&lt;/p&gt;
&lt;h3 id=&quot;找到根因源地址选错&quot;&gt;找到根因：源地址选错&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;$ python3 -c &quot;
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.connect((&apos;2400:3200::1&apos;, 53))
print(&apos;本地地址:&apos;, s.getsockname())
s.close()
&quot;
本地地址: (&apos;2408:xxxx:xxxx:xxxx::1001&apos;, 49906, 0, 0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;内核默认选了 &lt;code&gt;/128&lt;/code&gt; 的 DHCPv6 地址作为源地址。该地址没有对应的 on-link 路由，回包到达光猫后无法送回设备。&lt;/p&gt;
&lt;p&gt;强制绑定 SLAAC 的 &lt;code&gt;/64&lt;/code&gt; 地址发送同样的 DNS 查询，立刻收到回应，确认根因。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ python3 -c &quot;
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(5)
s.bind((&apos;2408:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx&apos;, 0))
query = b&apos;\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05baidu\x03com\x00\x00\x01\x00\x01&apos;
try:
    s.sendto(query, (&apos;2400:3200::1&apos;, 53))
    data, addr = s.recvfrom(512)
    print(&apos;收到回应，长度:&apos;, len(data))
except Exception as e:
    print(&apos;未收到回应:&apos;, e)
finally:
    s.close()
&quot;
收到回应，长度: 91
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;解决方案&quot;&gt;解决方案&lt;/h2&gt;
&lt;p&gt;确认系统用的是 Netplan：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ls /etc/netplan/
00-default-use-network-manager.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原始文件只有：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;network:
  version: 2
  renderer: NetworkManager
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在文件中添加 ethernets 段，为 eth0 指定 IPv6 配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;network:
  version: 2
  renderer: NetworkManager
  ethernets:
    eth0:
      dhcp4: true
      dhcp6: false
      accept-ra: true
      ipv6-address-generation: stable-privacy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;应用配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;netplan apply
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ipv6-address-generation: stable-privacy&lt;/code&gt; 明确告诉 NetworkManager 使用 RFC7217 方式生成 SLAAC 地址的接口标识符。这个参数能被 Netplan 正确识别并翻译给 NetworkManager，从而完整触发 SLAAC 流程，绕过了 &lt;code&gt;accept-ra&lt;/code&gt; 翻译丢失的问题。&lt;/p&gt;
&lt;h2 id=&quot;验证&quot;&gt;验证&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;ip -6 addr show eth0 | grep inet6   # 确认 IPv6 地址存在
tailscale netcheck                   # 确认 IPv6: yes
tailscale ping ab01                 # 确认直连建立
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重启设备，再确认一次。&lt;/p&gt;
&lt;h2 id=&quot;发现&quot;&gt;发现&lt;/h2&gt;
&lt;h3 id=&quot;手机流量可以走-ipv6-直连&quot;&gt;手机流量可以走 IPv6 直连&lt;/h3&gt;
&lt;p&gt;排查中发现：用手机流量连接 ab01 时，有时能建立 IPv6 直连，有时却走中继。原因是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;手机流量通常有公网 IPv6：4G/5G 网络普遍下发 IPv6 地址，且没有 NAT 转换。如果 ab01 也有可用的公网 IPv6，双方直接通过 IPv6 互通，不需要打洞。&lt;/li&gt;
&lt;li&gt;ab01 缺少公网 IPv6 时：手机只能靠 IPv4 打洞。但手机流量的 IPv4 通常处于严格的 CGNAT 后面，打洞成功率低，所以回退到中继。&lt;/li&gt;
&lt;li&gt;同局域网内设备表现不同：w01 能直连而 ab01 不能，正是因为 w01 有公网 IPv6，而 ab01 当时没有。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;不对称直连是正常现象&quot;&gt;不对称直连是正常现象&lt;/h3&gt;
&lt;p&gt;Tailscale 的每条连接都是双向独立建立的。两端各自尝试用自己能用的最佳路径去连对方，最终可能形成一条“不对称”的连接路径（一端走 IPv4，另一端走 IPv6）。只要至少一个方向能直连，整体连接就不会走中继。&lt;/p&gt;
&lt;h2 id=&quot;总结&quot;&gt;总结&lt;/h2&gt;
&lt;p&gt;Tailscale 的 &lt;code&gt;ipv6os=false&lt;/code&gt; 不一定代表系统没有 IPv6，也可能是源地址选择错误导致 UDP 回包收不到。排查时重点验证 UDP 的接收能力，而不只是发送。&lt;/p&gt;
&lt;p&gt;在 Armbian + Netplan + NetworkManager 环境下，&lt;code&gt;accept-ra: true&lt;/code&gt; 可能无法正确触发 SLAAC。在 Netplan 中添加 &lt;code&gt;ethernets&lt;/code&gt; 段并指定 &lt;code&gt;ipv6-address-generation: stable-privacy&lt;/code&gt;，可以显式引导 NetworkManager 走 SLAAC 流程，是持久有效的解决方案。&lt;/p&gt;</content:encoded></item><item><title>释放 Windows 系统上的磁盘空间</title><link>https://engineering.lishechuan.com/free-up-disk-space-on-windows/</link><guid isPermaLink="true">https://engineering.lishechuan.com/free-up-disk-space-on-windows/</guid><description>Windows 更新后 C:\Windows.old 动辄占用十几 GB，结合存储设置与磁盘清理可快速找回空间。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;不知道从什么时候开始，磁盘空间总是显得不够用。尤其在 Windows 更新之后……&lt;/p&gt;
&lt;h2 id=&quot;操作步骤&quot;&gt;操作步骤&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;使用 &lt;a href=&quot;https://www.jam-software.com/treesize&quot;&gt;TreeSize Free&lt;/a&gt; 分析磁盘，识别大文件。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;打开“设置”，进入“系统” - “存储”，点击 临时文件，系统会自动开始扫描。可以看到 &lt;code&gt;Installed apps&lt;/code&gt;, &lt;code&gt;Temporary files&lt;/code&gt;, &lt;code&gt;Other&lt;/code&gt; 等选项。进入每一项，查看磁盘占用详情，然后删除。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;使用“磁盘清理”（Disk Cleanup）清理系统更新残留:如果刚刚完成过 Windows 大版本更新，系统中可能会留有数 GB 到十余 GB 的旧系统备份。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;心得&quot;&gt;心得&lt;/h2&gt;
&lt;p&gt;心得：在 Windows 更新之后，&lt;code&gt;Temporary files&lt;/code&gt; 中往往会积累大量的旧系统文件，比如我的遭遇：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Previous Windows Installations - 27.3 GB&lt;/li&gt;
&lt;li&gt;Windows upgrade log files - 2.39GB&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另外，&lt;code&gt;C:\Windows.old&lt;/code&gt; 通常是旧系统的保留目录，它会在 Windows 升级后自动生成，用于保留旧版本系统文件，以便在需要时回退。这个目录有时会占用数 GB 甚至更多的硬盘空间。&lt;/p&gt;</content:encoded></item><item><title>修复基于 Debian/Ubuntu 的 Docker 基础镜像中的 CVE 漏洞</title><link>https://engineering.lishechuan.com/fix-cve-debian-ubuntu-docker-base-images/</link><guid isPermaLink="true">https://engineering.lishechuan.com/fix-cve-debian-ubuntu-docker-base-images/</guid><description>官方 Debian/Ubuntu 基础镜像常残留 CVE，在 Dockerfile 里 apt-get upgrade 一键补齐安全补丁。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;使用 &lt;code&gt;python:3.14-slim&lt;/code&gt; 或 &lt;code&gt;node:20-slim&lt;/code&gt; 等官方镜像时，上游更新可能并不及时，镜像内预装的 Debian/Ubuntu 系统包（如 &lt;code&gt;openssl&lt;/code&gt;、&lt;code&gt;glibc&lt;/code&gt;）仍可能存在 CVE 漏洞。&lt;/p&gt;
&lt;h3 id=&quot;解决方案&quot;&gt;解决方案&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;Dockerfile&lt;/code&gt; 紧跟 &lt;code&gt;FROM&lt;/code&gt; 之后加入以下命令，一键升级系统包并清理缓存：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;RUN apt-get update \
    &amp;#x26;&amp;#x26; apt-get upgrade -y \
    &amp;#x26;&amp;#x26; apt-get clean \
    &amp;#x26;&amp;#x26; rm -rf /var/lib/apt/lists/*
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;要点解析&quot;&gt;要点解析&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;apt-get upgrade -y&lt;/code&gt;：升级系统中所有可升级的软件包，打满系统软件包的最新安全补丁。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rm -rf /var/lib/apt/lists/*&lt;/code&gt; + &lt;code&gt;apt-get clean&lt;/code&gt;：彻底清理索引与缓存，控制镜像体积。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;最佳实践构建命令&quot;&gt;最佳实践（构建命令）&lt;/h3&gt;
&lt;p&gt;在 CI/CD 中构建时，建议配合以下参数使用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker build --pull --no-cache -t my-app .
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--pull&lt;/code&gt;：强制拉取最新的基础镜像（确保 upstream 修复也能同步进来）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--no-cache&lt;/code&gt;：跳过本地构建缓存，确保执行最新的 apt-get upgrade。&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Windows 上清理 Docker 残留文件的方法</title><link>https://engineering.lishechuan.com/clean-up-docker-on-windows/</link><guid isPermaLink="true">https://engineering.lishechuan.com/clean-up-docker-on-windows/</guid><description>卸载 Docker Desktop 后 C 盘残留数十 GB 镜像层？用 Robocopy 的 /MIR 就能强行清空 windowsfilter 目录。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;卸载 Docker Desktop 后，如果之前使用过 &lt;strong&gt;Windows 容器&lt;/strong&gt;（Windows Containers），系统常常会在 C 盘残留数 GB 到数十 GB 的容器镜像层文件。这些残留内容通常不能通过常规方式直接删除，因此很容易让磁盘空间被“吞掉”。&lt;/p&gt;
&lt;h2 id=&quot;1-核心残留位置&quot;&gt;1. 核心残留位置&lt;/h2&gt;
&lt;p&gt;最常见的目录是：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;C:\ProgramData\docker\windowsfilter&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这也是 Docker 在 Windows 容器模式下生成的历史镜像层和容器文件根目录。&lt;/p&gt;
&lt;h2 id=&quot;2-传统清理方式为什么常常失败&quot;&gt;2. 传统清理方式为什么常常失败？&lt;/h2&gt;
&lt;p&gt;直接使用右键删除、PowerShell &lt;code&gt;Remove-Item&lt;/code&gt; 或 CMD &lt;code&gt;rmdir&lt;/code&gt; / &lt;code&gt;icacls&lt;/code&gt; 时，通常会报错失败，原因主要有三类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;权限不足（Access Denied）&lt;/strong&gt;：文件所有者常为 &lt;code&gt;SYSTEM&lt;/code&gt; 或 &lt;code&gt;TrustedInstaller&lt;/code&gt;，普通管理员权限往往无法直接操作。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;路径过长（The system cannot find the path specified）&lt;/strong&gt;：&lt;code&gt;windowsfilter&lt;/code&gt; 中的目录层级非常深，容易超过 Windows 默认的 260 字符 &lt;code&gt;MAX_PATH&lt;/code&gt; 限制，导致传统清理命令在中途失败。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;服务占用&lt;/strong&gt;：尝试停止 &lt;code&gt;hns&lt;/code&gt; / &lt;code&gt;vmcompute&lt;/code&gt; 服务来释放锁时，常常受到企业电脑策略、安全软件或权限限制影响，导致无法正常关闭。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;3-终极解决方案使用-robocopy-强制清空&quot;&gt;3. 终极解决方案：使用 Robocopy 强制清空&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Robocopy&lt;/strong&gt;（Robust File and Folder Copy）是 Windows 自带的强大命令行文件管理工具。它原生支持绕过 260 字符路径限制，并且可以通过“镜像同步”机制直接覆盖目标目录中的内容，绕过部分权限和文件锁问题。&lt;/p&gt;
&lt;h2 id=&quot;清理步骤在管理员-cmd-中执行&quot;&gt;清理步骤（在管理员 CMD 中执行）&lt;/h2&gt;
&lt;p&gt;无需提前关闭 &lt;code&gt;vmcompute&lt;/code&gt; 或 &lt;code&gt;hns&lt;/code&gt; 服务，直接在&lt;strong&gt;以管理员身份运行&lt;/strong&gt;的命令提示符（CMD）中依次执行即可：&lt;/p&gt;
&lt;h3 id=&quot;创建一个临时空目录&quot;&gt;创建一个临时空目录&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmd&quot;&gt;mkdir C:\empty_temp
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;使用-robocopy-强制镜像清空&quot;&gt;使用 Robocopy 强制镜像清空&lt;/h3&gt;
&lt;p&gt;执行以下命令，用空目录去“镜像覆盖”残留目录。在这一步中，屏幕上可能会不断输出 &lt;code&gt;EXTRA&lt;/code&gt;，这表示 Robocopy 正在逐个删除超长路径下的残留文件和目录：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmd&quot;&gt;robocopy C:\empty_temp C:\ProgramData\docker\windowsfilter /MIR /R:0 /W:0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;删除已清空的目录&quot;&gt;删除已清空的目录&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-cmd&quot;&gt;rmdir /s /q C:\ProgramData\docker\windowsfilter
rmdir /s /q C:\empty_temp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;待 Robocopy 运行结束并输出统计信息后，直接删除空目录 &lt;code&gt;C:\empty_temp&lt;/code&gt; 和残留目录 &lt;code&gt;C:\ProgramData\docker\windowsfilter&lt;/code&gt; 即可。&lt;/p&gt;</content:encoded></item><item><title>AKS 在 Azure CNI Pod Subnet 模式下的流量路由</title><link>https://engineering.lishechuan.com/aks-pod-outbound-ip-azure-cni-nat-gateway/</link><guid isPermaLink="true">https://engineering.lishechuan.com/aks-pod-outbound-ip-azure-cni-nat-gateway/</guid><description>为什么 Node 子网没挂 NAT Gateway，Pod 出网却走了 Pod 子网的 NAT Gateway？答案藏在 podSubnetID 里。</description><pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在排查一个 AKS Pod 的网络出站流量时，我发现了一个奇怪的现象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Node 所在子网&lt;/strong&gt;：&lt;code&gt;nodes&lt;/code&gt;（&lt;strong&gt;没有&lt;/strong&gt;关联 NAT Gateway）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pod 出站出口 IP&lt;/strong&gt;：匹配的是子网 &lt;code&gt;pods&lt;/code&gt; 上绑定的 NAT Gateway 公网 IP。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为什么 Node 所在子网明明没有挂载 NAT Gateway，运行在它上面的 Pod 出网却走到了另一个子网的 NAT Gateway？&lt;/p&gt;
&lt;h2 id=&quot;排查过程与原因&quot;&gt;排查过程与原因&lt;/h2&gt;
&lt;p&gt;第一反应是去 Azure Portal 检查 AKS 的网络配置，但在 Portal UI 的集群和 NodePool 页面上，只展示了 Node 所在的子网（&lt;code&gt;nodes&lt;/code&gt;），完全找不到 &lt;code&gt;pods&lt;/code&gt; 的影子。&lt;/p&gt;
&lt;p&gt;为了搞清楚真相，我导出了 AKS 节点的底层资源 JSON 属性（或通过 Azure CLI 查看）。最终在 &lt;strong&gt;&lt;code&gt;agentPoolProfiles&lt;/code&gt;&lt;/strong&gt; 配置中找到了答案：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;agentPoolProfiles&quot;: [
  {
    &quot;name&quot;: &quot;nodepool1&quot;,
    &quot;vnetSubnetID&quot;: &quot;/subscriptions/.../subnets/nodes&quot;,
    &quot;podSubnetID&quot;: &quot;/subscriptions/.../subnets/pods&quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结合 Networking 页面，可以确认集群使用的是 Azure CNI Pod Subnet（动态 IP 分配）模式。&lt;/p&gt;
&lt;p&gt;在该模式下，AKS 会将 Node 与 Pod 的 IP 分离在两个不同的子网中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node Subnet：专门为 VMSS 节点分配 IP（例如 &lt;code&gt;nodes&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;Pod Subnet：专门为 Pod 动态分配 Azure VNet 内的真实 IP（例如 &lt;code&gt;pods&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在物理网络层面，流量的路由逻辑是：流量从谁所在的子网吐出，就走哪个子网绑定的路由/NAT Gateway规则。&lt;/p&gt;
&lt;p&gt;因此，即便 Node 所在的子网没有绑定 NAT Gateway，只要 Pod Subnet 绑定了 NAT Gateway，Pod 的公网请求就会直接从 Pod Subnet 吐出，带上该 NAT Gateway 的出口 IP。&lt;/p&gt;
&lt;h2 id=&quot;横向对比其他-aks-网络模式的流量出口行为&quot;&gt;横向对比：其他 AKS 网络模式的流量出口行为&lt;/h2&gt;
&lt;p&gt;以下是 Gemini 给出的不同网络模式的对比：&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;网络模式&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Pod 的 IP 来源&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;物理网络出口归属&lt;/th&gt;&lt;th align=&quot;left&quot;&gt;公网出站行为逻辑&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Azure CNI Pod Subnet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Pod 专用子网&lt;/strong&gt; (&lt;code&gt;pods&lt;/code&gt;)&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Pod Subnet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;流量直接从 Pod 子网发出，&lt;strong&gt;只受 Pod 子网的 NAT Gateway / UDR 路由控制&lt;/strong&gt;，忽略 Node 子网的配置。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Azure CNI（标准模式）&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Node 节点子网&lt;/strong&gt; (&lt;code&gt;nodes&lt;/code&gt;)&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Node Subnet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Pod 和 Node 共享同一个物理子网，Pod 的出站出口 IP &lt;strong&gt;完全取决于 Node 子网&lt;/strong&gt; 绑定了什么（如 NAT Gateway 或负载均衡器）。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Azure CNI Overlay&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;集群内部虚拟网段&lt;/strong&gt; (如 &lt;code&gt;10.244.0.0/16&lt;/code&gt;)&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Node Subnet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Pod IP 在 VNet 中不存在。流量在发出宿主机前会被 Node 做一层 &lt;strong&gt;SNAT&lt;/strong&gt;（源地址改写为 Node IP），&lt;strong&gt;出口完全受 Node 子网的路由和 NAT 策略控制&lt;/strong&gt;。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Kubenet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;集群内部虚拟网段&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Node Subnet&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;left&quot;&gt;同样采用 Node 侧 SNAT 机制，出口流量完全依赖 &lt;strong&gt;Node 子网&lt;/strong&gt; 的出口路由配置。&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;总结与踩坑心得&quot;&gt;总结与踩坑心得&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;出口由底层物理网卡子网决定：在 Azure CNI Pod Subnet 模式下，Pod 拥有真实的 VNet 子网身份。决定 Pod 出站 IP 的是 podSubnetID 绑定的规则，而非 vnetSubnetID（Node 子网）。&lt;/li&gt;
&lt;li&gt;Azure Portal UI 并不展示全貌：Portal 界面有时会隐去 podSubnetID 这种高级网络配置。当发现网络行为与 UI 显示不符时，直接查看 ARM Resource JSON 或使用 Azure CLI 查询 agentPoolProfiles 才是最准确的依据。&lt;/li&gt;
&lt;/ol&gt;</content:encoded></item><item><title>为 Azure Database for PostgreSQL flexible server 创建 Private Endpoint</title><link>https://engineering.lishechuan.com/create-private-endpoint-for-azure-database-for-postgresql-flexible-server/</link><guid isPermaLink="true">https://engineering.lishechuan.com/create-private-endpoint-for-azure-database-for-postgresql-flexible-server/</guid><description>相比 VNet Integration，Public Access + Private Endpoint 能把私网暴露范围缩小到单个数据库实例，更利于隔离与合规。</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在产品环境中，PSQL Server 和应用通常处于不同的网络环境中，甚至位于不同的 Subscription。Azure Database for PostgreSQL Flexible Server 常见的两种接入方式是 &lt;code&gt;VNet Integration&lt;/code&gt; 和 &lt;code&gt;Public Access + Private Endpoint&lt;/code&gt;。如果希望最小化网络打通范围，并降低安全与合规边界扩散的风险，通常更适合使用 &lt;code&gt;Private Endpoint&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id=&quot;比较&quot;&gt;比较&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;VNet Integration&lt;/code&gt;：将 PostgreSQL Flexible Server 部署到委派子网中，再通过 &lt;code&gt;VNet Peering&lt;/code&gt; 让应用网络与数据库网络互通。它本质上是把两个网络打通。这样虽然结构直接，但也可能带来额外的安全和合规工作。例如应用所在网络是 PCI 网络时，网络互通后，数据库所在网络通常也需要进入同等安全边界评估范围。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Public Access + Private Endpoint&lt;/code&gt;：数据库保留公共入口能力（但可完全关闭），但客户端优先通过 &lt;code&gt;Private Endpoint&lt;/code&gt; 从自己的 VNet 私网访问。相比之下，&lt;code&gt;Private Endpoint&lt;/code&gt; 更适合只想把“某一个具体 PaaS 实例”私网暴露给消费方，而不希望扩大网络信任边界的场景。Private Link 连接的是某个资源实例，而不是整个服务或整个 VNet，因此更容易控制访问范围，也更有利于隔离和审计。&lt;/p&gt;
&lt;p&gt;可以简单理解为：&lt;/p&gt;























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;方案&lt;/th&gt;&lt;th&gt;网络模型&lt;/th&gt;&lt;th&gt;安全边界&lt;/th&gt;&lt;th&gt;适用场景&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;VNet Integration + Peering&lt;/code&gt;&lt;/td&gt;&lt;td&gt;VNet 级别互通&lt;/td&gt;&lt;td&gt;边界更大，联通后要一起评估&lt;/td&gt;&lt;td&gt;同一信任域内，长期稳定互通&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Public Access + Private Endpoint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;资源实例级别私网接入&lt;/td&gt;&lt;td&gt;边界更小，更利于隔离&lt;/td&gt;&lt;td&gt;跨团队、跨 Subscription、合规要求较严&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;微软强调 &lt;code&gt;Private Endpoint&lt;/code&gt; 的&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/postgresql/network/concepts-networking-private-link&quot;&gt;几个优势&lt;/a&gt;，本质上可以概括为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;只把特定的 PostgreSQL 实例私网暴露给客户端，而不是打通整张网络。&lt;/li&gt;
&lt;li&gt;可以从本地网络、VPN、ExpressRoute 或已对等互联的网络私下访问，不需要走公网。&lt;/li&gt;
&lt;li&gt;有助于降低数据泄露风险，因为访问范围限定在具体资源实例上。&lt;/li&gt;
&lt;li&gt;支持跨 Region 私网访问，客户端和服务端不必位于同一区域。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;使用-private-endpoint-的步骤&quot;&gt;使用 Private Endpoint 的步骤&lt;/h2&gt;
&lt;p&gt;假设数据库和应用在不同的Subscription，且数据库在 A Subscription，应用在 B Subscription。要让应用通过 Private Endpoint 访问数据库，需要完成以下步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在 A Subscription 中创建 Azure Database for PostgreSQL flexible server 时，启用 &lt;code&gt;Public Access&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;在 B Subscription 中按需创建 &lt;code&gt;PrivateDnsZone&lt;/code&gt;，并通过 &lt;code&gt;VirtualNetworkLink&lt;/code&gt; 将 Private DNS Zone 与应用的 VNet 关联。&lt;/li&gt;
&lt;li&gt;在 B Subscription 中的客户端网络所在的 VNet 中创建 &lt;code&gt;Private Endpoint&lt;/code&gt;，将其关联到目标子网和 PSQL Server；&lt;code&gt;private_link_service_connections&lt;/code&gt; 可以使用自动批准或手动批准模式。&lt;/li&gt;
&lt;li&gt;在 B Subscription 中的客户端网络所在的 VNet 中创建 &lt;code&gt;PrivateDnsZoneGroup&lt;/code&gt;，将 &lt;code&gt;PrivateDnsZone&lt;/code&gt; 与 &lt;code&gt;Private Endpoint&lt;/code&gt; 关联起来。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;需要注意的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Private Endpoint 的 DNS 解析通常依赖于 Private DNS Zone，因此需要在客户端网络中配置 Private DNS Zone，并将其与客户端 VNet 关联。这样，客户端才能将数据库域名解析到 Private Endpoint 的私有 IP 地址。&lt;/li&gt;
&lt;li&gt;对于同一种 Private DNS Zone（比如 &lt;code&gt;privatelink.postgres.database.azure.com&lt;/code&gt;），一个 VNet 通常只关联一个实例。因此，如果客户端网络需要访问多个 Azure Database for PostgreSQL flexible server，通常会复用同一个 Private DNS Zone，并在该 Zone 中为不同数据库维护各自的 DNS 记录。需要注意的是，如果依赖 &lt;code&gt;PrivateDnsZoneGroup&lt;/code&gt; 的自动关联和 Azure 的自动记录管理机制，最好额外验证这些记录不会被覆盖或删除；在更复杂的场景下，也可以采用集中 DNS 管理或手动校验记录的方式。&lt;/li&gt;
&lt;li&gt;虽然在 Azure Portal 中可以从数据库页面发起 Private Endpoint 的创建流程，但 Private Endpoint 本身是创建在客户端 VNet 中的资源，因此通常应由客户端网络所在 Subscription 的管理员来创建。如果当前 Identity 只能管理本 Subscription 内的资源，无法同时完成两侧操作，那么更合适的做法是先在客户端侧创建 &lt;code&gt;Manual Private Link Connection&lt;/code&gt;，再由数据库所在 Subscription 的管理员审批该连接请求。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;参考文档&quot;&gt;参考文档&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure/postgresql/network/concepts-networking-private-link&quot;&gt;Networking with Private Link in Azure Database for PostgreSQL flexible server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>通过 IIS URL Rewrite 还原原始请求协议（HTTPS）</title><link>https://engineering.lishechuan.com/iis-url-rewrite-restore-original-request-scheme/</link><guid isPermaLink="true">https://engineering.lishechuan.com/iis-url-rewrite-restore-original-request-scheme/</guid><description>TLS 在网关终止后 ASP.NET 仍看到 HTTP，为什么要在 IIS 层用 URL Rewrite 提前还原协议。</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;将应用部署到 Kubernetes 集群时，通常会使用 Ingress Controller、Gateway 等组件完成 TLS 终止（TLS Termination），即客户端与 Ingress Controller 之间使用 HTTPS 协议，而 Ingress Controller 与服务之间使用 HTTP 协议。默认情况下，ASP.NET 会根据请求的协议来判断是否使用 HTTPS，但此时 ASP.NET 看到的请求是 HTTP，因此会误判为非 HTTPS 请求，从而导致一些功能无法正常工作，比如生成的重定向 URL 不正确，或者安全相关的功能失效。解决方法之一，就是使用 URL Rewrite 模块还原请求的协议。&lt;/p&gt;
&lt;h2 id=&quot;安装-url-rewrite-模块&quot;&gt;安装 URL Rewrite 模块&lt;/h2&gt;
&lt;p&gt;Windows 镜像默认没有安装 URL Rewrite 模块，需要手动安装。可以从 &lt;a href=&quot;https://www.iis.net/downloads/microsoft/url-rewrite&quot;&gt;IIS 官方网站&lt;/a&gt; 下载并安装 URL Rewrite 模块，然后根据请求头里的 &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; 值来还原请求的协议。&lt;/p&gt;
&lt;p&gt;在 Dockerfile 中，可以添加以下指令来安装和配置：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;
RUN $ErrorActionPreference = &apos;Stop&apos;; \
    Write-Host &apos;Downloading IIS URL Rewrite Module...&apos;; \
    $msi = Join-Path $env:TEMP &apos;rewrite_amd64.msi&apos;; \
    (New-Object Net.WebClient).DownloadFile(&apos;https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi&apos;, $msi); \
    Write-Host &apos;Installing URL Rewrite Module...&apos;; \
    $proc = Start-Process msiexec.exe -ArgumentList &apos;/i&apos;, $msi, &apos;/quiet&apos;, &apos;/norestart&apos; -NoNewWindow -Wait -PassThru; \
    if ($proc.ExitCode -ne 0) { throw (&apos;URL Rewrite install failed with exit code &apos; + $proc.ExitCode) }; \
    Remove-Item $msi -Force; \
    Write-Host &apos;Configuring global URL Rewrite rule for gateway TLS termination...&apos;; \
    $config = &apos;C:\Windows\System32\inetsrv\config\applicationHost.config&apos;; \
    [xml]$xml = Get-Content $config; \
    $webServer = $xml.configuration.&apos;system.webServer&apos;; \
    $rewrite = $webServer.rewrite; \
    if (-not $rewrite) { \
    $rewrite = $webServer.AppendChild($xml.CreateElement(&apos;rewrite&apos;)) \
    }; \
    $fragment = $xml.CreateDocumentFragment(); \
    $fragment.InnerXml = &apos;&amp;#x3C;allowedServerVariables&gt;&amp;#x3C;add name=&apos;&apos;HTTPS&apos;&apos; /&gt;&amp;#x3C;add name=&apos;&apos;SERVER_PORT&apos;&apos; /&gt;&amp;#x3C;/allowedServerVariables&gt;&amp;#x3C;globalRules&gt;&amp;#x3C;rule name=&apos;&apos;Fix-Forwarded-Proto&apos;&apos;&gt;&amp;#x3C;match url=&apos;&apos;.*&apos;&apos; /&gt;&amp;#x3C;conditions&gt;&amp;#x3C;add input=&apos;&apos;{HTTP_X_FORWARDED_PROTO}&apos;&apos; pattern=&apos;&apos;https&apos;&apos; /&gt;&amp;#x3C;/conditions&gt;&amp;#x3C;serverVariables&gt;&amp;#x3C;set name=&apos;&apos;HTTPS&apos;&apos; value=&apos;&apos;on&apos;&apos; /&gt;&amp;#x3C;set name=&apos;&apos;SERVER_PORT&apos;&apos; value=&apos;&apos;443&apos;&apos; /&gt;&amp;#x3C;/serverVariables&gt;&amp;#x3C;action type=&apos;&apos;None&apos;&apos; /&gt;&amp;#x3C;/rule&gt;&amp;#x3C;/globalRules&gt;&apos;; \
    $null = $rewrite.AppendChild($fragment); \
    $xml.Save($config); \
    Write-Host &apos;URL Rewrite rule successfully applied.&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;配置的变化&quot;&gt;配置的变化&lt;/h2&gt;
&lt;p&gt;使用上面的命令安装和配置 URL Rewrite 模块后，&lt;code&gt;C:\Windows\System32\inetsrv\config\applicationHost.config&lt;/code&gt; 文件中会新增以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;#x3C;configuration&gt;
  &amp;#x3C;!-- 其他配置 --&gt;
  &amp;#x3C;configSections&gt;
    &amp;#x3C;sectionGroup name=&quot;system.webServer&quot;&gt;
      &amp;#x3C;!-- 以下为新增 --&gt;
      &amp;#x3C;sectionGroup name=&quot;rewrite&quot;&gt;
        &amp;#x3C;section name=&quot;globalRules&quot; overrideModeDefault=&quot;Deny&quot; allowDefinition=&quot;AppHostOnly&quot; /&gt;
        &amp;#x3C;section name=&quot;rules&quot; overrideModeDefault=&quot;Allow&quot; /&gt;
        &amp;#x3C;section name=&quot;outboundRules&quot; overrideModeDefault=&quot;Allow&quot; /&gt;
        &amp;#x3C;section name=&quot;providers&quot; overrideModeDefault=&quot;Allow&quot; /&gt;
        &amp;#x3C;section name=&quot;rewriteMaps&quot; overrideModeDefault=&quot;Allow&quot; /&gt;
        &amp;#x3C;section name=&quot;allowedServerVariables&quot; overrideModeDefault=&quot;Deny&quot; /&gt;
      &amp;#x3C;/sectionGroup&gt;
      &amp;#x3C;!-- 以上为新增 --&gt;
    &amp;#x3C;/sectionGroup&gt;
    &amp;#x3C;!-- 其他 --&gt;
  &amp;#x3C;/configSections&gt;

  &amp;#x3C;system.webServer&gt;
    &amp;#x3C;!-- 其他配置 --&gt;
    &amp;#x3C;globalModules&gt;
      &amp;#x3C;!-- 以下为新增 --&gt;
      &amp;#x3C;add name=&quot;RewriteModule&quot; image=&quot;%SystemRoot%\system32\inetsrv\rewrite.dll&quot; /&gt;
      &amp;#x3C;!-- 以上为新增 --&gt;
    &amp;#x3C;/globalModules&gt;

    &amp;#x3C;tracing&gt;
      &amp;#x3C;traceProviderDefinitions&gt;
        &amp;#x3C;add name=&quot;WWW Server&quot; guid=&quot;{......}&quot;&gt;
          &amp;#x3C;areas&gt;
            &amp;#x3C;!-- 以下为新增 --&gt;
            &amp;#x3C;add name=&quot;Rewrite&quot; value=&quot;1024&quot; /&gt;
            &amp;#x3C;!-- 以上为新增 --&gt;
          &amp;#x3C;/areas&gt;
        &amp;#x3C;/add&gt;
        &amp;#x3C;!-- 其他配置 --&gt;
      &amp;#x3C;/traceProviderDefinitions&gt;
    &amp;#x3C;/tracing&gt;
    &amp;#x3C;!-- 以下为新增 --&gt;
    &amp;#x3C;rewrite&gt;
      &amp;#x3C;allowedServerVariables&gt;
        &amp;#x3C;add name=&quot;HTTPS&quot; /&gt;
        &amp;#x3C;add name=&quot;SERVER_PORT&quot; /&gt;
      &amp;#x3C;/allowedServerVariables&gt;
      &amp;#x3C;globalRules&gt;
        &amp;#x3C;rule name=&quot;Fix-Forwarded-Proto&quot;&gt;
          &amp;#x3C;match url=&quot;.*&quot; /&gt;
          &amp;#x3C;conditions&gt;
            &amp;#x3C;add input=&quot;{HTTP_X_FORWARDED_PROTO}&quot; pattern=&quot;https&quot; /&gt;
          &amp;#x3C;/conditions&gt;
          &amp;#x3C;serverVariables&gt;
            &amp;#x3C;set name=&quot;HTTPS&quot; value=&quot;on&quot; /&gt;
            &amp;#x3C;set name=&quot;SERVER_PORT&quot; value=&quot;443&quot; /&gt;
          &amp;#x3C;/serverVariables&gt;
          &amp;#x3C;action type=&quot;None&quot; /&gt;
        &amp;#x3C;/rule&gt;
      &amp;#x3C;/globalRules&gt;
    &amp;#x3C;/rewrite&gt;
    &amp;#x3C;!-- 以上为新增 --&gt;
    &amp;#x3C;modules&gt;
      &amp;#x3C;!-- 以下为新增 --&gt;
      &amp;#x3C;add name=&quot;RewriteModule&quot; /&gt;
      &amp;#x3C;!-- 以上为新增 --&gt;
    &amp;#x3C;/modules&gt;
  &amp;#x3C;/system.webServer&gt;
&amp;#x3C;/configuration&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;为何不在代码中实现&quot;&gt;为何不在代码中实现？&lt;/h2&gt;
&lt;p&gt;在应用代码中根据 &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; 恢复请求协议当然也是可行的，例如在 ASP.NET 中使用 Forwarded Headers Middleware。&lt;/p&gt;
&lt;p&gt;但在 IIS 场景下，某些重定向可能发生在请求进入应用之前。比较常见的是 IIS 对路径末尾斜杠（trailing slash）的规范化跳转，这类跳转由 IIS 直接返回响应，请求不会进入应用代码。&lt;/p&gt;
&lt;p&gt;常见例子包括：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;访问目录但 URL 没有末尾斜杠，例如 &lt;code&gt;/docs&lt;/code&gt; 被规范化为 &lt;code&gt;/docs/&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;访问子应用根路径但没有末尾斜杠，例如 &lt;code&gt;/myapp&lt;/code&gt; 被规范化为 &lt;code&gt;/myapp/&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;目录配合默认文档时，请求 &lt;code&gt;/portal&lt;/code&gt; 往往会先跳转到 &lt;code&gt;/portal/&lt;/code&gt;，再由 IIS 返回默认文档。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果只在代码层处理协议恢复，这类“代码之外”的跳转仍可能使用 HTTP 视角生成结果，导致跳转协议不符合预期。将协议恢复放在 IIS URL Rewrite 层处理，可以更早生效，覆盖这类前置处理路径。&lt;/p&gt;</content:encoded></item><item><title>ArgoCD 接入 Azure Entra ID 单点登录</title><link>https://engineering.lishechuan.com/argocd-azure-entra-id-sso/</link><guid isPermaLink="true">https://engineering.lishechuan.com/argocd-azure-entra-id-sso/</guid><description>从 App Registration 到 Federated Credentials，一步步把 ArgoCD 接入 Azure Entra ID 的免密 SSO。</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;ArgoCD 可以与 Azure Entra ID，实现单点登录（SSO）。本文将介绍如何在 Azure Entra ID 中配置 ArgoCD 的 SSO。&lt;/p&gt;
&lt;h2 id=&quot;创建-app-registration&quot;&gt;创建 App Registration&lt;/h2&gt;
&lt;p&gt;在 Azure 创建了 App Registration，系统就会在后台自动创建一个对应的 Enterprise Application。然后，将 App Registration 和 Enterprise Application 的所有者添加到 Azure Entra ID 中，并批准该应用程序。&lt;/p&gt;
&lt;h2 id=&quot;配置-sso&quot;&gt;配置 SSO&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;添加 Redirect URI （类型选 Web，值设为 &lt;code&gt;https://&amp;#x3C;argocd-url&gt;/auth/callback&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;添加 groups claims。这样就可以通过 Azure Entra ID 的组来控制 ArgoCD 的访问权限： 在 &lt;code&gt;App Registration&lt;/code&gt; 的配置中找到 &lt;code&gt;Token configuration&lt;/code&gt;。点击添加 &lt;code&gt;groups claims&lt;/code&gt;，勾选 &lt;code&gt;Groups assigned to the application&lt;/code&gt;，并选择&lt;code&gt;Group ID of ID, Access and SAML&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;添加-federated-credentials&quot;&gt;添加 Federated Credentials&lt;/h2&gt;
&lt;p&gt;在 App Registration 的 &lt;code&gt;Certificates &amp;#x26; secrets&lt;/code&gt; 中，点击 &lt;code&gt;Federated Credentials&lt;/code&gt; 来添加 AKS 的 issuer，以及 ArgoCD Server 的 Namespace 和 Service Account，这样就能实现“无密码”（Secretless）的身份验证。也就是说，AKS 会在 ArgoCD 的 Pod 注入环境变量 &lt;code&gt;AZURE_FEDERATED_TOKEN_FILE&lt;/code&gt;，它指向的文件是 AKS 的 Service Account Token 文件，ArgoCD 读取该文件内容，作为 client_assertion（客户端断言）发送给 Azure 的令牌端点 &lt;code&gt;/token&lt;/code&gt;，获取 Access Token。&lt;/p&gt;
&lt;p&gt;注意： issuer URL 必须与 AKS 集群的 issuerURL 完全一致，包括末尾的斜杠（/）。&lt;/p&gt;
&lt;h2 id=&quot;添加用户&quot;&gt;添加用户&lt;/h2&gt;
&lt;p&gt;在 Enterprise Application 中，进入 &lt;code&gt;Manage - Users and Groups&lt;/code&gt; 来添加 Role Assignments，以便用户或者用户组可以访问。&lt;/p&gt;</content:encoded></item><item><title>使用 az aks command invoke 重启 AKS 中的 Deployment</title><link>https://engineering.lishechuan.com/restart-aks-deployment-with-az-aks-command-invoke/</link><guid isPermaLink="true">https://engineering.lishechuan.com/restart-aks-deployment-with-az-aks-command-invoke/</guid><description>用 az aks command invoke 远程执行 kubectl rollout restart，注意它的退出码并不是 kubectl 的退出码。</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;当重启 Kubernetes 部署时，通常会使用 &lt;code&gt;kubectl rollout restart deployment &amp;#x3C;deployment-name&gt; -n &amp;#x3C;namespace&gt;&lt;/code&gt; 命令。然而，在某些情况下，可能无法直接访问 AKS 集群的 Kubernetes API，此时可以使用 Azure CLI 的 &lt;code&gt;az aks command invoke&lt;/code&gt; 命令来执行该操作。&lt;/p&gt;
&lt;h2 id=&quot;前置权限&quot;&gt;前置权限&lt;/h2&gt;
&lt;p&gt;需要具备在目标 AKS 集群上执行 Run Command 的 Azure RBAC 权限。实践中，仅有 Kubernetes RBAC 的 Kubernetes Admin 往往不足以执行 &lt;code&gt;az aks command invoke&lt;/code&gt;，建议至少具备该 AKS 资源范围的 Contributor 权限。&lt;/p&gt;
&lt;h2 id=&quot;重启-deployment&quot;&gt;重启 Deployment&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az aks command invoke --name &amp;#x3C;cluster-name&gt; --resource-group &amp;#x3C;resource-group&gt; --subscription &quot;&amp;#x3C;subscription-id-or-name&gt;&quot; --command &quot;kubectl rollout restart deployment &amp;#x3C;deployment-name&gt; -n &amp;#x3C;namespace&gt;&quot; --output json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意，该命令的退出码为 &lt;code&gt;az&lt;/code&gt; 命令的退出码，并不是 &lt;code&gt;kubectl&lt;/code&gt; 命令的退出码。即使 &lt;code&gt;kubectl&lt;/code&gt; 命令执行失败，&lt;code&gt;az aks command invoke&lt;/code&gt; 的退出码仍然可能是 &lt;code&gt;0&lt;/code&gt;。好在这个命令返回的 JSON 格式的结果里，包含了 &lt;code&gt;exitCode&lt;/code&gt; 字段，表示 &lt;code&gt;kubectl&lt;/code&gt; 命令的退出码。可以通过解析该字段来判断 &lt;code&gt;kubectl&lt;/code&gt; 命令是否执行成功。&lt;/p&gt;
&lt;p&gt;比如当 Deployment 不存在时，返回的 JSON 结果如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;exitCode&quot;: 1,
  &quot;finishedAt&quot;: &quot;2026-07-13T12:57:22+00:00&quot;,
  &quot;id&quot;: &quot;b1c77d81f142404d825db5f8a490f05a&quot;,
  &quot;logs&quot;: &quot;Error from server (NotFound): deployments.apps \&quot;abc\&quot; not found\n&quot;,
  &quot;provisioningState&quot;: &quot;Succeeded&quot;,
  &quot;reason&quot;: null,
  &quot;startedAt&quot;: &quot;2026-07-13T12:57:17+00:00&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另外，&lt;code&gt;kubectl rollout restart&lt;/code&gt; 执行后会立即返回，并不会等待重启完成。如果想知道重启结果，还需要使用 &lt;code&gt;kubectl rollout status&lt;/code&gt; 命令查看 Deployment 状态。可以执行如下命令等待重启完成：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;az aks command invoke --name &amp;#x3C;cluster-name&gt; --resource-group &amp;#x3C;resource-group&gt; --subscription &quot;&amp;#x3C;subscription-id-or-name&gt;&quot; --command &quot;kubectl rollout status deployment &amp;#x3C;deployment-name&gt; -n &amp;#x3C;namespace&gt;&quot; --output json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;exitCode&quot;: 0,
  &quot;finishedAt&quot;: &quot;2026-07-13T12:59:59+00:00&quot;,
  &quot;id&quot;: &quot;826c6fba27ae45eeb28956d6f32a6e20&quot;,
  &quot;logs&quot;: &quot;....&quot;,
  &quot;provisioningState&quot;: &quot;Succeeded&quot;,
  &quot;reason&quot;: null,
  &quot;startedAt&quot;: &quot;2026-07-13T12:59:54+00:00&quot;
}
&lt;/code&gt;&lt;/pre&gt;</content:encoded></item></channel></rss>