这次不是迁移,是迁移之后的小内存 VPS 被现实敲了一下。
现象很像网络问题:本机 SSH alias 直连秒断,云厂商 Web Console 卡在 connecting,Cloudflare 侧的 API 变成 521。更麻烦的是,机器上的代理服务还正常,说明不是整台机完全死掉,也不是浮动 IP、TUN 代理、SSH key 这类一眼能解释的问题。
最后重启以后 SSH 恢复,才有机会进机器复盘。结论很明确:512MB 内存太紧,系统在网络收包路径上发生了大量页分配失败,Nginx stream、SSH、API 这些入口服务被拖进了半死不活的状态。
故障现象 #
当时 SSH 失败停在密钥认证之前:
kex_exchange_identification: Connection closed by remote host这个错误点很关键。它说明连接已经到了远端,但还没进入正常 SSH 认证流程,所以不是 key 没换、权限不对、known_hosts 脏了这种问题。
架构上,这台机器的入口有点绕:
flowchart LR
Client["client"]
Nginx["nginx stream :443"]
SSH["sshd :22"]
API["api backend :8000"]
FM["filebrowser"]
CF["Cloudflare"]
Client -->|SSH over 443| Nginx
CF -->|api.example.com| Nginx
CF -->|fm.example.com| Nginx
Nginx -->|default stream| SSH
Nginx -->|SNI api| API
Nginx -->|SNI fm| FM所以 SSH、API、文件服务都会经过 Nginx stream。只要 Nginx、内核网络栈或者本机内存状态出问题,外面看到的就会很像“SSH 挂了”和“API 挂了”同时发生。
真正的证据 #
重启后翻内核日志,关键字不是 sshd,而是这些:
journalctl -k --since "2026-06-13 00:00:00" | \
egrep -i "page allocation failure|out of memory|oom|killed process"重启前出现了多次 page allocation failure,相关进程包括:
containerd-shimcontainerddockerdhysteriarunckswapd0
堆栈集中在网络收包路径,能看到类似 virtio_net、tcp_gro_receive、skb_page_frag_refill 这些函数。也就是说,不是单个业务进程写爆了日志那么简单,而是低内存状态已经影响到了内核处理网络包。
这也解释了为什么现象会这么诡异:TCP 端口可能还能接,代理 UDP 服务也可能还活着,但 SSH 握手、Nginx stream 转发、API upstream 都可能在内存紧张时随机卡住或断开。
第一轮:系统和内核收拾干净 #
先把系统包和内核状态整理到一个可控状态。
XanMod 源之前还停留在老写法:
deb [signed-by=/etc/apt/keyrings/xanmod-archive-keyring.gpg] http://deb.xanmod.org releases main这个源已经不适合当前 Debian 13,改成按 codename 的源:
deb [signed-by=/etc/apt/keyrings/xanmod-archive-keyring.gpg] http://deb.xanmod.org trixie main然后安装当前 x64v3 的 XanMod 内核:
apt update
apt install linux-xanmod-x64v3
reboot重启后确认:
uname -r
# 7.0.12-x64v3-xanmod1旧内核也清掉,只保留当前 XanMod 相关包,避免 /boot 和 grub 里堆一堆过期入口:
apt autoremove --purge
apt clean
update-grub顺手清理 journal 和 Docker 无用对象:
journalctl --vacuum-time=7d
docker system prune -af这一步不碰 Docker volumes,避免误删业务数据。
第二轮:给 512MB 内存留后路 #
这台机器实际内存只有 454MiB 左右,不能指望“应用别吃太多”这种愿望管理。要让系统在内存紧张时有明确退路。
扩 swap #
把 swap 扩到 2GB:
swapoff /swapfile
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile/etc/fstab 保持:
/swapfile none swap sw 0 0zswap 改成 zstd #
zswap 本来已经开了,但默认 compressor 是 lzo。确认内核支持 zstd:
grep -i zstd /proc/crypto
grep CONFIG_CRYPTO_ZSTD /boot/config-$(uname -r)运行时切换:
echo zstd > /sys/module/zswap/parameters/compressor
cat /sys/module/zswap/parameters/compressor
# zstd再持久化到 grub:
GRUB_CMDLINE_LINUX_DEFAULT="zswap.enabled=1 zswap.compressor=zstd net.ifnames=0 biosdevname=0"
update-grubsysctl 留一点网络和内存余量 #
新增 /etc/sysctl.d/99-lowmem-network-tuning.conf:
vm.min_free_kbytes = 16384
vm.swappiness = 30
vm.vfs_cache_pressure = 100
net.core.netdev_max_backlog = 2500这里 vm.min_free_kbytes 一开始试过更高,但在 512MB 机器上太激进,反而压缩了用户态可用内存。最后回到 16MB 左右,比较像这台机器能承受的值。
第三轮:决定谁该先死 #
小内存机器最怕的是大家一起抢内存,最后内核随机挑一个关键入口杀掉。要把优先级说清楚。
保护入口服务 #
给 ssh、nginx、supervisor 加 systemd drop-in:
[Service]
OOMScoreAdjust=-700实际检查时,sshd 自己已经是 -1000,Nginx、Supervisor 和 gunicorn 都是 -700:
for pid in $(pgrep -f "sshd|nginx|supervisord|gunicorn"); do
printf "%s score=%s adj=%s cmd=%s\n" \
"$pid" \
"$(cat /proc/$pid/oom_score)" \
"$(cat /proc/$pid/oom_score_adj)" \
"$(tr '\0' ' ' </proc/$pid/cmdline)"
done注意,如果 SSH 是通过 Nginx stream 进来的,重启 nginx 会断 SSH。改这种入口服务配置时,要么只 reload,要么准备好重连。
限制容器 #
几个代理和文件服务容器都补上资源限制:
services:
hysteria:
mem_limit: 96m
memswap_limit: 160m
pids_limit: 128
oom_score_adj: 500不同容器按实际情况微调:
| 容器 | mem_limit | memswap_limit | pids_limit | oom_score_adj |
|---|---|---|---|---|
| hysteria | 96m | 160m | 128 | 500 |
| hysteria2 | 96m | 160m | 128 | 500 |
| tuic-server | 64m | 128m | 128 | 500 |
| filebrowser | 96m | 160m | 128 | 500 |
这里踩了一个小坑:这台 Debian 包里的 Docker 是 26.1.5+dfsg1,docker update 不支持动态改 --oom-score-adj:
docker update --oom-score-adj 500 hysteria
# unknown flag: --oom-score-adj所以 oom_score_adj 要写进 compose,然后重建容器:
docker compose up -d --force-recreate验证:
docker inspect hysteria hysteria2 tuic-server filebrowser \
--format "{{.Name}} OOM={{.HostConfig.OomScoreAdj}} Mem={{.HostConfig.Memory}} Swap={{.HostConfig.MemorySwap}} Pids={{.HostConfig.PidsLimit}}"earlyoom 提前处理 #
最后加 earlyoom,让它在内核真正 OOM 前先动手:
apt install earlyoom/etc/default/earlyoom:
EARLYOOM_ARGS="-m 10,5 -s 20,10 -r 300 --prefer '(^|/)(hysteria|tuic-server|filebrowser)( |$)' --avoid '(^|/)(sshd|sshd-session|nginx|supervisord|gunicorn|systemd|dockerd|containerd)( |:|$)'"这和 systemd 的 OOMScoreAdjust 不冲突。OOMScoreAdjust 会影响 /proc/*/oom_score,earlyoom 默认也会参考这个分数;--avoid 只是再加一层“别主动杀这些入口服务”的保险。
启动后日志里能看到策略:
Preferring to kill process names that match regex '(^|/)(hysteria|tuic-server|filebrowser)( |$)'
Will avoid killing process names that match regex '(^|/)(sshd|sshd-session|nginx|supervisord|gunicorn|systemd|dockerd|containerd)( |:|$)'
sending SIGTERM when mem avail <= 10.00% and swap free <= 20.00%,
SIGKILL when mem avail <= 5.00% and swap free <= 10.00%日志别再把盘打满 #
这台机器根分区也不大,journal 和 Docker log 都要限一下。
/etc/systemd/journald.conf.d/99-vps-limits.conf:
[Journal]
SystemMaxUse=128M
SystemKeepFree=512M
RuntimeMaxUse=16M
MaxRetentionSec=7day
RateLimitIntervalSec=30s
RateLimitBurst=1000Docker daemon 默认日志:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "5m",
"max-file": "2"
}
}另外关掉 UFW logging:
ufw logging off之前有不少扫描流量触发 UFW BLOCK 日志,对小盘小内存机器都没什么好处。
最后验证 #
收尾时做了几组检查:
uname -r
# 7.0.12-x64v3-xanmod1
apt list --upgradable
# Listing...
systemctl --failed --no-pager
# 0 loaded units listed.
systemctl is-active earlyoom ssh nginx supervisor docker containerd
# active
# active
# active
# active
# active
# active内存状态:
Mem: 454Mi total, 235Mi available
Swap: 2.0Gi total, about 271Mi used入口验证:
ssh VPS_ALIAS 'echo ok'
curl -sS -o /dev/null -w '%{http_code}\n' https://api.example.com/
curl -sS -o /dev/null -w '%{http_code}\n' https://fm.example.com/结果是 SSH 正常,API 返回应用层 404,文件服务返回 200。API 的 404 是业务路由结果,不是 Cloudflare 521,也不是 upstream 挂掉。
再查优化后的内核日志:
journalctl -k --since "2026-06-13 06:28:32 UTC" --no-pager | \
egrep -i "out of memory|oom-kill|page allocation failure|killed process"没有新的 OOM 或 page allocation failure。
小结 #
这次最大的教训是:512MB VPS 可以跑,但不能靠默认配置硬扛。
尤其是 SSH 走 Nginx stream、API 也走同一个 443 入口时,入口服务必须被保护起来。真正该先让步的是代理容器、文件服务、临时任务这些低优先级进程,而不是 sshd、nginx 和 API supervisor。
当然,所有这些优化都只是把 512MB 的边界往外推一点。要从根上解决,还是升到 1GB RAM。低配能折腾,不能迷信。