---
title: "SSL 证书到期告警：已经没人会发邮件提醒你了"
description: "Let's Encrypt 停发到期提醒邮件，证书有效期已降到 200 天。一段实测过的 openssl 检查脚本、适配短周期证书的阈值，以及真正能叫醒你的告警。"
date: 2026-09-18
author: Nooc
authorAvatarLink: /images/avatars/nooc.webp
authorLink: https://nooc.me
tags:
  - SSL 证书到期
  - TLS 监控
  - certbot
  - 服务器宕机告警
  - 电话告警
---

# SSL 证书到期告警：已经没人会发邮件提醒你了

有两件事在所有人的证书体系底下悄悄变了，而大多数团队两件都没有跟上。

**第一，安全网被拆掉了。** Let's Encrypt 在 **2025 年 6 月 4 日**关停了到期提醒服务——就是那些在自动化悄悄坏掉时，救过成千上万个站点的邮件。停掉的理由都站得住脚（绝大多数用户已经自动续期；保存数百万个邮箱地址是隐私负担；这项服务每年要花「数万美元」），而那篇公告的结尾是：请自己去找第三方监控（[Let's Encrypt](https://letsencrypt.org/2025/06/26/expiration-notification-service-has-ended)）。很多人读到这里点头称是，然后就没有做后半句。

**第二，容错余量塌了。** 自 **2026 年 3 月 15 日**起，公共 TLS 证书的最长有效期为 200 天；按 [CA/Browser Forum SC-081v3 议案](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/)，2027 年 3 月 15 日降到 100 天，2029 年 3 月 15 日降到 47 天。Let's Encrypt 走得比上限更快：6 天期证书已于 2026 年 1 月 15 日全量开放，其路线图是 2027 年 2 月把默认配置降到 64 天、2028 年 2 月降到 45 天（[Let's Encrypt](https://letsencrypt.org/2025/12/02/from-90-to-45)）。

这两件事的方向是一致的：续期更频繁，意味着出错的机会更多；而出错的时候，没有任何人会给你发邮件。本文就是用来堵这个口子的——一段实测过的 shell 脚本、一套适配短周期证书的阈值，以及如何让最后一道防线用 [Echobell](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-ssl-certificate-expiry-alerts-zh&mt=8) 直接打响你的手机。

## 证书过期造成的故障到底有多严重？

**严重到去年有超过三分之一的组织真的遇到过。** DigiCert 的《2026 全球证书管理展望》——由 Propeller Insights 于 2026 年 5 月执行，覆盖美、英、澳三国 1,001 位 IT 与网络安全决策者——显示，过去一年中**超过三分之一**的组织因证书过期而发生服务中断。接近**四分之三**的组织累计有至少 5 小时的证书相关停机，**五分之一**达到 25 小时以上，接近**四分之一**表示其最严重的一次证书事故损失超过 **25 万美元**（[DigiCert](https://www.globenewswire.com/news-release/2026/09/09/3358724/0/en/digicert-research-finds-certificate-failures-are-a-six-figure-infrastructure-risk.html)）。

这些数字里最值得琢磨的是时长。5 小时并不是续期需要的时间——续期只要几秒。5 小时是有人**发现**它所花的时间。

## 自动续期为什么还不够？

**因为续期自动化是静默失败的，而一个已经不再运行的定时任务不会产生任何输出。** 下面每一种都是真实且常见的故障，而且都不发出任何声音：

- **定时器早就没在跑了。**一次系统升级、一次容器重建，或者三个月前某次没人记得的 `systemctl disable`。`certbot.timer` 没触发，看起来和成功触发一模一样。
- **续期成功了，但服务从没重载。**新证书已经落到磁盘上，而 nginx、HAProxy 或 Postfix 内存里握着的还是旧的。这是「全自动」配置最常见的过期方式。
- **验证路径坏了。**有人在 `/.well-known/acme-challenge/` 前面加了跳转、WAF 规则或一条 `Deny`，HTTP-01 就失败了；或者 DNS-01 用的那个 DNS 服务商 API token 过期了。
- **只有一个节点续了期。**两台负载均衡器，一个定时任务。第二台会一直提供旧证书，直到它不能再提供为止。
- **那张证书根本不在 Web 服务器上。**内部 mTLS 客户端、Kafka broker、LDAP 服务器、VPN 网关、设备管理推送证书。公网上什么都看不到，也没有任何 ACME 客户端在管它。
- **续期间隔被写死成了错的数值。**Let's Encrypt 对这一条说得很直接：当默认配置降到 64 天、再到 45 天之后，「把续期间隔硬编码成 60 天将不再够用」（[Let's Encrypt](https://letsencrypt.org/2025/12/02/from-90-to-45)）。

最后一条值得加重语气，因为它是日历正把所有人推进去的那个坑。如果你的续期节奏是某人在 2022 年敲进去的一个数字，那么证书有效期现在正朝着这个数字迎面走来。

## 怎么用命令行查证书的到期时间？

**一条 `openssl` 管道，不需要任何依赖。** 查线上主机：

```bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -startdate -enddate
```

查磁盘上的文件：

```bash
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
```

在共享 IP 上，`-servername` 不是可选项——不带 SNI 拿到的是服务器认定的默认证书，未必是你担心的那一张。

如果只要一个是非判断，可以完全跳过日期解析。`openssl x509 -checkend <秒数>` 在证书能挺过这个窗口时退出码为 **0**，在窗口内到期（包括已经过期）时退出码为 **1**：

```bash
openssl x509 -in fullchain.pem -noout -checkend $((14 * 86400)) \
  || echo "14 天内到期"
```

这个退出码约定就是整个监控的原语，下面的一切都只是围着它接管道而已。

### 抓住「续了期但没重载」这种情况

**把磁盘上的那张和实际在提供的那张比一比。** 这是几乎没人做的检查，而它恰好能抓到续期自动化自己看不见的那种故障：

```bash
served=$(openssl s_client -connect 127.0.0.1:443 -servername example.com </dev/null 2>/dev/null \
         | openssl x509 -noout -fingerprint -sha256)
ondisk=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -fingerprint -sha256)

[ "$served" = "$ondisk" ] || echo "服务仍在提供旧证书 —— 需要重载"
```

两条命令输出的格式完全相同（`sha256 Fingerprint=AB:CD:...`），所以直接做字符串比较就够。在续期定时器的时间窗之后几分钟跑它。

## 证书告警该用什么阈值？

**用证书有效期的比例，而不是固定天数。** 「提前 30 天提醒」这条规则对 90 天证书是合理的；套到 47 天证书上，它会对一张完全健康的证书报警；套到 6 天证书上，它会永远在响。

应该把这套梯度锚定在续期点上。Let's Encrypt 建议在「当前证书有效期约三分之二处」续期——也就是说，**剩余三分之一有效期**正是续期**本应已经发生**的时刻。这个点之后的一切，都是它没有发生的证据：

| 剩余有效期 | 含义 | 通知类型 |
| --- | --- | --- |
| 1/3 | 续期窗口已打开 | 不通知——这很正常 |
| 1/6 | 续期窗口错过了一次 | 普通推送 |
| 1/12 | 续期是在失败，不只是迟到 | 时效性 |
| < 1/24、已过期或不可达 | 你离故障只剩几个小时 | 来电 |

换成具体数字，对一张 47 天的证书大致是：7.8 天之前保持安静，3.9 天推送，2 天时效性，不到 1 天打电话。对 90 天证书是 15 天、7.5 天、3.75 天。对 6 天证书则以小时计，这时候人工梯度已经不是合适的工具了——应当依靠 [ACME Renewal Information](https://letsencrypt.org/2025/09/16/ari-rfc)（ARI，已作为 RFC 9773 发布），让 CA 直接告诉你的客户端何时续期，而你只在客户端反复失败时才告警。

算这个比例只要四行，而且它能让同一个脚本对你名下每一张证书都成立，不管签发方是谁：

```bash
to_epoch() {
  date -u -d "$1" +%s 2>/dev/null || date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null
}

pct_left() {  # 从标准输入读 PEM，打印剩余有效期占总有效期的百分比
  local pem nb na
  pem=$(cat)
  nb=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -startdate | cut -d= -f2)")
  na=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)")
  echo $(( (na - $(date -u +%s)) * 100 / (na - nb) ))
}
```

第一种 `date` 写法是 GNU 的，第二种是 BSD/macOS 的；`||` 会挑中你机器上有的那个。

## 怎么把它变成一条真的能触达我的告警？

Echobell 把 Webhook 或邮件变成普通推送、时效性通知，或者一通真正响铃、能穿透专注模式与勿扰模式的电话（见[绕过 iOS 专注模式](/blog/how-to-bypass-ios-focus-mode-for-critical-alerts)）。对证书来说这一点很重要，因为上面梯度的最后一档，往往是你在周日凌晨三点撞上的那一档。

### 第一步 —— 建两个频道，而不是一个

在 App 里建一个频道，通知类型设为**时效性**，命名为「证书即将到期」。再建第二个，类型设为**来电**，命名为「证书即将失效」。分别从频道详情里复制 Webhook URL，形如 `https://hook.echobell.one/t/<channel-token>`。把它们当密钥对待——拿到那个「来电」URL 的人就能让你的手机响（[Webhook 指南](/docs/webhook)）。

把模板写成在锁屏上不解锁就能行动的样子：

```
标题：TLS {{state}}：{{host}}
正文：还剩 {{daysLeft}} 天，{{notAfter}} 到期 —— 签发者 {{issuer}}
```

你 POST 过去的任何 JSON 键都会变成变量（[模板](/docs/template)）。

### 第二步 —— 跑检查

下面是把前面各节拼起来、端到端实测过的脚本。存成 `/usr/local/bin/cert-watch`：

```bash
#!/usr/bin/env bash
# cert-watch —— 当 TLS 证书临近到期时，向 Echobell 发送通知。
set -uo pipefail

HOOK="${ECHOBELL_CERT_HOOK:?请把 ECHOBELL_CERT_HOOK 设为你的频道 Webhook URL}"
WARN_DAYS="${WARN_DAYS:-14}"

post() {
  curl -sS -m 10 -X POST "$HOOK" \
    -H 'content-type: application/json' \
    -d "{\"host\":\"$1\",\"daysLeft\":$2,\"notAfter\":\"$3\",\"state\":\"$4\"}" \
    >/dev/null
}

days_left() {
  local end
  end=$(date -u -d "$1" +%s 2>/dev/null) ||
    end=$(date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null) || return 1
  echo $(( (end - $(date -u +%s)) / 86400 ))
}

check() {
  local host="$1" port="$2" pem state not_after days

  pem=$(openssl s_client -connect "$host:$port" -servername "$host" \
        </dev/null 2>/dev/null | openssl x509 2>/dev/null)

  if [ -z "$pem" ]; then
    post "$host" 0 "" "unreachable"
    return
  fi

  if printf '%s' "$pem" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then
    printf '%s' "$pem" | openssl x509 -noout -checkend $((WARN_DAYS * 86400)) >/dev/null 2>&1 && return 0
    state="expiring"
  else
    state="expired"
  fi

  not_after=$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)
  days=$(days_left "$not_after") || days=-999
  post "$host" "$days" "$not_after" "$state"
}

for target in "$@"; do
  case "$target" in
    *:*) check "${target%:*}" "${target##*:}" ;;
    *) check "$target" 443 ;;
  esac
done
```

它会上报三种状态——`expiring`、`expired`、`unreachable`——一切正常时保持沉默。目标写成 `host` 或 `host:port`，所以非 Web 的证书也照样覆盖：

```bash
ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" \
  cert-watch example.com api.example.com mail.example.com:993 ldap.internal:636
```

`unreachable` 被特意设计成一条告警，而不是静默跳过。一个把「我没看成」当作「一切正常」的监控，正是证书会过期的根本原因。

### 第三步 —— 给它排期，并在排期本身失败时告警

对 45 天及更长的证书，一天一次就够；如果你在用 6 天期证书，一天两次。systemd timer：

```ini
# /etc/systemd/system/cert-watch.service
[Unit]
Description=TLS certificate expiry check

[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/cert-watch example.com api.example.com mail.example.com:993
```

```ini
# /etc/systemd/system/cert-watch.timer
[Unit]
Description=Daily TLS certificate expiry check

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
```

`Persistent=true` 很关键：没有它，机器在预定时刻处于关机状态，那一次就直接跳过了。

然后把检查自身也闭环。`Type=oneshot` 的单元非零退出会触发 `OnFailure=`，于是一个 drop-in 就能让坏掉的续期自己开口：

```ini
# /etc/systemd/system/certbot.service.d/echobell.conf
[Unit]
OnFailure=echobell-alert@%n.service
```

```ini
# /etc/systemd/system/echobell-alert@.service
[Unit]
Description=Echobell alert for %i

[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/echobell-notify %i
```

其中 `/usr/local/bin/echobell-notify` 只有三行：

```bash
#!/usr/bin/env bash
curl -sS -m 10 -X POST "$ECHOBELL_CERT_HOOK" \
  -H 'content-type: application/json' \
  -d "{\"host\":\"$(hostname -f)\",\"state\":\"renewal-failed\",\"unit\":\"$1\",\"daysLeft\":-1}"
```

只要有任何一张证书续期失败，`certbot renew` 就会以非零码退出——这正是你想要的信号，也正是目前哪儿都没送到的信号。注意 certbot 的 `--deploy-hook` 只在**成功**时执行，所以它没法用来做这件事，失败路径必须从单元这一侧来。

### 第四步 —— 用条件把梯度分开

两个频道收到的是同一份 payload，由[条件](/docs/conditions)决定哪一个真的触发。时效性频道：

```
state == "expiring" && daysLeft > 3
```

来电频道：

```
state == "expired" || state == "unreachable" || state == "renewal-failed" || daysLeft <= 3
```

注意 `<=` 会用 `Number()` 强制转换两侧，所以即使 `daysLeft` 是以字符串发出去的，比较依然是数值比较。条件里没有「包含」运算符，这也正是脚本发一个显式的 `state` 字段、而不是一段需要你去做模式匹配的自由文本的原因。

### 第五步 —— 在信任它之前先测一次

把脚本指向一台证书已知有问题的主机，看告警是不是真的到了：

```bash
ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" cert-watch expired.badssl.com
```

测试时请在真正会收到通知的那台手机上打开勿扰模式，并在 App 里启用**重试失败来电**，好让被专注模式挡掉的电话再拨一次。一条从没触发过的升级路径，只是一个猜测。

## 如果我的监控已经在查证书了呢？

**那就把它现成的 Webhook 接到频道上，跳过脚本。** 大多数监控本来就知道到期时间，它们通常缺的是一条能穿透「人在睡觉」的路径。

- **Uptime Kuma** 自带证书到期通知，把它指向一个频道即可（[Uptime Kuma 指南](/docs/developer/uptime-kuma)、[电话告警配置](/blog/uptime-kuma-phone-call-alerts)）。
- **Prometheus + Alertmanager** 配 blackbox exporter 会给你 `probe_ssl_earliest_cert_expiry`，基于它写告警并路由到频道（[Prometheus 指南](/docs/developer/prometheus)、[Alertmanager 电话告警](/blog/alertmanager-phone-call-alerts)）。
- **Grafana** 的告警规则可以直接 POST 到频道（[Grafana 指南](/docs/developer/grafana)）。
- **Upptime** 和 **UptimeRobot** 都能覆盖公开端点（[Upptime](/docs/developer/upptime)、[UptimeRobot](/docs/developer/uptimerobot)）。
- **只会发邮件的那些**——CA 自己的控制台、云厂商的 ACM 提醒、内部 PKI——用一条转发规则就行。每个频道都有自己的邮箱地址，`from`、`to`、`subject`、`text`、`html` 都可以作为变量使用（[邮件触发](/docs/email-trigger)）。

上面这些都替代不了的一件事，是让检查从**提供证书的那台机器之外**发起。如果你的监控和被监控对象住在同一台主机上，那么一次让主机宕掉的故障，会顺手把告警一起带走。

## Echobell 不做什么

这里有必要说清楚，因为证书管理这个品类里，能做的事比这多得多的产品到处都是。

**Echobell 会做：**把 Webhook 或邮件变成普通推送、时效性通知或响铃电话；用条件过滤；用模板格式化；把同一次触发送达共享频道的每一位订阅者，各自选择自己的紧急程度。

**Echobell 不会做：**

- **发现或盘点你的证书。**它不会扫描你的网络，不会爬证书透明日志，也不会告诉你那张没人记得签发过的证书。上面的脚本只检查你列出来的主机。证书蔓延是个真问题，而这不是它的解法。
- **续期。**它没有 ACME 客户端，也碰不到你的密钥。它只告诉你续期坏了，修还是你的事。
- **按自己的节奏去检查证书。**没有托管探针。必须由你这边跑的某个东西——定时器、CI 任务、你现有的监控——去做那次查看。
- **提供值班轮换、升级策略或签收确认。**没有「五分钟无人接听就打给下一个人」。如果你需要这些，你需要的是事件管理平台——可参考 [Opsgenie 替代方案对比](/blog/opsgenie-end-of-life-alternatives)。
- **保证送达。**电话依赖推送基础设施、网络，以及一部有电的手机。它缩短的是「坏掉」与「知道」之间的距离，而不是一个可以绝对依赖的控制措施。

## 常见问题

### Let's Encrypt 真的不再发到期邮件了吗？

是的。到期提醒服务已于 2025 年 6 月 4 日终止，Let's Encrypt 也删除了此前与签发记录关联保存的邮箱地址。公告建议改用第三方监控，并举了 Red Sift Certificates Lite 为例（250 张证书以内免费）。如果你一年多没收到过 Let's Encrypt 的到期提醒，原因就在这里——而不是因为从来没有东西快过期。

### 2026 年 TLS 证书的有效期是多久？

自 2026 年 3 月 15 日起，公开信任的 TLS 证书最长 200 天。按 SC-081v3 议案，2027 年 3 月 15 日降到 100 天，2029 年 3 月 15 日降到 47 天。各家 CA 出于安全会签在上限之下——例如 DigiCert 签发 199 天的证书，明确是「为了避免超出允许的最长有效期」。Let's Encrypt 按自己的节奏走得更远也更快，6 天期证书自 2026 年 1 月起已全量开放。

### 如果我用了 ARI，还需要到期告警吗？

需要，但告警的事件不一样了。ARI（RFC 9773）告诉你的 ACME 客户端**什么时候**续期，这彻底消灭了「硬编码间隔」这种故障——但它不保证续期成功，不保证服务重载，也不保证客户端还活着。请对「续期反复失败」和「在提供的证书与磁盘上的不一致」告警，而不是对一个你已经不需要自己管的倒计时告警。

### 不在 Web 服务器上的证书怎么办？

那些恰恰是最容易过期的，因为没有 ACME 客户端盯着它们，在出事之前也没有浏览器会抗议。脚本接受 `host:port`，所以 993 上的 IMAP、636 上的 LDAPS、9093 上的 Kafka broker、8443 上的内部 API，用法完全一样。至于那些根本不经过套接字的证书——代码签名、推送通知证书、设备群里的客户端证书——需要从它们所在的地方取出日期，再 POST 到同一个频道。

### 每天检查会不会变成噪音？

只要它在一切正常时保持沉默就不会，而这正是脚本在阈值以上什么都不发的原因。会变成噪音的是另一种设计：每天报一次「证书正常」——两周之后没人再看，而它停止到达的那一天也没人会注意到。如果你确实想要心跳，把它放在单独的普通频道上，永远别放在会响铃的那个上。这个论证的一般版本见[消除告警疲劳](/blog/fix-alert-fatigue-developer-guide)。

### 整个团队能一起收到证书告警吗？

可以。共享频道之后，每位订阅者都会收到触发，并各自选择通知类型。一种可行的分工：当班的人订阅来电频道，其他人订阅时效性频道，这样凌晨三点的到期只吵醒一个人，而不是六个。

### 在 macOS 上能用吗？

能，只有一个注意点：BSD 的 `date` 不接受 `-d`，这正是 `days_left` 和 `to_epoch` 先试 GNU 写法、再回落到 `date -u -j -f` 的原因。macOS 默认的 `openssl` 是 LibreSSL，`-checkend` 和 `-fingerprint` 的行为完全一致。即使你用 Homebrew 装了 OpenSSL，也不需要改什么。

### 把证书信息放进通知里安全吗？

这里用到的字段——主机名、到期日期、签发者——都是公开的，任何人用同样的 `openssl` 命令都能从你的服务器上读到。不要往 payload 里加私钥、内部路径，或者非公开主机上证书除主机名以外的内容。Echobell 的通知内容与历史只保存在你的设备上，服务器只留账号、频道与订阅关系（[隐私模型](/docs/features)），这是个不错的默认设定，但不构成「可以多发一点」的理由。

---

## 相关阅读

- [API 宕机的电话告警](/blog/phone-call-alerts-api-downtime)
- [Uptime Kuma 电话告警](/blog/uptime-kuma-phone-call-alerts)
- [定时任务失败告警](/blog/cron-job-failure-alerts)
- [消除告警疲劳：开发者指南](/blog/fix-alert-fatigue-developer-guide)
- [如何让关键告警绕过 iOS 专注模式](/blog/how-to-bypass-ios-focus-mode-for-critical-alerts)
- [Webhook 集成指南](/docs/webhook)
- [条件过滤指南](/docs/conditions)
