Amazon Linux 2023 部署 Nginx + HTTPS 自动续签全实战

新机器到手,要上一个带域名的网站,Nginx 装好、证书签好、续签配好,这套活我在 Amazon Linux 2023 上做了不下十几遍。

网上的教程大多是 CentOS 时代的写法,搬到 AL2023 上会碰到不少问题:EPEL 装不上,crontab 命令不存在,续签任务看起来配好了,实际一次都没跑过。

这篇按我自己的实际操作顺序写,从系统检查一直到续签监控,命令都可以直接复制。新手照着做,半小时左右能弄完。

先看下我这台机器:

[ec2-user@ALG ~]$ cat /etc/os-release
NAME="Amazon Linux"
VERSION="2023"
PRETTY_NAME="Amazon Linux 2023.12.20261102"

下文域名统一用 example.com 和 www.example.com,换成你自己的就行。


装软件之前,先把网络和域名弄对

我帮人排查证书签不下来,大部分问题出在网络和 DNS 上,跟 Nginx 本身关系不大。所以这一步宁可多花五分钟。

EC2 默认分配的公网 IP,实例停止再启动就会变。域名解析到这个 IP 上,过段时间停机升个配置,网站就打不开了。去控制台 EC2 → 弹性 IP,分配一个,关联到实例上。

安全组入站规则加两条:TCP 80 和 TCP 443,来源都是 0.0.0.0/0。SSH 的 22 端口最好只放自己的 IP。80 端口配完 HTTPS 以后也要一直开着,续签要用,后面会细讲。

AL2023 默认没开 firewalld,SELinux 默认也是 permissive,系统层一般不用额外处理,看一眼心里有数:

systemctl is-active firewalld
getenforce

然后去域名服务商那里加 A 记录,主域名和 www 都指向弹性 IP。回到服务器上核对:

sudo dnf install -y bind-utils
curl -s https://checkip.amazonaws.com
dig +short example.com
dig +short www.example.com
dig +short AAAA example.com

前三条 IP 一致就没问题。最后那条 AAAA 建议一定要查,有的服务商会默认给域名带一条 IPv6 记录,而你的 EC2 没配 IPv6,Let's Encrypt 验证时优先走 IPv6,连不上就失败,报错内容还看不出跟 IPv6 有关系。我同事就在这上面卡了一个多小时,删掉那条记录后一次就过了。

DNS 刚改完可能要等几分钟生效,dig 结果对了再往下走。


安装 Nginx

AL2023 官方仓库自带 Nginx,不用加第三方源:

sudo dnf update -y
sudo dnf install -y nginx
sudo systemctl enable --now nginx
nginx -v
curl -I http://127.0.0.1

返回 HTTP/1.1 200 OK 就是起来了。再用自己电脑的浏览器访问 http://弹性IP,能看到 Nginx 欢迎页,说明安全组也通了。

nginx -v 输出的版本号先记下来,后面写 HTTP/2 配置要用。

接着建网站目录。别放在 /home/ec2-user 下面,这个目录权限是 700,Nginx 的 worker 进程用 nginx 用户运行,读不到里面的文件,访问就是 403。我统一放 /var/www:

sudo mkdir -p /var/www/example.com /var/www/letsencrypt
echo '

Hello HTTPS

' | sudo tee /var/www/example.com/index.html

多建的 /var/www/letsencrypt 是给证书验证用的,具体原理下面讲 Certbot 的时候再说。这个路径我习惯单独给一个目录,跟业务文件分开,以后换网站代码也不会影响续签。

站点配置放在 /etc/nginx/conf.d/ 下面,主配置 nginx.conf 尽量不动,以后升级 Nginx 会省事很多:

sudo vim /etc/nginx/conf.d/example.com.conf

先写一个只有 80 端口的版本:

server {
    listen 80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        root /var/www/example.com;
        index index.html;
    }
}

改完配置先测试,再重载:

sudo nginx -t
sudo systemctl reload nginx

nginx -t 显示 syntax is ok 和 test is successful 才能 reload。我以前改完配置直接 restart,少了个分号,Nginx 起不来,网站挂了好几分钟。reload 在配置有错时会拒绝加载,旧进程继续工作,比 restart 安全。

验证路径通不通,可以自己放个文件测一下:

sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
echo ok | sudo tee /var/www/letsencrypt/.well-known/acme-challenge/test
curl http://example.com/.well-known/acme-challenge/test

