核云服务器的巡检、加固与踩坑实录
title: 1 核云服务器的巡检、加固与踩坑实录 date: 2026-10-14 tags: - Linux - Nginx - Docker - 运维 - 安全加固
起因很简单:本来只想看一眼服务器状态,结果一路从内存、Swap 查到了鉴权机制、Docker 绕过防火墙、证书续期,最后卡在 Halo 的配置持久化上。
本文记录完整过程与踩到的坑。文中所有 IP、域名、端口、账号凭据、Token 均已脱敏(服务器地址统一写作
203.0.113.10,域名写作example.com)。
0. 环境与背景
| 项目 | 配置 |
|---|---|
| 系统 | Ubuntu 24.04 LTS (aarch64) |
| CPU / 内存 | 1 vCPU / 5.8 GiB |
| Web | Nginx(面板编译版) |
| 数据库 | MySQL(宿主)+ PostgreSQL(容器,供博客使用) |
| 部署 | Docker 容器 + systemd 服务混合 |
| 站点 | 博客(Halo)、网盘(OpenList)、自托管 WebUI |
典型的"小马拉大车"配置:所有服务挤在 1 核 5.8G 上,这决定了后面所有架构选择——尤其是抗压能力。
1. 健康巡检:先看数据,别先猜
巡检顺序建议固定下来,形成肌肉记忆:
# 系统与负载
uname -a && uptime && nproc
# 内存与 Swap
free -h && swapon --show
# 磁盘与 inode(inode 爆满也会导致无法写文件)
df -hT -x tmpfs -x devtmpfs
df -i -x tmpfs -x devtmpfs
# 压力指标(PSI):比 load 更直观
cat /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io
# 服务健康
systemctl --failed
# 资源占用 Top
ps -eo pid,user,%cpu,%mem,etime,cmd --sort=-%mem | head
本次结果:负载 0.35/0.20/0.12(1 核,很轻)、内存 5.8G 用 2.3G、根分区 17%、0 个失败服务、CPU 压力 avg10=3.87%。整体健康。
但巡检发现两个问题:
- 没有配置任何 Swap(
Swap: 0B) - 系统提示需要重启(内核已更新但未生效)
2. 加 Swap:小内存机器的保险绳
为什么必须要 Swap
5.8G 内存看着够用,但 Halo(Java) + 双 MySQL + Node 服务已经吃掉 1.7G,一旦有突发峰值就是直接 OOM Kill——被杀的往往是最占内存的那个进程。Swap 不能提升性能,但它能在峰值时把冷页换出去,避免进程被直接杀死。
操作
# 创建 4G Swap 文件
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo chown root:root /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 写入 fstab 实现开机自动挂载
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
# 降低交换倾向(服务器通常建议 10)
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system
几个值得注意的点
| 点 | 说明 |
|---|---|
fallocate vs dd |
ext4 上 fallocate 秒建;某些文件系统不支持时退回 dd |
chmod 600 |
必须,否则 mkswap 会警告且存在安全风险 |
findmnt --verify 的警告 |
swapfile 会报 non-bind mount source is a directory or regular file,这是正常提示,不是配置错误 |
swappiness=10 的取舍 |
降低但不禁止交换;内核在真正有压力时仍会换出匿名页,不会因此 OOM |
验证:free -h 出现 Swap: 4.0Gi,swapon --show 列出 /swapfile。
3. 内核更新后的重启:怎么做得又稳又不掉线
系统提示 *** System restart required ***,说明新内核已安装但仍在跑旧内核:
echo "运行: $(uname -r)"
ls /boot/vmlinuz-* # 已安装的内核
重启前必做的检查
重启最怕的是"服务起不来"。先确认它们都能自启:
systemctl is-enabled deepseek-harness nginx mysqld docker
docker inspect <容器> --format '{{.Name}} -> {{.HostConfig.RestartPolicy.Name}}'
本次 4 个容器全部有重启策略(always / unless-stopped),服务全部 enabled,可以放心重启。
用 systemd 定时器安排重启(比 shutdown -r 更可靠)
sudo systemd-run --on-active=30 --unit=my-reboot /sbin/reboot
为什么不用 shutdown -r +2?因为它会被任何人一条 shutdown -c 取消——本次就真实发生了:我安排的重启被另一个 root 会话取消了,导致计划凭空消失。用 systemd 瞬时定时器可以:
- 与会话/终端完全解耦(SSH 断开也不影响)
- 立即生效、可查询:
systemctl list-timers my-reboot* - 反悔也简单:
systemctl stop my-reboot.timer
重启后验证:uname -r 变成新内核、swapon --show 显示 Swap、systemctl --failed 为空、容器全部 Up。
4. 一个反直觉的问题:换了 Token,旧链接为什么还能访问?
这是本次最有价值的发现。
现象
自托管 WebUI 每次重启都会在日志里打印一个新的访问地址,形如:
http://127.0.0.1:<PORT>/?token=<每次都不一样>
按理说重启后旧链接就该失效。但实际情况是:重启后浏览器用旧链接依然能直接进入,甚至不带 token 只访问域名也能进。
实测(无 Cookie 裸请求)
| 请求 | 结果 |
|---|---|
/?(不带 token) |
401 |
/?token=<旧 token> |
401 已失效 |
/?token=<新 token> |
303 See Other + 下发 Cookie |
| 只带 Cookie、不带 token | 200 正常进入 |
结论很清楚:旧 token 确实失效了,但浏览器靠一张 Cookie 进去了。
机制
这套鉴权有两个完全独立的凭据:
① URL 里的 token = 进程级启动令牌(一次性)
const created = encodeBase64Url(randomBytes(32)); // 每次进程启动随机生成
它有三个特征:
- 每次进程启动重新随机生成 → 所以重启就换
- 只能用于首次进入:仅当请求满足
GET+ 路径为/+ 恰好一个 token 参数 + 比对通过时,才签发 Cookie 并 303 跳转 - 比对用
timingSafeEqual(防时序攻击)
② 浏览器 Cookie = 持久会话凭据
set-cookie: <auth-cookie>=v1.<payload>.<HMAC-SHA256>;
Max-Age=2592000; Path=/; Expires=<30 天后>; HttpOnly; SameSite=Strict
- 有效期 30 天
- 签名密钥持久化存储,跨重启不变(这是关键)
- 校验时只验签名和有效期,完全不看启动令牌
- Cookie 名与
authority绑定(sha256(authority))→ 不同访问域名各持一份
完整因果链
第一次用带 token 的链接访问
↓ 校验启动令牌通过(一次性)
↓ 303 跳转,token 从地址栏消失
↓ 下发 30 天有效、持久密钥签名的 Cookie
浏览器存下 Cookie → 此后所有访问都靠 Cookie
↓
重启后启动令牌换了(旧 URL token 失效)
但签名密钥没变 → Cookie 依然有效 → 你依然在登录状态
所以:换 token ≠ 强制下线。 改端口、重启、重新生成 token 都踢不掉已登录的浏览器,最长可保持 30 天。
如何自证 + 如何真正吊销
自证:把链接复制到无痕窗口打开 → 立刻 401。
吊销(二选一):
- 轮换签名密钥(最彻底,所有已签发 Cookie 当场失效):删除凭据存储中的签名密钥记录后重启服务
- 仅清除自己浏览器的 Cookie(只影响本机)
这个机制值得记住:很多自托管工具都用"一次性 URL 令牌 + 长效签名 Cookie"的模式,URL 里的令牌从来不是长效凭据。
5. 大流量攻击怎么防:1 核机器的现实
先认清现实
1 核 1Gbps 的机器,不可能靠自身扛住任何规模的洪水:
- L3/L4 洪水(SYN Flood、UDP 反射)在网卡/内核层就打满,Nginx 根本没机会处理
- L7 洪水(CC、401 洪水)每个请求都要过一遍业务代码,1 核瞬间 100%
有个容易忽略的点:即使鉴权拒绝对数据库零开销,每次请求仍要做一次 HMAC 校验,CPU 照样被吃满。也就是说——打不穿,但能耗死。
核心原则:把攻击流量拦在机器之外,而不是在机器上处理它。
两条路:橙云代理 vs 隧道
方案 A:CDN 橙云代理(DNS 层)
用户 ──→ CDN 边缘节点(吸收/拦截攻击) ──回源──→ 源站:443 ──→ Nginx
↑ 攻击在这里被挡掉 ↑ 但这台机器仍在公网监听
服务器零改动,只改 DNS。缺点:源站 IP 仍暴露。
方案 B:Tunnel 隧道
用户 ──→ CDN 边缘 ──→ 隧道(服务器主动拨出的长连接)──→ 127.0.0.1:<内部端口>
↑ 服务器不开任何入站端口
服务器主动连出去,公网没有入口。可以关掉 80/443,只剩 SSH。
| 橙云代理 | Tunnel | |
|---|---|---|
| 你要操作 | DNS 点几下 | 后台建隧道 + 装客户端 |
| 服务器端口 | 80/443 仍需开放 | 可全关 |
| 源站 IP 暴露 | ⚠️ 仍暴露,可被绕过 | ✅ 彻底隐身 |
| 还需额外配置 | 源站锁 CDN 回源 + real_ip | 无 |
⚠️ 只开橙云 = 纸糊的防护
源站 IP 会通过历史 DNS 记录、证书透明度日志等渠道泄露。攻击者一条命令就能绕过 CDN:
curl --resolve your-domain.com:443:203.0.113.10 https://your-domain.com/
所以必须配套:
- 源站只允许 CDN 回源(防火墙只放行 CDN 官方网段)
- Nginx 恢复真实客户端 IP(
real_ip_header CF-Connecting-IP等)
第 2 点极易被忽略:不配的话,Nginx 看到的全是 CDN 节点 IP,你的限速会把所有访客当成同一个人,直接误伤所有人,日志和封禁系统也全部失真。
免费套餐的两个实际坑
| 坑 | 影响 |
|---|---|
| 单文件上传 100MB 上限 | 网盘类服务传大文件会失败(下载不受限) |
| WebSocket 空闲 100 秒断开 | 长连接需心跳,源站配的 proxy_read_timeout 3600s 不再起作用 |
应急动作清单(按顺序)
- CDN 开启 Under Attack Mode(边缘 JS 挑战,秒级生效)
- 云安全组临时只留 CDN 回源网段,掐断直连
iptables -I INPUT -s <攻击源> -j DROP精确封禁- 降级:暂停重服务(网盘/大文件),保住 SSH 与核心服务
6. 安全审计:几个真实的暴露点
6.1 最容易被忽略的:Docker 发布端口绕过 ufw
这是本次审计最值得写的一条。
现象:ufw status 里没有放行某端口,但 ss -tlnp 显示它监听在 0.0.0.0。
原因:Docker 发布端口时会在 nat 表写 DNAT 规则,流量走 FORWARD 链而不是 INPUT 链,而 ufw 的规则只作用于 INPUT。结果就是 ufw 完全管不住 Docker 发布的端口。
# 查看 Docker 的 DNAT 规则(注意 -d 和目标端口的转换)
sudo iptables -t nat -S DOCKER
# -A DOCKER ! -i br-xxxx -p tcp --dport 13306 -j DNAT --to-destination 172.x.x.x:3306
正确解法不是加防火墙规则,而是从绑定地址根治——把 0.0.0.0 改成 127.0.0.1:
# docker-compose.yml
ports:
- "127.0.0.1:13306:3306" # 而不是 "13306:3306"
改完必须重建容器(端口绑定在创建时确定),但数据在 bind mount 里,不受影响。稳妥流程:
# 1. 备份配置
cp .env .env.bak-$(date +%Y%m%d%H%M%S)
# 2. 改前记录数据库清单(用于改后比对)
docker exec <容器> mysql -uroot -p"$PW" -N -e "show databases;" | sort
# 3. 修改并重建
sed -i 's|^HOST_IP=0\.0\.0\.0$|HOST_IP=127.0.0.1|' .env
docker compose up -d
# 4. 改后比对数据 + 验证监听地址 + 从外部复测
docker port <容器> # 应显示 127.0.0.1:13306
6.2 端口暴露面的排查方法
三个视角必须交叉对照,缺一不可:
sudo ufw status # 防火墙允许了什么
sudo ss -tlnp # 进程实际监听了什么
nc -zv <公网IP> <端口> # 外部实际能不能连上
本次审计还发现:面板端口对公网可达,且面板应用层已开启"禁止境外访问"(这降低了风险,但不等于安全)。
6.3 一个方法论教训:不要用间接证据下结论
我一度读到一个数据库文件里有 admin / <默认密码哈希> 的记录,立刻怀疑是出厂弱口令 + 公网暴露,甚至已经准备发警报。
核实后发现:那是遗留的模板库(时间戳是 2016 年、IP 是内网模板地址),真正的用户库在另一个路径下,账号和密码都正常。
教训:判断安全问题时,必须确认你读的那份数据就是运行时真正生效的那份。中间文件、备份文件、模板文件都可能误导你。
另一件事同理:我曾通过"某张关联表为空"推断"你没在用手机 App",据此关掉了面板端口,结果导致 App 直接连不上——关联表为空不等于功能没被使用,别拿间接证据做影响他人的决定。
7. 域名统一:根域名 301 跳转到 www
站点同时能通过根域名和 www 访问,内容完全重复。决定把根域名统一跳到 www。
server {
listen 80;
listen 443 ssl;
server_name example.com;
ssl_certificate /path/to/cert/fullchain.pem;
ssl_certificate_key /path/to/cert/privkey.pem;
# ACME HTTP-01 校验目录 —— 必须保留!
location ^~ /.well-known/acme-challenge/ {
root /path/to/webroot;
default_type text/plain;
allow all;
}
# 根域名统一跳转($request_uri 保留路径与查询参数)
location / {
return 301 https://www.example.com$request_uri;
}
}
两个关键细节
必须用
$request_uri而不是$uri:前者包含完整路径 + 查询串,跳转后参数不丢。/archives/123?utm_source=test → https://www.example.com/archives/123?utm_source=test ✅必须保留 ACME 校验 location:
location /一旦改成return 301,如果校验路径也被跳转走,证书续期会直接失败——而这个故障要 90 天后才暴露。用^~前缀匹配可以让它优先于其他 location。
为什么 443 上还要留证书? 因为跳转本身也要先完成 TLS 握手,证书缺失就不是 301 而是证书错误了。
8. 最大的坑:Halo 的 external-url 改了不生效
背景
Halo 的 external-url 配的是根域名,但现在根域名 301 跳到 www。这会导致 Halo 生成的所有绝对链接(RSS、站点地图、后台跳转)都要多一跳。于是按标准做法改配置:
# docker-compose.yml
command:
- --halo.external-url=https://www.example.com/
docker compose up -d 重建容器,docker inspect 确认参数已生效……但站点地图依然输出旧域名。
排查路径
① 看日志 → 无任何 externalUrl 相关记录
② 看主题文件 → 无硬编码域名
③ 看磁盘缓存 → 无 sitemap/rss 缓存文件
④ 全文 grep 数据库 → 竟然也搜不到旧域名 ❌
第 ④ 步是关键,也是最大的陷阱:grep 搜不到不等于不存在。
根因
Halo 2.x 把配置持久化在数据库的 ConfigMap 里,表结构是这样的:
CREATE TABLE extensions (
name varchar(255) PRIMARY KEY,
data bytea, -- ← 二进制!
version bigint
);
data 是 bytea(二进制),pg_dump | grep 输出的是十六进制转义,明文 grep 永远搜不到。必须按二进制正确解码:
-- 用 escape 编码解码后搜索
select name from extensions where encode(data,'escape') like '%example%';
找到后确认结构:
/registry/configmaps/system
└─ data
├─ authProvider
├─ basic → { title, language, externalUrl: "https://example.com/" } ← 就是这里
└─ theme
结论:命令行参数只在首次初始化时写入数据库,之后以数据库中的值为准,改启动参数不再生效。
第二个坑:PostgreSQL 没有 replace(bytea, bytea, bytea)
直觉上会这么写:
-- ❌ 报错:function replace(bytea, bytea, bytea) does not exist
update extensions set data = replace(data, convert_to('old','UTF8'), convert_to('new','UTF8'))
where name = '/registry/configmaps/system';
更坑的是:这条语句报错后如果 stderr 被重定向掉了,你会以为它执行成功了(我就是这样浪费了一轮排查)。
正确写法是走文本往返:
-- ✅ 先转 text,替换,再转回 bytea
update extensions
set data = convert_to(
replace(convert_from(data,'UTF8'), 'https://example.com/', 'https://www.example.com/'),
'UTF8')
where name = '/registry/configmaps/system';
第三个坑:改库前必须先停服务
Halo 会把内存中的配置回写数据库。如果服务还在跑,你改完它可能立刻用旧值覆盖回去。
正确顺序:
# 1. 备份原始二进制数据(base64 便于保存)
docker exec <db> psql -U <user> -d <db> -t -A \
-c "select encode(data,'base64') from extensions where name='/registry/configmaps/system';" \
> configmap-backup.b64
# 2. 停服务(防止回写)
docker compose stop <service>
# 3. 改库
# 4. 校验 JSON 合法性(别把配置改坏)
# 5. 启动服务
docker compose up -d
# 6. 验证:站点地图 / RSS 里的链接是否已更新
改完验证 sitemap.xml 和 rss.xml 输出的是新域名 ✅。
通用教训:现代应用的"配置"往往是多来源叠加的——命令行参数、配置文件、环境变量、数据库持久化,优先级各不相同。改了不生效时,第一反应应该是"它到底从哪里读的",而不是"我再改一遍"。
9. 证书续期:一个会在 90 天后爆炸的隐患
现象与根因
面板的证书仓库目录名长这样:
/www/server/panel/vhost/ssl_saved/a1b2c3d4e5f60718293a4b5c6d7e8f90/
这个哈希经实测是 md5(证书内容):
md5sum fullchain.pem
# a1b2c3d4e5f60718293a4b5c6d7e8f90 ← 与目录名完全一致
证书内容一变,哈希就变,目录路径就变。 所以:
❌ 直接让 Nginx 指向证书仓库路径 → 续期后路径失效,HTTPS 直接挂 ✅ 复制到站点证书目录,并用脚本按域名动态解析仓库路径
解决方案:同步脚本 + 定时任务
#!/bin/bash
# 从数据库动态解析当前证书路径,内容有变化就同步并 reload
DB="/path/to/ssl_data.db"
DEST_ROOT="/www/server/panel/vhost/cert"
for domain in example.com; do
dest="$DEST_ROOT/$domain"
src=$(sqlite3 "$DB" "select path from ssl_info where subject='$domain' order by id desc limit 1;")
[ -f "$src/fullchain.pem" ] || continue
# 内容相同就跳过
cmp -s "$src/fullchain.pem" "$dest/fullchain.pem" && continue
cp -f "$src/fullchain.pem" "$src/privkey.pem" "$dest/"
chmod 600 "$dest"/*.pem
changed=1
done
[ "$changed" = "1" ] && nginx -t && nginx -s reload
定时任务安排在续期任务之后执行,并留出足够间隔。
另一个发现:证书在库里 ≠ 已部署
查询证书记录时看到某域名有有效证书,但 use_for_site 是空数组,vhost/cert/<域名>/ 目录根本不存在。也就是说:证书申请成功了,但从未部署到站点——这也正是那份站点配置当初被停用的真正原因(配置引用了不存在的证书路径,Nginx 起不来)。
所以排查"网站 404 / 502 / 证书错误"时,要检查三处是否一致:
- 站点配置里引用的证书路径
- 该路径下的文件是否真实存在
- 证书是否是最新版本
10. 最终状态与入口清单(脱敏)
| 服务 | 入口 | 状态 |
|---|---|---|
| 博客前台 | https://www.example.com/ |
200 |
| 博客后台 | https://www.example.com/console |
302 → 登录页 |
| 根域名 | https://example.com/ |
301 → www |
| 网盘 | https://pan.example.com/ |
200(走 CDN) |
| 自托管 WebUI | https://app.example.com/ |
401(需令牌) |
| 面板 | https://203.0.113.10:<PANEL_PORT>/<安全路径> |
200 |
| SSH | 203.0.113.10:22 |
密钥登录 |
公网暴露面:22 / 80 / 443 / <PANEL_PORT>,其余全部收回本机。Docker 发布的数据库端口已改为 127.0.0.1 绑定。
内核已更新、Swap 4G 生效(开机自挂载)、证书续期已配自动同步。
附:本次用到的命令速查
# ── 巡检 ──────────────────────────────
uptime && free -h && df -hT && df -i
cat /proc/pressure/cpu
systemctl --failed
ps -eo pid,user,%cpu,%mem,cmd --sort=-%mem | head
# ── Swap ─────────────────────────────
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10
# ── 重启 ─────────────────────────────
systemd-run --on-active=30 --unit=my-reboot /sbin/reboot
systemctl list-timers my-reboot*
# ── 端口暴露面 ────────────────────────
ufw status && ss -tlnp && nc -zv <IP> <PORT>
iptables -t nat -S DOCKER # 看 Docker 是否绕过了 ufw
# ── Nginx ────────────────────────────
nginx -t && nginx -s reload
nginx -T | grep -A5 <关键词> # 导出实际加载的配置
# ── Docker ───────────────────────────
docker inspect <容器> --format '{{.HostConfig.RestartPolicy.Name}}'
docker inspect <容器> --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}'
# ── 证书 ─────────────────────────────
openssl x509 -noout -subject -dates -in fullchain.pem
diff <(openssl x509 -noout -modulus -in fullchain.pem) \
<(openssl rsa -noout -modulus -in privkey.pem) # 校验证书与私钥是否匹配
写在最后
这次从"看一眼服务器状态"开始,最后覆盖了资源规划、鉴权机制、网络架构、容器网络、配置持久化和证书生命周期。几个我认为最值得记住的点:
- URL 里的令牌是一次性的,长效凭据是 Cookie —— 换 token 不等于踢人下线
- Docker 发布端口绕过 ufw —— 必须从绑定地址根治
grep搜不到 ≠ 不存在 —— 二进制字段(bytea)要用正确的解码方式replace(bytea,bytea,bytea)在 PostgreSQL 里不存在 —— 而且报错被抑制时会伪装成成功- 改配置不生效时,先问"它从哪里读" —— 命令行 / 文件 / 环境变量 / 数据库,优先级可能反直觉
- 证书仓库目录名可能是内容哈希 —— 直接引用会在续期后失效
- 别用间接证据做影响他人的决定 —— 一张关联表为空,不代表功能没在被使用
小机器也能跑得稳,前提是你知道它的边界在哪里,以及哪些防线必须放在它之外。
