<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Certbot on Ambertu</title><link>https://ambertu.com/tags/certbot/</link><description>Recent content in Certbot on Ambertu</description><generator>Hugo</generator><language>zh-CN</language><copyright>2026 Ambertu · 粤ICP备18071259号</copyright><lastBuildDate>Fri, 04 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://ambertu.com/tags/certbot/index.xml" rel="self" type="application/rss+xml"/><item><title>Caddy 还是 nginx 给 API 中转做反代：证书自动化是决定性差异</title><link>https://ambertu.com/posts/caddy-vs-nginx-api-gateway-tls/</link><pubDate>Fri, 04 Sep 2026 00:00:00 +0000</pubDate><guid>https://ambertu.com/posts/caddy-vs-nginx-api-gateway-tls/</guid><description>反代能力上 Caddy 和 nginx 都够用，真正的分水岭是证书生命周期谁来管。对 API 中转站来说证书过期不是「用户看到警告」，而是所有 SDK 客户端硬失败的全站中断。本文含一个共享域名下 ACME 验证被吞掉的真实坑。</description><content:encoded><![CDATA[<p>给大模型 API 中转选反向代理，网上的对比文章大多在比性能、比配置语法、比模块生态。这些差异对一个 API 网关来说其实都不重要——<strong>真正的分水岭是证书生命周期由谁管理。</strong></p>
<p>原因很简单：对普通网站，证书过期是&quot;用户看到一个可怕的警告，但能点继续&quot;；对 API 网关，证书过期是<strong>所有客户端硬失败的全站中断</strong>。Python 的 <code>httpx</code>、<code>requests</code>、OpenAI SDK 全都会直接抛 <code>SSLError</code>，没有&quot;点继续&quot;这个选项。而且报错文本是 <code>certificate verify failed</code>，第一眼看着像客户端环境问题，会把人引到完全错误的方向排查。</p>
<h2 id="一张手动证书的真实时间线">一张手动证书的真实时间线</h2>
<p>我手上一台机器用的是 nginx 加商业 DV 证书（TrustAsia 签发），实测有效期：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">notBefore = Jun 18 08:00:00 2026 GMT
</span></span><span class="line"><span class="cl">notAfter  = Sep 16 07:59:59 2026 GMT
</span></span></code></pre></div><p>正好 90 天。查证书那天是 9 月 3 日，<strong>距到期 13 天</strong>。</p>
<p>这台机器上跑着 API 服务和一个静态站，证书是手工申请、手工安装的。它现在没坏，问题是<strong>它的存续完全依赖&quot;有人记得&quot;</strong>——记得到期日、记得去续、记得装上去、记得 reload。这三个月里只要有一次没记住，就是一次全站中断。</p>
<figure>
<svg viewBox="0 0 800 260" role="img" aria-label="对比图：手动证书在 90 天有效期末尾依赖人工记得续期，存在中断风险窗口；ACME 自动续期在生命周期约三分之二处自动更新，永远不会接近到期日" style="width:100%;height:auto;color:currentColor">
  <defs>
    <marker id="c1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor"/>
    </marker>
  </defs>
  <text x="0" y="14" font-size="10.5" font-family="monospace" fill="currentColor" opacity="0.6">90 天证书生命周期</text>
  <!-- manual -->
  <text x="0" y="52" font-size="10.5" font-family="monospace" fill="currentColor">手动</text>
  <rect x="70" y="38" width="600" height="20" fill="none" stroke="currentColor" stroke-width="1.3"/>
  <rect x="583" y="38" width="87" height="20" fill="currentColor" opacity="0.22"/>
  <text x="70" y="76" font-size="9" font-family="monospace" fill="currentColor" opacity="0.65">签发</text>
  <text x="670" y="76" font-size="9" font-family="monospace" fill="currentColor" opacity="0.65" text-anchor="end">到期</text>
  <line x1="583" y1="90" x2="583" y2="62" stroke="currentColor" stroke-width="1.2" marker-end="url(#c1)"/>
  <text x="583" y="104" font-size="9.5" font-family="monospace" fill="currentColor" text-anchor="middle" font-weight="bold">今天 · 剩 13 天</text>
  <text x="676" y="52" font-size="9.5" font-family="monospace" fill="currentColor" font-weight="bold">⚠</text>
  <text x="70" y="122" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">灰色区间是风险窗口：全靠有人记得。忘一次 = 一次全站中断。</text>
  <line x1="0" y1="140" x2="780" y2="140" stroke="currentColor" stroke-width="1" opacity="0.2"/>
  <!-- auto -->
  <text x="0" y="180" font-size="10.5" font-family="monospace" fill="currentColor">ACME</text>
  <rect x="70" y="166" width="600" height="20" fill="none" stroke="currentColor" stroke-width="1.3"/>
  <line x1="470" y1="160" x2="470" y2="192" stroke="currentColor" stroke-width="1.4" stroke-dasharray="4 3"/>
  <text x="470" y="152" font-size="9.5" font-family="monospace" fill="currentColor" text-anchor="middle">约 2/3 处自动续期</text>
  <path d="M 470 202 L 560 202" stroke="currentColor" stroke-width="1.2" marker-end="url(#c1)" opacity="0.6"/>
  <text x="568" y="206" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">还有 30 天缓冲用于重试</text>
  <text x="70" y="232" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">永远不会接近右端。续期失败还有一个月时间告警和补救。</text>
