27
0
0

核云服务器的巡检、加固与踩坑实录

2026-10-15
文章摘要
|

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%。整体健康。

但巡检发现两个问题:

  1. 没有配置任何 Swap(Swap: 0B)
  2. 系统提示需要重启(内核已更新但未生效)

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 只访问域名也能进。

请求 结果
/?(不带 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。

吊销(二选一):

  1. 轮换签名密钥(最彻底,所有已签发 Cookie 当场失效):删除凭据存储中的签名密钥记录后重启服务
  2. 仅清除自己浏览器的 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/

所以必须配套:

  1. 源站只允许 CDN 回源(防火墙只放行 CDN 官方网段)
  2. Nginx 恢复真实客户端 IP(real_ip_header CF-Connecting-IP 等)

第 2 点极易被忽略:不配的话,Nginx 看到的全是 CDN 节点 IP,你的限速会把所有访客当成同一个人,直接误伤所有人,日志和封禁系统也全部失真。

免费套餐的两个实际坑

坑 影响
单文件上传 100MB 上限 网盘类服务传大文件会失败(下载不受限)
WebSocket 空闲 100 秒断开 长连接需心跳,源站配的 proxy_read_timeout 3600s 不再起作用

应急动作清单(按顺序)

  1. CDN 开启 Under Attack Mode(边缘 JS 挑战,秒级生效)
  2. 云安全组临时只留 CDN 回源网段,掐断直连
  3. iptables -I INPUT -s <攻击源> -j DROP 精确封禁
  4. 降级:暂停重服务(网盘/大文件),保住 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;
    }
}

两个关键细节

  1. 必须用 $request_uri 而不是 $uri:前者包含完整路径 + 查询串,跳转后参数不丢。

    /archives/123?utm_source=test  →  https://www.example.com/archives/123?utm_source=test  ✅
    
  2. 必须保留 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 / 证书错误"时,要检查三处是否一致:

  1. 站点配置里引用的证书路径
  2. 该路径下的文件是否真实存在
  3. 证书是否是最新版本

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)    # 校验证书与私钥是否匹配

写在最后

这次从"看一眼服务器状态"开始,最后覆盖了资源规划、鉴权机制、网络架构、容器网络、配置持久化和证书生命周期。几个我认为最值得记住的点:

  1. URL 里的令牌是一次性的,长效凭据是 Cookie —— 换 token 不等于踢人下线
  2. Docker 发布端口绕过 ufw —— 必须从绑定地址根治
  3. grep 搜不到 ≠ 不存在 —— 二进制字段(bytea)要用正确的解码方式
  4. replace(bytea,bytea,bytea) 在 PostgreSQL 里不存在 —— 而且报错被抑制时会伪装成成功
  5. 改配置不生效时,先问"它从哪里读" —— 命令行 / 文件 / 环境变量 / 数据库,优先级可能反直觉
  6. 证书仓库目录名可能是内容哈希 —— 直接引用会在续期后失效
  7. 别用间接证据做影响他人的决定 —— 一张关联表为空,不代表功能没在被使用

小机器也能跑得稳,前提是你知道它的边界在哪里,以及哪些防线必须放在它之外。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论