能返回 ok 就说明外部访问这个路径没问题,测完把 test 文件删掉。证书签不下来的时候,先用它排查最快。


Certbot 是什么,它替你干了哪些事

很多新手把 Certbot 和 Let's Encrypt 当成一回事,其实是两样东西。

Let's Encrypt 是发证书的机构,也就是 CA,由非营利组织 ISRG 运营,免费给网站签发证书。Certbot 是装在你服务器上的客户端,由 EFF(电子前哨基金会)开发维护,负责去 Let's Encrypt 申请证书、完成验证、把证书存到本地,以后再按时续签。打个比方,Let's Encrypt 是办证大厅,Certbot 是替你跑腿排队的人。

两边沟通用的是 ACME 协议,标准编号 RFC 8555。所以 Certbot 并不是唯一选择,acme.sh、lego 也都是 ACME 客户端,原理一样。我用 Certbot 主要是因为它是 Let's Encrypt 官方推荐的,文档全,出了问题搜得到答案。acme.sh 是纯 shell 写的,支持的 DNS 服务商多,用惯了也挺顺手。

签一张证书,Certbot 在后台大致做了这些事:第一次运行时生成账号密钥,在 Let's Encrypt 注册一个账号。然后告诉 CA 要给哪几个域名申请证书,CA 会出一道“考题”,叫 challenge。Certbot 按要求把答案放好,CA 从外网来检查,检查通过就说明这个域名确实归你管。最后 Certbot 在本地生成证书私钥,提交证书请求,把签好的证书拿回来存到 /etc/letsencrypt 下面。私钥从头到尾都留在你自己的服务器上,不会发给 CA。

考题常见的有两种。

HTTP-01 是最常用的,CA 会访问 http://你的域名/.well-known/acme-challenge/一串随机文件名,能读到 Certbot 放的内容就算通过。它只走 80 端口,这也是 80 不能关的根本原因。

DNS-01 是让你在域名下加一条 _acme-challenge 开头的 TXT 记录。它不需要开任何端口,也只有它能签通配符证书 *.example.com。代价是续签时要能自动改 DNS,得装对应的 DNS 插件,比如域名托管在 Route 53 上,可以用 certbot-dns-route53 配合 IAM 角色。

至于 Certbot 怎么交这份答卷,它有几种插件可选:

  • --webroot:往已经在运行的网站目录里放验证文件,Nginx 不用停,配置也不会被改动,本文用的就是这个
  • --nginx:自动读取并修改 Nginx 配置,临时加验证规则,签完还能顺手帮你写好 443 配置,省事,但配置会被它改得比较乱
  • --standalone:Certbot 自己临时起一个 Web 服务占用 80 端口,机器上已经有 Nginx 就得先停掉,续签时也一样,不适合线上
  • --manual:手动放文件或者手动加 DNS 记录,偶尔签一张可以,没办法自动续签

想清楚这几种的区别,后面配置为什么这么写就很好理解了。


安装 Certbot

从 CentOS 过来的人,第一反应是 yum install epel-release。AL2023 不支持 EPEL,这条路走不通。

可以先 dnf search certbot 看看官方仓库有没有,不同版本的仓库内容会有变化。我自己用的是 Certbot 官方推荐的 pip 虚拟环境方式,版本新,也不会跟系统 Python 包冲突。系统自带的 python3 是 3.9,我另外装个 3.11:

sudo dnf install -y python3.11 python3.11-pip
sudo python3.11 -m venv /opt/certbot
sudo /opt/certbot/bin/pip install --upgrade pip
sudo /opt/certbot/bin/pip install certbot
sudo ln -sf /opt/certbot/bin/certbot /usr/bin/certbot
certbot --version

能打印版本号就装好了。用的是 webroot 模式,所以 certbot-nginx 插件就没装,Nginx 配置自己写,出了问题也知道该查哪里。


签发证书

签正式证书之前,先用 --dry-run 演练一次。Let's Encrypt 有频率限制,同一组域名 7 天内最多签 5 张一样的证书,反复删了重签很容易被限住,只能等。dry-run 走测试环境,不占次数,也不会保存证书:

sudo certbot certonly --webroot -w /var/www/letsencrypt \
  -d example.com -d www.example.com --dry-run

看到 The dry run was successful 再签正式的:

