一台刚开的云主机,默认离“能对外服务”差着十件事:密码登录开着、防火墙只有厂商那层、时钟可能偏、日志无限增长、备份不存在、崩了没人知道。任何一件漏掉,都会在某个深夜变成事故。
这篇是一份照着敲的十步清单,每步只留最小动作,容易出错的地方单独标出。
1. 十步清单总览
| 步 | 动作 | 完成标志 | 漏了会怎样 |
|---|---|---|---|
| 1 | 建普通用户 + 装公钥 | ssh deploy@host 免密可登 |
一直用 root 密码,爆破目标 |
| 2 | 防火墙(安全组 + ufw) |
只开 22/80/443 | 数据库端口暴露公网 |
| 3 | 时区与 NTP | timedatectl 显示同步 |
TLS 校验失败、日志时间错乱 |
| 4 | swap 取舍 | 有或明确没有 | 内存打满直接 OOM kill |
| 5 | Docker(若用) | docker run hello-world |
手工装运行时,升级地狱 |
| 6 | 反向代理 | nginx -t 通过、80 可达 |
应用端口直接对外 |
| 7 | TLS | 证书链完整、自动续期 | 浏览器告警、API 调用失败 |
| 8 | 日志轮转 | logrotate -d 无错 |
磁盘写满,全站 502 |
| 9 | 备份 | 能从备份恢复一次 | 数据丢失不可逆 |
| 10 | 监控与自检 | 有告警通道、脚本可跑 | 故障靠用户投诉发现 |
顺序不能乱:先收紧入口,再谈服务;先有备份,再谈上线。
2. 第 1 步:用户与密钥
$ adduser deploy
$ usermod -aG sudo deploy
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@SERVER_IP
$ sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
$ sudo chmod 600 /home/deploy/.ssh/authorized_keys
| 检查项 | 正确值 | 错误后果 |
|---|---|---|
~/.ssh 权限 |
700 |
sshd 拒绝使用密钥 |
authorized_keys 权限 |
600 |
同上,日志提示 bad permissions |
| 家目录 | 不能 group/other 可写 | 同上 |
| 公钥内容 | 单行、以 ssh-ed25519 或 ssh-rsa 开头 |
多行粘贴导致整段失效 |
先开新终端验证免密能登,再改 sshd 配置;顺序反了会把自己锁在门外。
3. 第 2 步:双层防火墙
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
$ sudo ufw allow 22/tcp
$ sudo ufw allow 80,443/tcp
$ sudo ufw enable
$ sudo ufw status verbose
| 层 | 位置 | 管什么 | 谁先拦截 |
|---|---|---|---|
| 云安全组 | 厂商网络层 | 实例级入站/出站 | 先,包还没到网卡 |
ufw / firewalld |
主机内核 netfilter | 进程与端口 | 后 |
两层作用不同:安全组挡“谁能把包送进来”,ufw 挡“进来了能不能到端口”。只配一层,换厂商或加网卡就会漏。
Docker 会绕过 ufw:发布端口由 DOCKER-USER 链处理,ufw deny 8080 无效。要么在 DOCKER-USER 加规则,要么让容器只绑 127.0.0.1 由 nginx 转发。
4. 第 3 步:时区与 NTP
$ sudo timedatectl set-timezone Asia/Shanghai
$ timedatectl
$ systemctl status systemd-timesyncd --no-pager
$ chronyc tracking # 装了 chrony 时
| 指标 | 正常 | 异常处理 |
|---|---|---|
System clock synchronized |
yes |
查 UDP 123 是否被安全组拦 |
NTP service |
active |
systemctl enable --now systemd-timesyncd |
时钟不对表现隐蔽:证书“尚未生效”、JWT 一签发就过期、日志顺序错乱。先查这一步比怀疑代码快。
5. 第 4 步:swap 的取舍
$ sudo fallocate -l 2G /swapfile
$ sudo chmod 600 /swapfile
$ sudo mkswap /swapfile
$ sudo swapon /swapfile
$ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
$ free -h
| 场景 | 建议 | 理由 |
|---|---|---|
| ≤2G 内存、跑数据库 | 给 2G swap,vm.swappiness=10 |
避免 OOM 直接杀进程 |
| ≥8G 内存、纯应用 | 可以不给 | 换页反而拖慢 |
| 容器密集 | 给,且限制容器内存 | 否则一个容器吃满整机 |
swap 是缓冲不是方案,上了它仍然要配内存告警。
6. 配套项:自动补丁与暴力破解防护
$ sudo apt install -y unattended-upgrades fail2ban
$ sudo dpkg-reconfigure -plow unattended-upgrades
$ sudo fail2ban-client status sshd
| 项 | 建议 | 说明 |
|---|---|---|
unattended-upgrades |
开,只装安全更新 | 默认不自动重启,内核要手动重启生效 |
fail2ban |
看是否已禁密码登录 | 已禁密码 + 云厂商防护时收益有限 |
与 ufw 共存 |
走 iptables 链 | sudo iptables -L -n 看 f2b 链 |
前者解决“三个月后才发现某个 CVE 没打”,建议一律开;后者在关掉密码登录后属于可选项,判断依据是还有没有暴露面。
7. 第 5–7 步:Docker、反代、TLS
$ curl -fsSL https://get.docker.com | sudo sh
$ sudo usermod -aG docker deploy
$ sudo mkdir -p /etc/docker && sudo tee /etc/docker/daemon.json <<'EOF'
{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } }
EOF
$ sudo systemctl restart docker
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
$ sudo nginx -t && sudo systemctl reload nginx
$ sudo certbot --nginx -d example.com -d www.example.com
$ sudo certbot renew --dry-run
| 配置 | 不写会怎样 |
|---|---|
daemon.json 日志上限 |
容器日志无限增长,写满磁盘 |
X-Forwarded-For |
应用只看到 127.0.0.1,限流失效 |
X-Forwarded-Proto |
应用生成 http:// 链接,混合内容告警 |
certbot renew --dry-run |
三个月后才发现续期失败 |
nginx -t 不通过绝不 reload:语法错误会让整站挂掉。改配置前先 cp 一份带时间戳的备份。
8. 第 8 步:日志轮转
$ sudo tee /etc/logrotate.d/app >/dev/null <<'EOF'
/var/log/app/*.log {
daily
rotate 14
maxsize 100M
compress
delaycompress
missingok
notifempty
copytruncate
}
EOF
$ sudo logrotate -d /etc/logrotate.d/app # 预演,不实际执行
$ sudo logrotate -f /etc/logrotate.d/app # 强制跑一次验证
| 指令 | 作用 | 坑 |
|---|---|---|
daily + maxsize |
按时间或大小触发 | 只写 daily 时,一天内暴涨不管 |
copytruncate |
复制后清空原文件 | 应用仍持有 fd 时安全,但可能丢几行 |
rotate 14 |
保留 14 份 | 与磁盘容量一起算 |
compress |
gzip 旧文件 | — |
journald 单独管:/etc/systemd/journald.conf 里设 SystemMaxUse=500M,否则 /var/log/journal 会悄悄吃几个 G。
9. 第 9 步:备份
$ crontab -e
# 每天 03:20 全量库 + 每小时增量文件
20 3 * * * /usr/bin/mysqldump --single-transaction --routines --triggers app > /backup/app-$(date +\%F).sql && gzip -f /backup/app-$(date +\%F).sql
30 3 * * * rclone sync /backup remote:bucket/backup --max-age 30d
| 备份对象 | 命令 | 验证方式 |
|---|---|---|
| MySQL | mysqldump --single-transaction |
在临时库 source 一次 |
| 配置文件 | tar czf /backup/etc-$(date +%F).tgz /etc |
解包对比 diff -r |
| 对象存储 | rclone sync |
— |
| 整机 | 云快照 | 定期演练恢复 |
cron 里的 % 必须转义成 \%。备份脚本要有错误输出与“超过 N 小时没新备份就告警”的检查——没有告警的备份等于没有备份。
10. 第 10 步:自检脚本与监控
#!/usr/bin/env bash
set -uo pipefail
fail=0
check() { if eval "$2" >/dev/null 2>&1; then echo "OK $1"; else echo "FAIL $1"; fail=1; fi; }
check "nginx running" "systemctl is-active --quiet nginx"
check "http 200" "curl -fsS -o /dev/null -m 5 http://127.0.0.1/"
check "disk <85%" "[ \$(df --output=pcent / | tail -1 | tr -dc 0-9) -lt 85 ]"
check "port 443 listen" "ss -tlnH 'sport = :443' | grep -q ."
check "cert >14d" "openssl s_client -connect 127.0.0.1:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -checkend 1209600"
exit $fail
| 检查项 | 覆盖的故障 |
|---|---|
| 服务状态 | 进程挂了但机器还在 |
| HTTP 探测 | 进程在但 upstream 不通 |
| 磁盘水位 | 慢性的“写满即 502” |
| 证书剩余 | 续期失败 |
脚本放 /usr/local/bin/preflight.sh,chmod +x 后接 cron 或告警,上线前跑一次、日常定时跑。
11. 回滚点:改配置前先备份
| 改动 | 备份动作 | 回滚方式 |
|---|---|---|
| nginx 配置 | cp -a /etc/nginx /backup/nginx-$(date +%F-%H%M) |
覆盖回去 + nginx -t + reload |
| sshd 配置 | 同上,保留原文件 | 覆盖 + sshd -t + reload |
| unit 文件 | systemctl cat 输出存文件 |
覆盖 + daemon-reload |
| 数据库结构 | 变更前 mysqldump --no-data |
用快照或反向 SQL |
| 整机 | 云快照 | 回滚快照(分钟级) |
快照可靠但粒度粗、耗时,文件级备份快而细。两个都要:“误删一个文件”不值得回滚整机,“内核升级炸了”只能整机回滚。
12. 排错:六种上线即翻车
| 现象 | 原因 | 处理 |
|---|---|---|
| 改完 sshd 连不上 | 配置有误或密钥权限不对 | 控制台 VNC 进;sshd -t 先验证 |
80 端口不通但本机 curl 通 |
安全组未放行 / ufw 拦 |
两层都查 |
| 容器端口对外暴露 | Docker 绕过 ufw |
绑 127.0.0.1 或改 DOCKER-USER |
| 证书续期失败 | 80 端口被占、DNS 变了 | certbot renew --dry-run 定位 |
| 备份任务静默失败 | cron 环境变量缺失、% 未转义 |
脚本里写全路径,重定向日志 |
13. 边界:这些不该自己搭
| 需求 | 更合适的方案 | 理由 |
|---|---|---|
| 高可用 | 托管负载均衡 + 多可用区 | 单机自建做不到故障切换 |
| 证书与 DNS | 托管 ACM + DNS API | 续期与解析耦合更少 |
| 日志与指标 | 托管可观测平台 | 自建 ELK 的维护量超过业务 |
一句话收口:先收口入口(用户、密钥、防火墙),再保证可观测(日志、监控、自检),最后才是服务本身。 十步里前四步和第八、九步与业务无关,但恰恰是最容易被跳过、也最容易在深夜咬人的部分。
14. 自测
- 云安全组与
ufw各挡哪一层?为什么两层都要配? - 为什么
ufw deny 8080挡不住 Docker 发布的端口? - 改 sshd 配置前必须先做什么验证动作?
logrotate里copytruncate解决什么问题,代价是什么?cron里的date +%F为什么必须写成\%F?- 什么情况下该用云快照回滚,而不是文件级备份?