背景

VPS 上已经有一个 api.golforfun.net 的 HTTPS 服务,现在要加一个新的 rag.helloai.jp,同一台机器、同一个 IP 162.43.92.249、同一个 443 端口。不需要新 IP,不需要停旧服务,靠 SNI 区分。

这篇记录 DNS、SSL、SNI 三层是怎么配合的,以及这次踩到的坑。

一、DNS:子域指向哪台服务器

helloai.jp 的权威 NS 是 XServer(ns1.xserver.jp 到 ns5.xserver.jp),所以 A 记录要在 XServer サーバーパネル → ドメイン設定 → DNSレコード設定 加,不要在 Xdomain 面板加——Xdomain 和 XServer 是两套独立的 DNS 区域,Xdomain 加的记录 XServer 看不到。

加的记录:

rag.helloai.jp  A  162.43.92.249  TTL 3600

坑一:泛解析 *.helloai.jp 干扰

rag.helloai.jp 之前解析到 85.131.207.20(WordPress 那台),因为存在 *.helloai.jp 的泛解析。删掉泛解析后,新加的 rag A 记录才生效。

验证:

dig +short rag.helloai.jp @ns1.xserver.jp   # 权威 NS,立即生效
dig +short rag.helloai.jp @8.8.8.8          # Google DNS,可能延迟
dig +short rag.helloai.jp @1.1.1.1          # Cloudflare DNS

三条都返回 162.43.92.249 才算传播完成。

坑二:本地 DNS 缓存

VPS 上 dig 返回新 IP,但 Mac 本地 curl 还打到旧 IP(XServer 的「無効なURLです」页面)。清 Mac 缓存:

sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder

如果还不行,临时在 /etc/hosts 加 162.43.92.249 rag.helloai.jp,绕过 DNS。验证完删掉。

二、SSL:证书怎么签

用 Let’s Encrypt,DNS-01 验证。不用 HTTP-01,因为:

  • HTTP-01 需要 A 记录先传播到公共 DNS,XServer 传播慢,certbot 可能打到旧 IP 404。
  • DNS-01 验证的是 _acme-challenge.rag.helloai.jp 的 TXT 记录,和 A 记录独立,A 记录没传播也能签。

签证书

certbot certonly --manual --preferred-challenges dns -d rag.helloai.jp

certbot 打印 TXT 值,去 XServer 面板加:

  • 主机名:_acme-challenge.rag(XServer 自动补 .helloai.jp)
  • 类型:TXT
  • 值:certbot 打印的那串

坑三:TXT 记录的格式

主机名写 _acme-challenge.rag,不要写 _acme-challenge.rag.helloai.jp,XServer 会拼成 _acme-challenge.rag.helloai.jp.helloai.jp。

加完验证:

dig +short TXT _acme-challenge.rag.helloai.jp @ns1.xserver.jp
dig +short TXT _acme-challenge.rag.helloai.jp @8.8.8.8

两条都返回 "0lwiHRqAMf32FOTALxqgxMUZ-v15EMuAyGHmL27BKhw"(certbot 打印的值)后,回 certbot 按回车。

坑四:–manual 不自动续期

签成功后 certbot 会提示:

This certificate will not be renewed automatically. Autorenewal of --manual
certificates requires the use of an authentication hook script (--manual-auth-hook)
but one was not provided.

证书 2027-01-20 到期,提前一周手动重跑 certbot certonly --manual --preferred-challenges dns -d rag.helloai.jp,加新的 TXT(每次值都变,旧的删掉)。

坑五:–manual 不能加 –non-interactive

加了会报 An authentication script must be provided with --manual-auth-hook。必须交互式,手动加 TXT 后回车。

三、SNI:同一 IP 上多个证书

为什么需要 SNI

TLS 握手发生在 HTTP 请求之前,服务器在握手时就要出示证书,但那时它还不知道客户端要访问哪个域名。没有 SNI,一个 IP:443 只能配一张证书,这张证书的 SAN 必须包含这个 IP 上所有域名。

SNI(Server Name Indication)让客户端在 TLS 握手的 ClientHello 里带 server_name 扩展,nginx 在握手阶段就根据这个字段选对应的证书。

nginx 配置

两个域名,两个 server block,同一个 listen 地址和端口:

# 已有的
server {
    listen 162.43.92.249:443 ssl;
    server_name api.golforfun.net;
    ssl_certificate /etc/letsencrypt/live/api.golforfun.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.golforfun.net/privkey.pem;
    ...
}