sudo certbot certonly --webroot -w /var/www/letsencrypt \
  -d example.com -d www.example.com \
  -m 你的邮箱 --agree-tos --no-eff-email

成功后证书路径是:

/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem

现在把站点配置改成完整版:

server {
    listen 80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

80 端口那段特意保留了验证路径,其他请求一律跳转 HTTPS。这样续签照常走 80,用户访问 http 也会被带到 https。

http2 on 这种写法要 Nginx 1.25.1 及以上。前面记下的版本号如果比这个低,就删掉这一行,把 listen 改成 listen 443 ssl http2。

sudo nginx -t && sudo systemctl reload nginx
curl -I https://example.com
curl -I http://example.com

第一条返回 200,第二条返回 301,浏览器地址栏有小锁,证书部分就完成了。


签完之后,/etc/letsencrypt 里都有什么

这个目录建议花两分钟看一下,以后排查续签问题基本都在这里:

sudo ls /etc/letsencrypt

几个关键目录:

  • accounts:Let's Encrypt 账号信息和账号密钥
  • archive:证书的真实文件,每续签一次多一组,名字是 cert1.pem、cert2.pem 这样递增
  • live:指向 archive 里最新一组文件的软链接,Nginx 配置引用的就是这里
  • renewal:每张证书一个续签配置文件
  • renewal-hooks:放续签前后要执行的脚本,下面有 pre、deploy、post 三个子目录

Nginx 配置里一定要写 live 目录。续签后 live 下的软链接会自动指向新文件。有人把证书复制到 /etc/nginx/ssl/ 下面再引用,续签明明成功了,Nginx 读的还是旧证书,到期照样出问题。

renewal 目录里的文件也值得打开看看:

sudo cat /etc/letsencrypt/renewal/example.com.conf

里面大概是这样:

[renewalparams]
account = 一串账号ID
authenticator = webroot
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
[[webroot_map]]
example.com = /var/www/letsencrypt
www.example.com = /var/www/letsencrypt

签证书时用了什么验证方式、webroot 目录在哪,Certbot 都记在这个文件里。所以后面执行 certbot renew 不用带任何参数,它会照着这份记录重新走一遍。反过来说,以后要是改了验证目录,这里没跟着改,续签就会失败。key_type = ecdsa 是 Certbot 2.0 之后的默认值,证书用的是 ECDSA 密钥,比 RSA 更短,握手更快,现在的浏览器都支持。

日常用得最多的几条命令,我整理在这:

# 查看所有证书、包含的域名和到期时间
sudo certbot certificates

# 演练续签
sudo certbot renew --dry-run

# 给现有证书加一个域名,所有域名要一起写上
sudo certbot certonly --webroot -w /var/www/letsencrypt \
  --cert-name example.com \
  -d example.com -d www.example.com -d api.example.com

# 不再使用的证书,删掉后续签任务也会跳过它
sudo certbot delete --cert-name example.com

# 私钥泄露时吊销证书
sudo certbot revoke --cert-name example.com

还有一个 --force-renewal 参数可以强制续签,它会计入频率限制,平时别随手加。


自动续签

这里是 AL2023 上最容易漏掉的地方。pip 装的 certbot 不会自动建定时任务,AL2023 默认又没有安装 cron。照着网上的教程执行 crontab -e,要么提示命令不存在,要么装了 cronie 却没启动 crond,任务写进去了但不会执行。

系统本身就是 systemd,我直接用 systemd timer,不用再装别的东西。

先写 service:

sudo vim /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot Renew
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/certbot/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"

再写 timer:

sudo vim /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot renew twice daily

[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target

一天检查两次是 certbot 官方的建议。证书离到期还远的时候,renew 检查完就直接退出,默认在到期前 30 天左右才真正续签。RandomizedDelaySec 加一点随机延迟,避免大量机器同一时间请求 Let's Encrypt。Persistent=true 的意思是到点时机器正好关机,开机后会补跑一次。

Certbot 的钩子有三种。pre-hook 在续签前执行,post-hook 在续签尝试后执行,deploy-hook 只在某张证书真正续签成功后才执行。reload Nginx 放在 deploy-hook 里最合适,证书没换就不会去动 Nginx。钩子也可以写成脚本放进 /etc/letsencrypt/renewal-hooks/deploy/,加上执行权限,Certbot 续签成功后会自动调用。证书要同步到别的机器,或者顺带发个通知,都可以写在这里面。

启用定时器:

sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer
systemctl list-timers | grep certbot

能看到下次执行时间,说明定时器生效了。不过这只能说明任务排上了,续签能不能成功还得手动跑一遍:

sudo certbot renew --dry-run
sudo systemctl start certbot-renew.service
journalctl -u certbot-renew.service -n 50 --no-pager

dry-run 显示 all simulated renewals succeeded,service 日志里也没有报错,这样才算配完。如果失败,更详细的日志在 /var/log/letsencrypt/letsencrypt.log。

certbot 本身也要定期升级,我一般每个季度升一次:

sudo /opt/certbot/bin/pip install --upgrade certbot

还有个变化要知道:Let's Encrypt 从 2025 年起已经不再发送证书到期提醒邮件,证书有效期以后也会从 90 天逐步缩短到 45 天。续签失败后不会再有邮件提醒,只能靠自己做监控。


80 端口为什么不能关

我之前遇到过一次。机器上的定时任务一直正常运行,后来做安全整改,有同事觉得全站都上 HTTPS 了,80 端口没用,就在安全组里删掉了。这个想法乍一听挺合理,我当时也没拦。

前面说过,webroot 走的是 HTTP-01 验证,Let's Encrypt 必须从外网访问 80 端口才能读到验证文件。80 一关,每次续签都会失败。从到期前 30 天开始,它一天失败两次,日志里全是 Timeout during connect,因为加了 --quiet,也没人去看。最后证书过期,用户打开网站看到一整页安全警告,我们才发现。

处理起来很快,安全组加回 80,手动 certbot renew,一分钟就恢复了。可网站已经有好几个小时不能正常访问。

所以 80 端口要一直开着,除了验证路径什么内容都不提供,只负责跳转。确实要关 80 的话,就改用 DNS-01 验证,那是另一套配置,这里先不展开。


这些坑我和身边的人都踩过

  • 安全组里的 80 端口不要关
  • 证书路径写 live 目录,别写 archive,也别复制到别的地方
  • 调试时先用 --dry-run,别拿正式环境反复试
  • 改了 webroot 目录,记得同步 renewal 配置文件
  • AL2023 默认没有 cron,别直接照抄 crontab 教程
  • 改完配置先 nginx -t,再 reload
  • 域名的 AAAA 记录要检查
  • 记得绑定弹性 IP
  • 网站文件别放 /home/ec2-user
  • 配了自动续签,也要加到期监控

到期监控脚本

这个脚本从外部连接 443 端口获取证书,检查的是 Nginx 实际返回给用户的那张,比检查本地文件可靠:

#!/bin/bash
# check_cert.sh 用法: ./check_cert.sh example.com
DOMAIN=${1:-example.com}
WARN_DAYS=20

END=$(echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)

if [ -z "$END" ]; then
  echo "[ERROR] ${DOMAIN} 获取证书失败"
  exit 2
fi

DAYS=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))