</svg>
<figcaption>
两者的差别不是"自动比手动省事"，而是<strong>失败模式完全不同</strong>：
手动方案的失败是静默的、突然的、全量的；ACME 方案即使续期失败，也还有约 30 天窗口留给告警和人工介入。
</figcaption>
</figure>
<p>顺带说一个正在发生的趋势，它会让手动方案越来越站不住：<strong>证书有效期正在被行业强制缩短。</strong> 按 CA/Browser Forum 已通过的决议，TLS 证书最长有效期会分阶段从原来的一年多压到 200 天、100 天，最终到 47 天量级。手动续期在一年一次的年代还算可行，压到两个月一次就必然出事——<strong>这不是效率问题，是可靠性问题。</strong></p>
<h2 id="两者的实际差异">两者的实际差异</h2>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Caddy</th>
					<th>nginx</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>证书管理</strong></td>
					<td><strong>内置 ACME，默认自动申请与续期</strong></td>
					<td>本身不管，需要 certbot + 定时器</td>
			</tr>
			<tr>
					<td>反代能力</td>
					<td>够用</td>
					<td>够用</td>
			</tr>
			<tr>
					<td>流式 / SSE</td>
					<td>对 <code>text/event-stream</code> 默认刷新</td>
					<td>需显式关 <code>proxy_buffering</code> 和 <code>gzip</code></td>
			</tr>
			<tr>
					<td>复杂 rewrite / 正则路由</td>
					<td>能做，但表达力弱一些</td>
					<td><strong>更强，且久经考验</strong></td>
			</tr>
			<tr>
					<td>模块生态</td>
					<td>较小</td>
					<td><strong>大得多</strong></td>
			</tr>
			<tr>
					<td>配置语法</td>
					<td>Caddyfile，简洁</td>
					<td>熟悉的人多</td>
			</tr>
			<tr>
					<td>内存占用</td>
					<td>略高（Go 运行时）</td>
					<td>略低</td>
			</tr>
			<tr>
					<td>出网要求</td>
					<td><strong>需要能访问 ACME 目录</strong></td>
					<td>无</td>
			</tr>
	</tbody>
