自建服务器 + 云服务器:网站部署与维护
前言
云服务器的内存和磁盘不够用,但手边有一台性能更好的电脑、Mac mini 或自建服务器,可以把网站运行在自己的设备上,让云服务器只负责公网入口。
应用、运行依赖和主要数据留在自建服务器,通过反向 SSH 隧道连接云服务器。用户仍然用域名和 HTTPS 访问,不需要进入你的局域网,自建服务器通常也不需要公网 IP 或路由器端口映射。
这篇从首次上线开始,说明两台机器怎样连接、域名如何访问内网应用,以及后续更新、排障和回滚怎么做。
两台服务器分别做什么
用户浏览器
│ HTTPS
▼
云服务器:公网 IP、域名入口、Apache、TLS 证书
│ 访问云端 loopback 转发端口
▼
反向 SSH 隧道(由自建服务器主动建立)
│
▼
自建服务器:应用进程、运行依赖、发布版本
│
└─ 数据库、上传文件或其他持久化数据| 位置 | 主要职责 |
|---|---|
| 开发或构建环境 | 保存源码、运行测试、生成发布包;也可以是自建服务器 |
| 自建服务器 | 运行网站,保存应用版本与数据,维持隧道连接 |
| 云服务器 | 接收公网请求,处理 HTTPS,将请求转入隧道 |
节省资源的关键是应用在哪里运行,不只是源码放在哪里。生产环境可以只接收运行产物和必要依赖,不必保留完整源码;云服务器也不必长期中转、保存发布包。
这种方式能减轻云端的应用 CPU、内存和主要存储压力,但云端代理、TLS、SSH、日志仍占资源,流量也仍经过云服务器。实际收益要看应用负载,不能理解成“只用一个 IP,其他零占用”。
代价是网站同时依赖自建设备、供电、上行带宽和隧道。设备睡眠、重启或家中断网,公网网站也会受影响。纯静态官网通常直接托管更简单,不必为了用这套结构额外运行一个服务。
开发机怎样连接两台服务器
后面所有操作都要从开发机 SSH 到云服务器和自建服务器。这一步出错最常见,而且几乎都是用法问题,不是密钥问题。
| 连接 | 命令 | 认证 |
|---|---|---|
| 开发机 → 云服务器 | ssh <CLOUD_USER>@<CLOUD_HOST> | 开发机默认密钥,不加 -i |
| 开发机 → 自建服务器 | ssh <MINI_USER>@<MINI_HOST> | 同上 |
| 自建服务器 → 云服务器(发布脚本、隧道) | 在自建服务器上用它自己的密钥 | 密钥只留在自建服务器,不拷到别处 |
公钥已写入对方 authorized_keys 的前提下,用对用户名和地址就能直连:不需要 ssh-copy-id、~/.ssh/config 别名或 ssh-add,ssh 会自动尝试默认密钥。动手前先做连通性自检:
ssh -o BatchMode=yes -o ConnectTimeout=8 <CLOUD_USER>@<CLOUD_HOST> 'echo cloud-ok'
ssh -o BatchMode=yes -o ConnectTimeout=8 <MINI_USER>@<MINI_HOST> 'echo mini-ok; whoami'BatchMode=yes 让失败时直接报错,而不是卡在密码输入;自建服务器上的 whoami 应等于 MINI_USER。
连不上自建服务器时
| 现象 | 真正原因 | 处理 |
|---|---|---|
Permission denied (publickey,...) | 最常见是用户名写错:自建服务器上只有它自己的系统用户,Tailscale 账号名、开发机用户名在那里都不存在 | 一律用 MINI_USER@MINI_HOST |
解析到 198.18.x.x,或 ssh <主机名> 不通 | 开着 Clash 等 TUN 代理时,主机名被 fake-ip 劫持;或根本没配这个别名 | 只用固定 IP;用 tailscale status 确认自建服务器在线 |
| 以为“agent 里没密钥、config 没别名”所以连不上 | 这些都不需要 | 直接跑自检命令,不要去翻 ~/.ssh |
| 找不到自建服务器上的部署目录 | 这些路径只存在于自建服务器,开发机上没有 | 路径相关的命令都通过 SSH 在自建服务器上执行 |
| 换了几个用户名、几把密钥还是失败 | 穷举既没用,还可能触发对端的防护 | 自检失败就停,保留完整报错,再对照上表 |
对照都没问题仍被拒绝,才去检查自建服务器 ~/.ssh/authorized_keys 是否被改动过。需要授权新密钥时,只交出公钥,由你自己追加;不要把私钥拷来拷去,也不要让工具或别人替你改 authorized_keys。
开发机的 DNS 不可信
开发机开着代理的 TUN / dns-hijack 时,所有走 53 端口的查询都会被改写成 fake-ip,连“直接问权威 NS”也会被伪造,所以开发机上的 dig 不能作为结论。改在云服务器上查,或走 DoH:
ssh <CLOUD_USER>@<CLOUD_HOST> 'dig -4 +short <域名> A @223.5.5.5'
curl -s "https://223.5.5.5/resolve?name=<域名>&type=A" -H "Accept: application/dns-json"在自建服务器上执行多行命令
用 heredoc 一次发过去,不需要交互终端。<<'REMOTE' 带引号,脚本体原样到达远端,不在开发机上提前展开变量:
ssh -o BatchMode=yes <MINI_USER>@<MINI_HOST> 'bash -s' <<'REMOTE'
set -euo pipefail
# 在自建服务器上执行的命令
REMOTE需要传变量时写成 ssh ... "release_stamp=$stamp bash -s" <<'REMOTE'。
上线前确定配置
| 配置项 | 怎样确定与检查 |
|---|---|
| 应用启动命令 | 查看仓库脚本和生产入口,确认启动的是 Web 服务,不是开发服务器 |
应用端口 APP_PORT | 在自建服务器分配并检查监听,本文假设 SSH 客户端与应用处于同一主机网络空间 |
测试端口 TEST_PORT | 与正式端口分开,确认没有其他进程占用 |
云端转发端口 TUNNEL_PORT | 在云服务器分配,只绑定 127.0.0.1,Apache 指向同一个端口 |
SSH 目标 CLOUD_SSH | 使用已核对主机指纹的专用账号,配置独立密钥;地址写固定 IP,不依赖主机名或别名(原因见下一节) |
自建服务器地址 MINI_HOST | 固定 IP(例如 Tailscale 的 100.x),不用主机名 |
自建服务器用户 MINI_USER | 自建服务器上的系统用户名(在它上面执行 whoami 的值),不是 Tailscale 账号名,也不是开发机的用户名 |
| 域名与证书 | DNS 指向云端入口,明确证书在何处签发、由谁续期 |
| 服务标签与目录 | 应用和隧道各自管理,名称不与其他项目冲突;数据目录不放进发布目录 |
健康检查 HEALTH_PATH | 使用应用实际实现的只读路径,最好返回运行版本 |
这三个端口不是同一个值,也不需要相同。示例若用应用端口 3000、云端转发端口 18080,连接关系就是云端 127.0.0.1:18080 转到自建服务器 127.0.0.1:3000;实际值从端口登记和监听检查中确定。
部署第二个项目时,为它分配自己的端口、标签和目录。已有项目更新则保留这些配置,只生成新的发布版本,不每次重新挑端口。
在自建服务器启动应用
先按项目锁文件安装依赖、运行测试和生产构建。可以在自建服务器构建,也可以在兼容的构建环境生成产物后传过去;包含原生依赖时,核对系统与 CPU 架构。
发布与数据分开,例如:
应用目录/
releases/<发布标识>/ 程序和运行依赖
shared/ 配置、日志和持久化数据新目录接收本次产物,不原地覆盖旧版本。发布包排除凭据、私钥和用户数据,传输前后比对 SHA-256。记录源码版本;工作区有未提交修改时,额外保留对应快照。
按应用真实支持的配置方式,让服务监听 127.0.0.1:$APP_PORT。先检查进程与本机请求:
# 自建服务器执行;变量由实际配置填写
: "${APP_PORT:?请先设置应用端口}"
: "${HEALTH_PATH:?请先设置健康检查路径}"
lsof -nP -iTCP:"$APP_PORT" -sTCP:LISTEN
curl -fsS "http://127.0.0.1:$APP_PORT$HEALTH_PATH"确认监听者就是新启动的进程,响应版本也正确。新进程因端口占用退出、旧进程继续返回 200,不算启动成功;不要为了释放端口批量结束未知进程。
本机检查通过后,用 systemd、LaunchAgent 或现有容器管理方式托管应用。配置工作目录、环境变量、日志、启动和失败重试策略,并检查机器重启后的行为。macOS 的用户 LaunchAgent 依赖用户会话,不能默认等同于无人登录也会运行的系统服务。
建立反向 SSH 隧道
自建服务器主动连云服务器,云端不需要直接访问家庭内网。先在云端配置专用隧道账号和公钥,将远程转发权限限制到分配的 loopback 端口,不授予不必要的交互管理权限。
隧道使用专用密钥。最简单的限制写在云端该账号的 authorized_keys 里,在这把公钥前加 permitlisten="127.0.0.1:<TUNNEL_PORT>",restrict 前缀,让它只能转发这一个端口;密钥泄露也不能当通用登录用。
OpenSSH 服务端也可结合 AllowTcpForwarding remote、PermitListen 等配置限制该账号,并保持 GatewayPorts no。这些规则应限定到专用账号,先检查有效配置,不覆盖整台机器的 SSH 设置。具体约束见 sshd_config。
在自建服务器上前台试连:
: "${CLOUD_SSH:?请先设置已验证的云端 SSH 目标}"
: "${APP_PORT:?请先设置应用端口}"
: "${TUNNEL_PORT:?请先设置云端转发端口}"
ssh -NT \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-R "127.0.0.1:$TUNNEL_PORT:127.0.0.1:$APP_PORT" \
"$CLOUD_SSH"-R 左侧是在云服务器监听的地址,右侧目标由自建服务器上的 SSH 客户端连接。-N 不执行远端命令,-T 不分配终端。参数说明见 OpenSSH ssh。
ExitOnForwardFailure 能发现转发端口建立失败,但不代表后面的应用一定可达。保持隧道运行,在云服务器另一个终端检查:
# 云服务器执行,并在此终端填写同一组云端端口和健康路径
: "${TUNNEL_PORT:?}"
: "${HEALTH_PATH:?}"
ss -ltnp
curl -fsS "http://127.0.0.1:$TUNNEL_PORT$HEALTH_PATH"确认只出现预期的 loopback 监听,响应与自建服务器本机一致。无需向公网开放这个转发端口;公网只通过 Web 入口访问应用,SSH 入口按实际管理策略限制。
试连通过后,把同一条前台 SSH 命令交给进程管理器运行,配置开机启动、断线重启和重试间隔。保活用于发现断连,本身不负责重新连接;不要仅在终端加 & 就当作长期服务。
保活必须两头都配
上面的 ServerAliveInterval 只让自建服务器这一端发现断线并退出,进程管理器随后重连。但反向隧道的转发端口是由云服务器上的 sshd 持有的:如果云端没有配置保活,网络抖动后那条旧 sshd 不知道对端已经不在了,会继续占着 TUNNEL_PORT。此时每一次重连都会在云端日志里留下 bind [127.0.0.1]:TUNNEL_PORT: Address already in use,配合 ExitOnForwardFailure=yes 立即退出,进程管理器再拉起、再失败,如此循环。公网表现为 500/502,而应用本机健康检查却完全正常。这个状态要等内核 TCP 超时(Linux 默认约 2 小时)收掉旧 sshd 才会自愈。
所以云端 sshd_config 也要开服务端保活,让 sshd 在对端失联后主动断开并释放端口:
ClientAliveInterval 30
ClientAliveCountMax 3两个值相乘就是最长释放时间(这里 90 秒)。它是 sshd 全局配置,一台云服务器只需加一次,同机所有反向隧道一起受益;只想限定到隧道账号时放进 Match User 块。修改前备份配置,sshd -t 通过后再 systemctl reload,并保持一个已登录的备用会话,避免把自己锁在外面。参见 sshd_config 的 ClientAliveInterval。
症状判断与临时恢复
自建服务器上 launchctl print 显示隧道 state = running 却 last exit code = 255,公网 500/502,而应用本机健康检查正常,就按下面两步定性:
# 云服务器:端口被哪个 sshd 占着,日志里是否不断出现 bind 失败
ss -ltnp | grep ":$TUNNEL_PORT"
grep -E "bind \[127.0.0.1\]:$TUNNEL_PORT" /var/log/auth.log | tail -n 3日志文件名随系统而异(RHEL 系是 /var/log/secure,也可用 journalctl -u sshd)。看到 Address already in use 约每 10 秒一条,就是残留 sshd 占端口。临时恢复只结束占着该端口的那一个 sshd,进程管理器约 10 秒内会重连:
# 云服务器执行
pid=$(ss -ltnp | grep ":$TUNNEL_PORT" | grep -oE "pid=[0-9]+" | cut -d= -f2)
echo "占用的 sshd: $pid"; kill "$pid"不要按用户名批量 pkill sshd,那会把你自己的会话一起踢掉。根治靠上面的服务端保活。
上线前顺手验证一次:让隧道异常断开(例如给自建服务器断网几秒),看云端端口多久被释放、隧道多久重连成功。这个时间就是以后每次网络抖动的故障窗口。
配置域名、HTTPS 与反向代理
域名 A 记录指向云服务器公网 IPv4;使用 AAAA 时,也要保证 IPv6 入口确实可用。先核对生效 DNS 和虚拟主机,避免同一地址端口上出现相互冲突的站点配置。
最直接的证书方案是在云服务器签发并续期,因为 HTTPS 在这里终止。HTTP-01 需要公网挑战路径可达;DNS-01 通过 DNS TXT 验证,适合通配符或不开放 HTTP 挑战的环境。参见 挑战类型。
新域名优先用 DNS-01。 云服务器在国内、解析链路对境外不稳定时,certbot certonly --apache 会反复失败,且每次报错不同:
During secondary validation: DNS problem: SERVFAIL looking up CAA for ...
During secondary validation: ...: Timeout during connect
During secondary validation: DNSSEC: DNSKEY Missing: ...错误里只要有 During secondary validation,就不是你的 vhost、防火墙或 DNS 记录有问题:主验证点已通过,失败的是 Let's Encrypt 从境外随机抽取的二次验证点。这时不要去改配置,也不要加 acme-challenge 反代,直接改 DNS-01,它只要求能写 TXT 记录:
certbot certonly --manual --preferred-challenges dns -d example.com \
--manual-auth-hook "/root/dns-hook.py add" \
--manual-cleanup-hook "/root/dns-hook.py clean" \
--non-interactive --agree-tos钩子脚本的约定很简单,换 DNS 服务商只改这一个文件:add 读取环境变量 CERTBOT_DOMAIN、CERTBOT_VALIDATION,为 _acme-challenge.<域名> 写一条 TXT 记录并等待生效;clean 删除这条记录。DNS 服务商有官方 certbot 插件时(Cloudflare、DNSPod、Route53 等),直接用插件更省事。API 凭据用只有 DNS 读写权限的独立子账号,文件权限 600,放在部署目录之外。
certonly 只签发、不改 Apache,需要自己把 vhost 的 SSLCertificateFile、SSLCertificateKeyFile 指向 /etc/letsencrypt/live/example.com/ 下的 fullchain.pem、privkey.pem(改前先备份,apache2ctl configtest 通过再重载)。验证时别只看浏览器,它可能信任过旧的自签名证书:
echo | openssl s_client -connect <CLOUD_HOST>:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -issuer -dates
certbot renew --cert-name example.com --dry-runissuer 为 Let's Encrypt 且 curl 的 ssl_verify_result 为 0 才算成功。已经能正常续期的 HTTP-01 证书不必主动换,哪次续期因二次验证失败,再改成 DNS-01 即可。
下面是 Apache 2.4 的 HTTPS 反代示例,假设域名为 example.com、云端转发端口为 18080,证书已经签发。替换成实际值,并确认 ssl、proxy、proxy_http、headers 模块已加载:
<VirtualHost *:443>
ServerName example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
ProxyRequests Off
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:18080/
ProxyPassReverse / http://127.0.0.1:18080/
</VirtualHost>保留现有 TLS 安全配置和日志设置,不把示例当成全部站点配置。HTTP 入口按证书验证方式保留所需挑战路径,再安排跳转 HTTPS;不要把别人的内网挑战转发规则一起抄进来。
应用的公开 origin 应是 https://example.com,不是内部 HTTP 地址。只有指定代理可信,应用才使用其转发头;Cookie、登录回调、WebSocket、上传和媒体 Range 根据业务分别测试。
修改前备份 Apache 配置,语法检查通过才重载:
sudo apache2ctl configtest && sudo systemctl reload apache2同一台云服务器上可能并存两套互不相干的证书体系,排查前先分清:
| 云服务器本机 certbot | 证书由自建服务器签发 | |
|---|---|---|
| 验证方式 | HTTP-01(apache)或 DNS-01 | 通常是 webroot,vhost 里要把 /.well-known/acme-challenge/ 反代回自建服务器 |
| 证书位置 | /etc/letsencrypt/live/<域名>/ | 签发后复制到云端自定目录 |
| 续期 | certbot.timer | 自建服务器上的定时任务,续期后还要复制并重载 Apache |
只有后一种的 vhost 才需要 acme-challenge 反代,新建其他域名的 vhost 时不要连这段一起复制。查某个域名归哪一套:
grep -E "^authenticator|^installer" /etc/letsencrypt/renewal/example.com.conf 2>/dev/null \
|| echo "不在云服务器 certbot 管理下"
certbot certificates | grep -E "Certificate Name|Expiry"续期后必须重载 Apache,新证书才会生效。如果 /etc/letsencrypt/renewal-hooks/deploy/ 为空,放一个可执行脚本,内容为 #!/bin/sh 加 systemctl reload apache2,对这台机器上所有证书生效。证书由另一台机器签发时,分发和云端重载同样要自动化,不要手动复制测试证书覆盖线上文件。参见 Certbot 续期说明。
完成首次上线验收
| 层级 | 检查结果 |
|---|---|
| 自建服务器 | 应用进程存活,正式端口与健康响应版本正确 |
| 云端隧道端口 | loopback 监听正确,可以取得相同的应用响应 |
| 公网域名 | HTTPS 验证通过,路由到预期站点,没有旧版本或错误缓存 |
| 真实浏览器 | 页面、接口、登录和关键操作可用;移动端按实际业务复测 |
PUBLIC_ORIGIN='https://example.com'
curl -fsS -o /dev/null \
-w 'http=%{http_code} tls=%{ssl_verify_result}\n' "$PUBLIC_ORIGIN/"
curl -sSI "$PUBLIC_ORIGIN/"不加 -k 绕过证书验证,也不只凭首页 200 判定完成。检查浏览器请求和关键业务,用测试账号验证写操作。
正式使用前,在维护窗口验证隧道断开能否恢复,以及设备重启后应用和隧道能否重新接上。自建设备的睡眠策略、网络变化和供电条件,都是这套部署的一部分。
上线后的更新与回滚
日常更新主要发生在自建服务器,云端域名、证书和转发端口通常保持不变。
新产物放入新的 release 目录,用独立测试端口试运行;测试数据、定时任务和消息消费者也要隔离,避免试运行直接改生产数据。确认新进程、版本和关键接口后,备份服务定义,再将正式应用切到新版本。
几个出过事的细节:
- 试运行前先清理测试端口上的残留进程,并确认试运行进程仍然存活,最后才看健康检查。 否则新进程因端口占用立即退出,健康检查却打到旧进程返回
ok,等于没测到新包。清理只针对测试端口上的监听者,别批量结束未知进程。 - 打包前两端比对 SHA-256;包里不放
node_modules、.env、私钥和用户数据,也不要运行会同时打其他平台安装包的总构建命令。 - 切换前备份 launchd 的 plist(如加时间戳后缀),回滚就靠它。每次发布只改入口、工作目录和日志路径,
ProgramArguments只保留「Node + 当前入口」两项,整组重写,不要逐项追加。 - macOS 上
launchctl bootout是异步的:要等 job 真的消失再bootstrap,否则会报Bootstrap failed: 5: Input/output error,服务停在空窗里,公网 502。 - 一次发布只动应用自己的服务,不顺手重启隧道、证书任务或数据服务。
回滚同理:还原备份的 plist,等 job 消失,再 bootstrap 和 kickstart,然后重新验收。旧 release 目录保留着,没有备份时也可以把 plist 改回上一个 release。服务重启后用户登录态可能失效,数据本身不受影响。
正式应用继续使用原端口时,隧道和 Apache 通常不需要重启。普通单实例切换可能短暂中断请求;要求无中断时,需要另外设计双实例、流量摘除和连接排空,不能靠重启命令保证。
切换失败就恢复上一个已验证 release 和服务定义,再按“本机 → 云端隧道端口 → 公网 → 浏览器”复查。不要为了回滚应用顺便覆盖数据库、上传文件或同步数据。
数据格式有变化时,先确认旧代码兼容性。备份使用存储支持的一致性方式,并在隔离位置演练恢复;能列出备份文件,不代表备份一定可用。
更新同机上已上线的其他项目:先反查取值
两台机器上往往不止一个项目。要更新的项目已经上线时,端口、标签、目录都能在机器上查出来,不要凭记忆填,也不要照抄别的项目的值。只需要知道域名,然后按顺序做下面这些只读检查:
# 1. 自建服务器:项目的 LaunchAgent(标签通常以项目名开头:服务、隧道、数据库)
ls ~/Library/LaunchAgents/ | grep -v '\.bak'; launchctl list | grep -i <项目名>
# 2. 读服务 plist:入口、工作目录、端口、环境变量(密钥类只看键名,不贴值)
plutil -p ~/Library/LaunchAgents/<标签>.plist
# 3. 读隧道 plist 的 -R,得到「云端端口:本机端口」
plutil -p ~/Library/LaunchAgents/<标签>.tunnel.plist | grep -A1 -- '-R'
# 4. 云服务器:用域名反查 vhost 和反代端口,两处端口要对得上
grep -rl <域名> /etc/apache2/sites-available/
grep -n ProxyPass /etc/apache2/sites-available/<域名>*.conf
# 5. 部署根目录:最近一次 release 的结构,就是这次要复现的样子
ls -la "<部署根目录>/releases/<最近一次>" # 看哪些是符号链接,哪些是构建产物查到后照上一次发布的结构做,不要套用别的项目的发布脚本,因为包结构不同。要核对的点:发布包里带源码还是构建产物、依赖在哪里安装、哪些路径链到 shared/、试运行端口、plist 入口怎么写。发布前先确认本地 git status 干净且已推送。
两个已踩过的坑:
- 构建时共享目录里有用户上传的文件会失败。 如果 release 里的
storage是指向shared/storage的符号链接,Next/Turbopack 构建会遍历它,遇到已上传的文件直接报错。构建时先放一个空目录,构建完再换回符号链接。 plutil -replace ProgramArguments.N在下标已存在时是插入,不是替换,会留下旧入口,服务起不来,公网 502。逐项改会让参数越积越多,所以发布脚本应当用plutil -replace ProgramArguments -json '["<NODE>","<新入口>"]'整组重写;手动改时也可以先plutil -remove ProgramArguments.N再-insert。改完用plutil -p看一遍数组,只应有两项。
数据服务的备份与恢复
应用旁如果还有存用户数据的常驻服务,日常 Web 发布不碰它;只有改它的代码、迁移数据、变更数据格式或处理数据异常时才动。原则:
- 数据目录在 release 目录之外,发布只换代码,不换数据。目录名带项目名,不与其他项目重名。
- 动手前先备份:
rsync -a 源/ 备份/data/,不加--delete,备份的目的就是保留旧文件。随后在备份目录生成SHA256SUMS,恢复前可校验。 - 恢复的顺序是:停数据服务及其隧道,先别动 Web → 整目录改名保留现状(不直接删)→
shasum -a 256 -c SHA256SUMS校验后复制回备份 → 先启数据服务,再启隧道。 - 不手改单个数据文件,不删历史目录,不用浏览器缓存覆盖服务端数据。
- 恢复完成后,用各端测试账号做新增、修改、删除和离线恢复测试,再宣布完成。
改动前后可以数一遍数据文件和历史目录的数量作最小核对,两次应一致。
日常排障与资源检查
| 表现 | 排查顺序 |
|---|---|
公网 502 | 应用本机 → 隧道进程 → 云端监听和请求 → Apache 错误日志 |
| 应用本机正常,隧道进程反复重启 | 云端 ss -ltnp 看谁占着转发端口,日志里有 Address already in use 就是残留 sshd;结束那一个 sshd 进程即可当场恢复,根治靠云端 ClientAlive* 保活(见“症状判断与临时恢复”) |
| 开发机连不上自建服务器 | 先核对用户名是 MINI_USER、地址是固定 IP,再确认 Tailscale 在线,最后才查 authorized_keys(见“连不上自建服务器时”) |
开发机 dig 说域名不存在 | 可能是代理 fake-ip 劫持,改在云服务器上查或走 DoH |
| 内网正常,公网登录或跳转异常 | 公共 origin、Host/转发头、Cookie 和 HTTPS 配置 |
| 重启后网站离线 | 自建服务器会话、服务自启动、密钥读取、隧道重连 |
| 访问慢 | 应用负载、自建网络上行、隧道延迟、云端带宽和连接数 |
| 证书签发或续期失败 | 报错含 During secondary validation 是境外二次验证点的问题,不是你的配置,改用 DNS-01;其他错误再查 DNS、挑战可达性和 ACME 出站连接 |
| 证书已续期但浏览器仍是旧证书 | 检查续期后是否重载了 Apache(renewal-hooks/deploy),并用 openssl s_client 看实际下发的证书 |
两台机器分别监控:自建服务器看应用、数据、磁盘与供电网络;云服务器看代理、隧道端口、证书和流量。日志设置容量与保留期限,并脱敏凭据及个人信息。
纯静态站不必走隧道
判断只问一个问题:部署完成后,有没有一个需要你自己常驻运行的进程?Node、Python 后端有,走本文的隧道方案;Vite、Astro、Hugo 等构建后只剩 html、css、js 和图片的站没有(前端直接调第三方公开 API 也算静态站),直接把构建产物放到云服务器,用 Apache 的 DocumentRoot 提供即可。
为静态站硬套隧道,要凭空造一个进程,多占一个端口、一把密钥、两个服务,每个请求多一跳家庭宽带,可用性还从“云服务器”变成“云服务器 × 家宽 × 隧道 × 自建服务器在线”。静态站放云服务器几乎不占资源,只是多一个虚拟主机。
做法是 release 目录加软链,回滚只改软链:
/var/www/example.com/releases/<时间戳>/
/var/www/example.com/current -> releases/<时间戳>DocumentRoot /var/www/example.com/current
<Directory /var/www/example.com/current>
# current 是软链,缺 +FollowSymLinks 会 403
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
</Directory>入口 index.html 不缓存,带内容哈希的 js、css 可以长期强缓存。部分系统的 mime 表缺 webp,需要 AddType image/webp .webp。切换软链即时生效,不需要重载 Apache。证书同样用上面的 DNS-01。
这套结构把主要计算和数据搬到了自己的设备上,而不是消除了运行成本。只要把公网入口、隧道、应用和数据各自的职责分清,首次上线和后续维护就能沿着同一条请求链完成。

暂无评论