if [ "$DAYS" -lt "$WARN_DAYS" ]; then
  echo "[WARN] ${DOMAIN} 证书还剩 ${DAYS} 天,检查续签日志"
  exit 1
fi

echo "[OK] ${DOMAIN} 证书还剩 ${DAYS} 天"

续签正常时,剩余天数一般会保持在 30 天上下。降到 20 天以下还没恢复,说明续签已经卡住了。这时离过期还有将近三周,有足够时间排查。告警可以接企业微信或钉钉机器人,在脚本里加一行 curl 就行。脚本最好放在另一台机器上跑,网站所在的机器出了问题,它也能照常告警。


整套流程下来,Nginx 和证书本身都不复杂。把 Certbot 的工作方式弄明白以后,大部分问题都能顺着查下去:验证失败就看 80 端口和 DNS,续签没跑就看 timer,续签成功网站没变就看证书路径和 deploy-hook。

我现在每次配完 HTTPS,都会跑 nginx -t、certbot renew --dry-run、systemctl list-timers 这三条命令,结果都正常,再把监控脚本挂上,这件事才算真正做完。

下一篇打算写 Nginx 反向代理,包括 upstream、超时参数和健康检查,这几项在线上出问题的频率比证书还高。

觉得有用的话,欢迎点赞、在看,转给身边正在折腾服务器的朋友。关注 @运维躬行录,后面会继续分享这类可以直接上手的实操记录,有问题欢迎在评论区交流。

公众号:耕云躬行录

个人博客:躬行笔记

参与讨论