</table>
<p>看这张表要抓重点：<strong>除了第一行和最后一行，其余差异对一个 API 网关都不构成决策依据。</strong> 反代能力两边都够，性能差异在 LLM 场景里被上游生成耗时完全淹没——你的瓶颈是模型在想，不是代理在转发。</p>
<p>Caddy 的代价也要说清，别只看好处：</p>
<ul>
<li><strong>需要出网访问 ACME 目录。</strong> 内网隔离环境、或出网被严格管控的机器上，自动签发根本走不通，这时 Caddy 的核心优势直接消失。</li>
<li><strong><code>on_demand_tls</code> 是个陷阱。</strong> 配错了会给任意 Host 头签发证书，等于把签发配额敞开给别人刷。要用就必须配 <code>ask</code> 端点做白名单校验。</li>
<li><strong>复杂路由场景 nginx 更稳。</strong> 如果你已经有一大堆 nginx 的 <code>location</code> 规则和正则，重写成 Caddyfile 的风险可能高于收益。</li>
</ul>
<h2 id="不换-nginx-的话certbot-要配对">不换 nginx 的话，certbot 要配对</h2>
<p>大多数人的现实是 nginx 配置已经很复杂、不想动。那就把证书自动化补上，同样能解决问题：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 首次签发。--deploy-hook 是关键，否则证书换了但 nginx 还在用内存里的旧的</span>
</span></span><span class="line"><span class="cl">certbot certonly <span class="se">\
</span></span></span><span class="line"><span class="cl">  --webroot -w /var/www/acme <span class="se">\
</span></span></span><span class="line"><span class="cl">  -d ambertu.com -d www.ambertu.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --deploy-hook <span class="s2">&#34;systemctl reload nginx&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 确认自动续期的定时器真的在跑 —— 这一步很多人跳过</span>
</span></span><span class="line"><span class="cl">systemctl list-timers <span class="p">|</span> grep certbot
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 演练一次，别等到真到期才发现签发链路是坏的</span>
</span></span><span class="line"><span class="cl">certbot renew --dry-run
</span></span></code></pre></div><p>三个容易漏的点：</p>
<ul>
<li><strong><code>--deploy-hook</code> 不能省。</strong> 证书文件换了，但 nginx 不会自己重新读——不 reload 就等于没续。</li>
<li><strong><code>systemctl list-timers</code> 必须确认。</strong> certbot 的自动续期靠 systemd timer（或 cron），装了 certbot 不代表定时器启用了。</li>
<li><strong><code>--dry-run</code> 要定期跑。</strong> 签发链路可能因为 DNS 变更、防火墙调整、challenge 路径被占而悄悄失效，<code>--dry-run</code> 是唯一能提前发现的手段。</li>
</ul>
<h2 id="一个共享域名下的真实坑acme-验证被吞掉">一个共享域名下的真实坑：ACME 验证被吞掉</h2>
<p>这个坑值得单独讲，因为它只在&quot;一个域名下住着多个应用&quot;时出现，而这种架构相当常见。</p>
<p>HTTP-01 验证要求 <code>http://your-domain/.well-known/acme-challenge/&lt;token&gt;</code> 能被访问到。问题是：<strong>这个路径会命中哪个 <code>location</code>？</strong></p>
<figure>
<svg viewBox="0 0 820 300" role="img" aria-label="示意图：共享域名下 ACME 验证路径的两种命中情况。未加专用 location 时验证路径落入站点根目录并被静态站的 try_files 返回 404 或被部署脚本清除；加了专用 location 后验证路径指向独立稳定目录，签发成功" style="width:100%;height:auto;color:currentColor">
  <defs>
    <marker id="c2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M 0 1 L 10 5 L 0 9 z" fill="currentColor"/>
    </marker>
  </defs>
  <text x="0" y="14" font-size="10.5" font-family="monospace" fill="currentColor" opacity="0.6">GET /.well-known/acme-challenge/TOKEN 会命中哪个 location？</text>
  <!-- bad -->
  <text x="0" y="46" font-size="10.5" font-family="monospace" fill="currentColor" opacity="0.6">❌ 没有专用 location</text>
  <g font-family="monospace" font-size="10" fill="currentColor">
    <rect x="0" y="58" width="130" height="36" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="65" y="80" text-anchor="middle">certbot</text>
    <line x1="130" y1="76" x2="196" y2="76" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <rect x="198" y="58" width="150" height="36" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="273" y="80" text-anchor="middle">nginx location /</text>
    <line x1="348" y1="76" x2="414" y2="76" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <text x="381" y="68" text-anchor="middle" font-size="8.5" opacity="0.65">try_files</text>
    <rect x="416" y="58" width="240" height="36" fill="currentColor" fill-opacity="0.1" stroke="currentColor" stroke-width="1.3"/>
    <text x="536" y="76" text-anchor="middle" font-size="9">站点根目录/.well-known/…</text>
    <text x="536" y="88" text-anchor="middle" font-size="8.5" opacity="0.7">文件不在这里</text>
    <line x1="656" y1="76" x2="700" y2="76" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <text x="712" y="80" font-weight="bold">404</text>
  </g>
  <text x="0" y="120" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">签发失败，报错只说"验证不通过"，不会告诉你是哪个 location 吃掉了请求。</text>
  <text x="0" y="138" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">即使把 webroot 指对了，下一次部署静态站又会把 .well-known 目录清掉。</text>
  <line x1="0" y1="156" x2="800" y2="156" stroke="currentColor" stroke-width="1" opacity="0.2"/>
  <!-- good -->
  <text x="0" y="188" font-size="10.5" font-family="monospace" fill="currentColor" opacity="0.6">✓ 加一个专用 location，指向独立目录</text>
  <g font-family="monospace" font-size="10" fill="currentColor">
    <rect x="0" y="200" width="130" height="36" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="65" y="222" text-anchor="middle">certbot</text>
    <line x1="130" y1="218" x2="196" y2="218" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <rect x="198" y="200" width="150" height="36" fill="currentColor" fill-opacity="0.1" stroke="currentColor" stroke-width="1.5"/>
    <text x="273" y="216" text-anchor="middle" font-size="8.5">location</text>
    <text x="273" y="229" text-anchor="middle" font-size="8">/.well-known/acme-challenge/</text>
    <line x1="348" y1="218" x2="414" y2="218" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <text x="381" y="210" text-anchor="middle" font-size="8.5" opacity="0.65">前缀更长</text>
    <rect x="416" y="200" width="240" height="36" fill="none" stroke="currentColor" stroke-width="1.3"/>
    <text x="536" y="218" text-anchor="middle" font-size="9">/var/www/acme/.well-known/…</text>
    <line x1="656" y1="218" x2="700" y2="218" stroke="currentColor" stroke-width="1.2" marker-end="url(#c2)"/>
    <text x="712" y="222" font-weight="bold">200</text>
  </g>
  <text x="0" y="262" font-size="9.5" font-family="monospace" fill="currentColor" opacity="0.75">前缀 location 更长，优先命中；目录独立于任何应用的部署产物，部署脚本碰不到它。</text>
  <text x="0" y="284" font-size="9.5" font-family="monospace" fill="currentColor" font-weight="bold">这个 location 必须在 80 端口的 server 块里 —— 别被 301 到 443 拦掉（见下）。</text>