# 新加的
server {
    listen 162.43.92.249:443 ssl;
    server_name rag.helloai.jp;
    ssl_certificate /etc/letsencrypt/live/rag.helloai.jp/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rag.helloai.jp/privkey.pem;
    ...
}

nginx 允许同一个 listen 地址上多个 server block,因为 server_name 不同。客户端访问 rag.helloai.jp 时,SNI 带 rag.helloai.jp,nginx 选第二个 block,出示 rag.helloai.jp 的证书。

坑六:必须绑具体 IP,不能绑 0.0.0.0:443

这台 VPS 的 100.99.4.120:443 被 tailscaled 占用(Tailscale 内网)。nginx 绑 0.0.0.0:443 会和 tailscaled 冲突(Linux 内核里 0.0.0.0 和具体 IP 的绑定冲突)。

必须绑具体公网 IP:

listen 162.43.92.249:443 ssl;

坑七:UFW 没放行 443

ufw status | grep 443

如果没放行,ufw allow 443/tcp。否则外部 curl 直接 HTTP 000(连接超时)。

坑八:proxy_read_timeout 默认 60s 超时

RAG API 的 LLM 推理 30 秒–2 分钟,nginx 默认 proxy_read_timeout 60s 会在模型响应前超时,返回 504。

location /api/ {
    proxy_pass http://127.0.0.1:8093;
    proxy_read_timeout 300s;
    proxy_connect_timeout 10s;
}

坑九:default_server 和旧客户端

如果客户端不支持 SNI(很老的 Java 6、Windows XP IE),nginx 会用 default_server 的证书。默认是第一个 listen 的 block。这台 VPS 上第一个是 api.golforfun.net,所以旧客户端访问 rag.helloai.jp 会收到 api.golforfun.net 的证书,报错。

WordPress 的 wp_remote_post 底层是 curl,现代 curl 都支持 SNI,没问题。但如果要兼容极旧客户端,可以在默认 block 放一张 SAN 包含两个域名的证书,或者显式设 default_server 到一个返回 444 的 block。

四、验证

证书是否正确签发

ls -la /etc/letsencrypt/live/rag.helloai.jp/

应该有 fullchain.pem、privkey.pem、cert.pem、chain.pem。

SNI 是否正确路由

openssl s_client -connect 162.43.92.249:443 -servername rag.helloai.jp </dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect 162.43.92.249:443 -servername api.golforfun.net </dev/null 2>/dev/null | openssl x509 -noout -subject

预期:

subject=CN = rag.helloai.jp
subject=CN = api.golforfun.net

如果两条显示同一个证书,说明 SNI 没生效,或者两个 block 的 listen 写错了。

端到端测试

curl -s https://rag.helloai.jp/api/health
curl -s -X POST https://rag.helloai.jp/api/ask \
  -H "Content-Type: application/json" \
  -d '{"text":"Net Sales 是多少","top_k":5}' | head -c 300

/api/health 返回 {"status":"ok"},/api/ask 返回 answer + sources,说明 nginx + HTTPS + SNI 都通了。

确认旧服务没被影响

curl -s -X POST https://api.golforfun.net/api/golf-rules \
  -H "Content-Type: application/json" \
  -d '{"text":"红桩怎么抛球?罚几杆?"}' | head -c 200

返回规则答案(哪怕缓存命中),说明旧服务没被新 nginx 配置影响。

五、总结

层 关键点
DNS XServer 面板加 A 记录,注意泛解析干扰,本地 DNS 缓存要清
SSL DNS-01 验证,TXT 主机名写 _acme-challenge.rag,–manual 不自动续期
SNI 同一 IP:443 多个 server block,listen 绑具体公网 IP,不绑 0.0.0.0
nginx proxy_read_timeout 300s,UFW 放行 443
验证 openssl s_client -servername 看证书 CN,curl 测端到端

一句话:同一台 VPS 跑多个 HTTPS 域名,DNS 各自解析到同一个 IP,证书各自签,nginx 靠 SNI 在 TLS 握手阶段按 server_name 选证书。不需要新 IP,不需要停旧服务。

参考配置:/etc/nginx/sites-available/rag.helloai.jp.conf、/etc/letsencrypt/live/rag.helloai.jp/。

已知遗留:证书 2027-01-20 到期,提前一周手动重跑 certbot,加新的 TXT 记录,旧的删掉。