</svg>
<figcaption>
共享域名下这个坑有两重：<strong>验证路径可能落到错误的目录，而正确的目录又可能被部署脚本清空。</strong>
一个指向独立目录的专用 <code>location</code> 同时解决两个问题。
</figcaption>
</figure>
<p>正确的配法：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="c1"># 80 端口的 server 块里 —— 顺序很重要
</span></span></span><span class="line"><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">server_name</span> <span class="s">ambertu.com</span> <span class="s">www.ambertu.com</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># ACME 验证必须放在 301 之前，否则请求会被跳转走
</span></span></span><span class="line"><span class="cl">    <span class="kn">location</span> <span class="s">/.well-known/acme-challenge/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kn">root</span> <span class="s">/var/www/acme</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kn">return</span> <span class="mi">301</span> <span class="s">https://</span><span class="nv">$host$request_uri</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p><strong>顺序是关键。</strong> 如果 80 端口的 server 块里只有一句 <code>return 301 https://...</code>，那么 ACME 的验证请求会被 301 走。Let&rsquo;s Encrypt 会跟随跳转到 443，所以这种情况有时也能成——但如果 443 上的 <code>location /</code> 又用 <code>try_files ... =404</code> 兜底，请求最终还是 404。加一个显式的前缀 <code>location</code> 才是确定的做法，不依赖跳转链路碰巧走对。</p>
<p>再说一遍那个连带的坑：<strong>验证目录要放在任何应用的部署产物之外。</strong> 如果你把 webroot 指向静态站的根目录，而部署脚本用的是 <code>rsync --delete</code> 式的镜像同步，<code>.well-known</code> 会在下一次发版时被删掉——证书在三个月后续期失败，而当时没有人会想起两个月前改过部署脚本。</p>
<h2 id="混合方案">混合方案</h2>
<p>如果 nginx 配置太复杂不想重写，但又想要自动证书，可以让 Caddy 只做 TLS 终止：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">外网 → Caddy :443（自动证书） → nginx :8080（全部现有路由逻辑） → 各应用
</span></span></code></pre></div><p>Caddyfile 简单到几行：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">ambertu.com</span>, <span class="gh">www.ambertu.com</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">8080</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">flush_interval</span> <span class="s">-1</span><span class="c1">          # 保住 SSE 流式
</span></span></span><span class="line"><span class="cl">        <span class="k">header_up</span> <span class="s">X-Real-IP</span> <span class="se">{remote_host}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>代价是多一跳（本机回环，延迟可忽略），以及 nginx 那边要改成监听 8080 且信任来自本机的 <code>X-Real-IP</code>——这一步别搞错，否则所有客户端 IP 都会变成 <code>127.0.0.1</code>，按 IP 的限流会一起误伤。</p>
<h2 id="无论用哪个把到期日变成告警">无论用哪个：把到期日变成告警</h2>
<p>这是这篇文章最该带走的一条。<strong>再好的自动化也可能静默失效</strong>，所以到期监控是独立于方案选择的必需品：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="c1"># /usr/local/bin/cert-expiry-check.sh</span>
</span></span><span class="line"><span class="cl"><span class="nv">DOMAIN</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="nv">ambertu</span><span class="p">.com</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">THRESHOLD</span><span class="o">=</span><span class="m">20</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">END</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="p">|</span> openssl s_client -connect <span class="s2">&#34;</span><span class="nv">$DOMAIN</span><span class="s2">:443&#34;</span> -servername <span class="s2">&#34;</span><span class="nv">$DOMAIN</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="p">|</span> openssl x509 -noout -enddate <span class="p">|</span> cut -d<span class="o">=</span> -f2<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> -z <span class="s2">&#34;</span><span class="nv">$END</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;无法获取证书：</span><span class="nv">$DOMAIN</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">exit</span> 2<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">DAYS</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span> <span class="k">$(</span>date -d <span class="s2">&#34;</span><span class="nv">$END</span><span class="s2">&#34;</span> +%s<span class="k">)</span> <span class="o">-</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">)</span> <span class="o">/</span> <span class="m">86400</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$DOMAIN</span><span class="s2"> 证书剩余 </span><span class="nv">$DAYS</span><span class="s2"> 天（到期 </span><span class="nv">$END</span><span class="s2">）&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$DAYS</span><span class="s2">&#34;</span> -lt <span class="s2">&#34;</span><span class="nv">$THRESHOLD</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;低于 </span><span class="nv">$THRESHOLD</span><span class="s2"> 天，需要处理&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="m">0</span>
</span></span></code></pre></div><p>两点设计考虑：</p>
<ul>
<li><strong>从外部探测，而不是读本地证书文件。</strong> 读文件只能证明&quot;磁盘上那份还有效&quot;，探端口才能证明&quot;nginx 实际吐出去的那份还有效&quot;。两者不一致正是&quot;续了但忘了 reload&quot;这个经典故障的样子。</li>
<li><strong>阈值设 20 天而不是 3 天。</strong> 要留出的不是&quot;续期需要的时间&quot;，而是&quot;发现问题、排查、找人、处理&quot;的时间。</li>
</ul>
<p>配个定时任务每天跑一次，非零退出就告警。这十几行东西能挡住的，是那种&quot;周末证书过期、周一早上所有客户端集体报 SSL 错误&quot;的事故。</p>
<h2 id="相关阅读">相关阅读</h2>
<ul>
<li><a href="/posts/llm-api-timeout-retry-double-billing/">大模型 API 中转的超时与重试：一次 504 可能让你双倍计费</a> —— 另一类会让全部请求一起失败的配置问题</li>
<li><a href="/posts/llm-api-sse-proxy-buffering/">反代把大模型 API 的 SSE 流式响应缓冲了：五个缓冲点与三层排查法</a> —— 换成 Caddy 后 SSE 的行为差异</li>
<li><a href="/posts/api-gateway-real-ip-ratelimit/">API 中转站的限流为什么不能信 X-Forwarded-For</a> —— 混合方案里 <code>X-Real-IP</code> 那一跳为什么必须配对</li>
</ul>
]]></content:encoded></item></channel></rss>