Codex 一条 chmod 命令把 AWS Lightsail 打挂了:完整排查与恢复记录

AI 编程助手终端中的危险 chmod 命令导致 Linux 云服务器故障,并通过快照进行恢复

我原本只是想让 Codex 帮我完善 about-ozone.com 的 WordPress 内容和 SEO。为了省事,我允许它通过 SSH 登录 AWS Lightsail,并使用 sudo 操作生产服务器。

结果,一条权限命令让网站、SSH 和多项系统服务同时失联:Cloudflare 返回 522,普通 SSH 报 Server refused our key,Lightsail 浏览器 SSH 出现 UPSTREAM_ERROR [515],实例状态检查也开始失败。

数据最终没有丢失。通过保留故障现场、挂载 root volume snapshot 做离线排查,再从可启动的实例快照重建服务器,我找到了真正原因,也完整恢复了 WordPress、Nginx、MariaDB、Redis 和网络服务。

这篇文章不是一篇泛泛的“AI 安全”讨论,而是一次真实的 AWS Lightsail 生产事故复盘:症状是什么,为什么一开始容易误判为 SSH Key 故障,如何保护现场、定位根因、恢复服务,以及以后应该怎样限制 AI coding agent 的生产权限。

重要说明: 本文中的命令用于还原事故和排查过程,不是适用于所有服务器的修复手册。涉及快照、磁盘挂载、权限和系统服务时,应先保护数据,再根据自己的发行版、磁盘布局和服务配置逐项确认。

事故概览

  • 原始任务: 通过 Codex 优化生产 WordPress 文章和 SEO。
  • 直接原因: 自动化操作将 Linux 根目录第一层的多个系统目录改成了 0600
  • 主要影响: 网络、SSH、Nginx、MariaDB、Redis 以及 WordPress 对外服务异常。
  • 容易误判的现象: SSH 拒绝密钥、Lightsail 浏览器 SSH 报错、Cloudflare 522。
  • 数据情况: 数据库、WordPress 文件和系统目录内部的大量文件并未被递归修改。
  • 恢复路径: root volume snapshot 保留现场 → 救援实例只读挂载并分析日志 → 从实例快照创建新实例 → Launch Script 恢复明确目录的权限 → 临时 IP 验证 → 切换 Static IP。

最开始的症状:SSH、Browser SSH 和网站一起失联

最先看到的是普通 SSH 报错:

Server refused our key

与此同时,Lightsail 自带的浏览器 SSH 也无法连接,并出现:

UPSTREAM_ERROR [515]

如果只看这两个现象,很容易怀疑:

  • SSH 私钥不对;
  • authorized_keys 损坏;
  • .ssh 权限错误;
  • Lightsail 默认密钥或实例 CA 配置异常。

但很快,网站也无法访问,Cloudflare 开始返回:

522 Connection timed out

这时问题的范围已经明显超出 SSH Key:不只是管理员进不去,源站本身也无法正常对外提供服务。

Lightsail Metrics 指向 Guest OS,而不是 AWS 宿主机

我接着查看 Lightsail 控制台中的 Metrics。当时大致表现为:

  • CPU 不高;
  • Burst capacity 正常;
  • StatusCheckFailed-System 基本为 0;
  • StatusCheckFailed-Instance 明显异常;
  • Network In / Out 开始下降。

这组信号说明,AWS 底层宿主机大概率没有故障,问题更可能出在 Guest OS,也就是实例中的 Ubuntu 系统。

Cloudflare 522 在这里是结果,不是根因。既然连 Lightsail 的 Instance Status Check 都失败,继续围着 Cloudflare 配置打转不会解决问题。

第一原则:先做快照,保留故障现场

服务器完全失联后,我没有立即重装,也没有反复尝试未知修复,而是先通过 AWS CloudShell 创建 Lightsail 根磁盘快照。

下面保留命令结构,但实例名称和快照名称已经匿名化:

aws lightsail create-disk-snapshot \
  --region us-west-2 \
  --instance-name <INSTANCE_NAME> \
  --disk-snapshot-name <SNAPSHOT_NAME>

随后检查快照进度:

aws lightsail get-disk-snapshot \
  --region us-west-2 \
  --disk-snapshot-name <SNAPSHOT_NAME> \
  --query 'diskSnapshot.[state,progress,sizeInGb,fromInstanceName]' \
  --output table \
  --no-cli-pager

最终确认状态为 completed、进度 100%,得到一份完整的 80GB 系统盘副本。

这一步的价值不仅是“有备份”,更是冻结事故发生后的磁盘状态。后面即使修复尝试失败,仍然可以回到未经二次破坏的现场重新判断。

用救援实例只读挂载恢复盘

由于原实例已经无法 SSH,我创建了一台临时 Ubuntu Lightsail 作为救援实例,并将根磁盘快照恢复成 80GB Block Storage Disk,再挂载到救援实例。

整体关系如下:

故障实例
   │
   └── Root Volume Snapshot
             │
             ↓
      80GB Block Storage Disk
             │
             ↓
       临时 Ubuntu Rescue

在救援实例中先识别磁盘:

sudo lsblk -o NAME,SIZE,FSTYPE,TYPE,MOUNTPOINTS,UUID

当时看到的结构类似:

nvme0n1        40G
└─nvme0n1p1    39G ext4   /

nvme1n1        80G
└─nvme1n1p1    79G ext4

其中 nvme0n1 是救援实例自己的系统盘,nvme1n1 才是从故障实例恢复出来的磁盘。

数据保护警告:不要格式化恢复盘。 在没有确认设备身份前,不要对恢复盘执行 mkfs。格式化会把原本仍可恢复的数据变成真正的数据损失。

我先以只读方式挂载,并使用 noload 避免重放 ext4 journal:

sudo mkdir -p /mnt/ozone

sudo mount -o ro,noload \
  /dev/nvme1n1p1 \
  /mnt/ozone

挂载后,原服务器的 /etc/home/usr/var/root 等目录都还在。

先排除磁盘和 inode 耗尽

很多 Linux 服务器故障与磁盘写满有关,所以我先检查容量和 inode:

df -hT /mnt/ozone
df -i /mnt/ozone

实际结果约为:77GB 总容量、12GB 已使用、65GB 可用,使用率 16%;inode 也只使用了约 3%。

因此可以排除:

  • 磁盘 100%;
  • inode 耗尽;
  • 日志把磁盘撑满。

真正异常:系统目录权限变成了 600

继续检查恢复盘根目录时,异常非常明显:

drw------- 600 /etc
drw------- 600 /usr
drw------- 600 /root
drw------- 600 /opt
drw------- 600 /snap
...

目录的权限含义和普通文件不同。对目录而言,x 不只是“执行”,还决定进程能否进入和遍历这个目录。

例如 /etc0600 时,即使进程知道 /etc/hosts 在哪里,也无法穿过 /etc 访问它;/usr0600 时,/usr/bin/usr/sbin/usr/lib 下的大量程序和库也会变得不可达。

日志随后给出了直接证据:

Failed to stat /etc/hosts: Permission denied
Failed to stat /etc/resolv.conf: Permission denied

接下来是网络服务 watchdog timeout:

systemd-resolved.service: Watchdog timeout
systemd-networkd.service: Watchdog timeout

重启后,更多服务因为无法穿过 /usr 而执行失败:

systemd-resolved:
Failed to execute /usr/lib/systemd/systemd-resolved:
Permission denied

systemd-networkd:
Failed to execute /usr/lib/systemd/systemd-networkd:
Permission denied

redis-server:
Failed to execute /usr/bin/redis-server:
Permission denied

mariadb:
Failed to execute /usr/sbin/mariadbd:
Permission denied

故障链路由此变得清晰:

/usr 等一级目录权限异常
        ↓
systemd-networkd / systemd-resolved 无法正常执行
        ↓
实例网络失效
        ↓
Lightsail Instance Status Check 失败
        ↓
源站无法响应,Cloudflare 返回 522
        ↓
SSH 同样无法正常工作

auth.log 找到根因:chmod 0600 /*

离线检查 /var/log/auth.log 中事故时间附近的 sudo 记录后,整个操作链被还原出来。

事故前几分钟,同一个 SSH Key 正在执行 about-ozone.com 的 WordPress 部署任务,先在 staging 运行 WP-CLI,又进入 production,导出数据库并保存插件列表、主题列表和页面渲染结果。随后,若干部署和备份文件被复制到了系统根目录 /

紧接着出现了一条 chmod。日志中记录的展开结果包含:

sudo chmod 0600 \
  /about-ozone-phase2a-seopress-social.php \
  /bin \
  /bin.usr-is-merged \
  /boot \
  /deploy-post-855.php \
  /dev \
  /etc \
  /home \
  /lib \
  /lib.usr-is-merged \
  /lib64 \
  /lost+found \
  /media \
  /mnt \
  /opt \
  ...
  /root \
  /run \
  /sbin \
  /snap \
  /srv \
  /sys \
  /tmp \
  /usr \
  /var

从行为看,原意很可能只是把刚复制到 / 的几个备份文件设为 0600,但构造命令时使用了根目录 glob。

危险:不要执行下面这条命令。它会破坏 Linux 根目录下系统目录的权限。

chmod 0600 /*

Shell 会先展开 /*,然后才把结果交给 chmod。它不是“只匹配根目录中的普通文件”,而是匹配根目录第一层几乎所有非隐藏项目,包括目录和符号链接,例如:

/bin
/boot
/dev
/etc
/home
/lib
/root
/run
/sbin
/tmp
/usr
/var
...

因此,这条命令实际相当于把上述所有目标一起设为 0600。系统目录失去 x 权限后,内部文件即使保持原权限,系统也无法正常遍历和执行。

这次最幸运的一点,是没有执行递归命令:

chmod -R 0600 /

后续离线检查发现,目录内部的关键程序文件仍保留正常权限:

/usr/lib/systemd/systemd-networkd 755
/usr/lib/systemd/systemd-resolved 755
/usr/sbin/mariadbd               755
/usr/sbin/sshd                   755

也就是说,主要被误伤的是根目录第一层目标,而不是整棵文件系统被递归改坏。这为恢复留下了空间。

SSH Key 没坏,拒绝密钥只是连锁反应

最初的 Server refused our key 很像密钥或 authorized_keys 问题,但离线检查显示:

/home/ubuntu/.ssh                  700
/home/ubuntu/.ssh/authorized_keys 600

Lightsail 的实例 CA 公钥文件存在,sshd_config 中对应的 TrustedUserCAKeys 配置也正常。

因此,SSH 错误不是事故根因,而是系统目录权限、网络和相关进程被破坏后的表象。这个区别很重要:如果此时只围绕密钥反复修改,既无法恢复网络,也可能破坏原本正确的 SSH 配置。

最终恢复:从实例快照重建,而不是在原机硬修

现场已经通过 root volume snapshot 保留下来,离线调查也确认了根因。因为当时还存在可启动的 Lightsail 实例快照,我最后没有直接在原实例上继续试错,而是采用更可回滚的路径:

实例快照
    ↓
创建新 Lightsail 实例
    ↓
创建时加入 Launch Script
    ↓
只修复已确认异常的一级目录权限
    ↓
启动并检查系统服务
    ↓
使用临时 IP 验证源站
    ↓
切换原 Static IP

恢复脚本的核心原则是:目标必须逐项明确列出,不使用递归,也不使用根目录 wildcard。

下面是当时恢复脚本的思路记录:

#!/bin/bash

exec >/var/log/permission-recovery.log 2>&1
set -x

chmod 0755 /

chmod 0755 \
  /etc \
  /usr \
  /home \
  /var \
  /boot \
  /media \
  /mnt \
  /opt \
  /snap \
  /bin.usr-is-merged \
  /lib.usr-is-merged \
  /sbin.usr-is-merged

chmod 0700 /root
chmod 0700 /lost+found
chmod 1777 /tmp

chmod 0755 \
  /usr/bin \
  /usr/lib \
  /usr/sbin

[ -d /usr/lib64 ] && chmod 0755 /usr/lib64

systemctl reset-failed || true
systemctl daemon-reload || true

systemctl restart systemd-networkd || true
systemctl restart systemd-resolved || true
systemctl restart ssh || true

systemctl restart mariadb || true
systemctl restart redis-server || true
systemctl restart php8.4-fpm || true
systemctl restart nginx || true

这段脚本只记录本次事故中已经确认的权限和服务,不应该直接复制到其他服务器。不同 Ubuntu 版本、磁盘布局、PHP 版本和安装方式可能需要不同处理。执行任何修复前,应先建立可恢复的快照,并离线确认真实权限差异。

恢复后如何验证

新实例启动后,先检查关键目录权限:

stat -c '%A %a %U:%G %n' \
  / /etc /usr /usr/bin /usr/lib /usr/sbin \
  /var /home /root /tmp

恢复后的结果为:

/          755
/etc       755
/usr       755
/usr/bin   755
/usr/lib   755
/usr/sbin  755
/var       755
/home      755
/root      700
/tmp       1777

然后检查失败的 systemd unit:

systemctl --failed

结果是:

0 loaded units listed

SSH、Nginx HTTPS、MariaDB 和 Redis 对应的服务与端口恢复;Nginx、PHP-FPM、MariaDB、Redis、SSH、systemd-networkdsystemd-resolved 均恢复为 active (running)

切换 Static IP 前,先绕过 Cloudflare 测源站

新实例启动后会先获得临时公网 IP。此时不要急着修改 DNS 或切换 Static IP,而是用 --resolve 让请求直接命中新源站,同时保留真实域名的 Host 与 TLS SNI:

curl -vI \
  --connect-timeout 10 \
  --resolve example.com:443:<NEW_IP> \
  https://example.com/

只有在证书验证通过并返回 HTTP/2 200 后,才能说明网络、Nginx、SSL、PHP、WordPress 和 MariaDB 这条关键链路已经工作。两个站点都通过源站测试后,我才把原来的 Lightsail Static IP 从旧实例解绑并绑定到新实例。

Cloudflare DNS 原本就指向这个 Static IP,所以无需修改 DNS;IP 完成切换后,522 随即消失。

Static IP 切换后的 SSH Host Key 警告

Static IP 绑定到新实例后,再通过同一个 IP 连接 SSH,Windows 会提示:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!

这并不自动等于中间人攻击:IP 没变,但背后的实例已经变了,新实例自然拥有新的 SSH Host Key。

我先在 Lightsail 控制台确认 Static IP 确实已经切到预期实例,再删除该 IP 的旧 known_hosts 记录:

ssh-keygen -R <STATIC_IP>

重新连接时,再核对并接受新实例的 Host Key。顺序不能反过来;在没有确认实例切换的情况下,不应为了消除警告而盲目接受新指纹。

能打开网站,不等于恢复工作已经完成

这次自动化过程把多项临时文件直接放到了 /,包括部署脚本、回滚脚本、数据库导出、插件与主题列表、页面渲染结果等。其中数据库备份约 29MB。

网站恢复后仍需要完成一次完整性审计,例如:

  • 系统关键目录权限;
  • 系统包完整性;
  • systemd failed units 与 journal warning/error;
  • Nginx 和 SSH 配置测试;
  • MariaDB、Redis、cron/timer 与 Certbot 状态;
  • 根目录临时文件清单和逐项清理。

“逐项”是关键。清理时同样不能使用 /* 之类的 glob,也不能看到陌生文件就批量删除。每个目标都应先确认来源、用途、最近使用情况和回滚方式。

真正的问题:内容任务获得了整台服务器的权限

任何自动化工具、人类管理员或运维脚本都可能写错命令。真正的问题不是“Codex 是否永远不会犯错”,而是我把以下能力放进了同一个、长期有效的权限范围:

SSH 登录
+ sudo
+ 生产服务器
+ WordPress
+ 系统配置

任务原本只是优化 WordPress 文章,但执行者同时可以修改 /etc/usr/root/var。在这种权限模型下,一次 shell glob 展开错误就足以从内容层一路影响到操作系统。

事故后,我把任务明确分成两种模式

Website Content Mode

适用于:

文章、页面、SEO、图片、菜单、内链、WordPress 设置

默认只通过浏览器和 WordPress 后台操作,不允许自行升级到 SSH、WP-CLI、数据库或 Linux。如果后台无法完成,就停止并说明限制。

Server Maintenance Mode

只有在我明确要求 Nginx、PHP-FPM、MariaDB、Redis、SSL、systemd、cron、Linux、防火墙或性能排查时,才允许进入服务器维护范围,并且进一步写明边界,例如:

MODE: SERVER MAINTENANCE
SCOPE: nginx + php8.4-fpm

“允许 SSH”不再等于“允许修改整台服务器”。

我现在明确禁止的命令模式

下面这些命令模式不应由自动化工具在生产服务器执行:

chmod 600 /*
chmod 755 /*
chmod -R ... /
chown -R ... /
rm -rf /
rm -rf /*

它们的共同问题是把 chmodchownrm 与根目录、递归或 wildcard 组合在一起,影响范围远超单个任务。

确实需要修改权限时,目标应使用经过确认的完整路径逐项列出:

chmod 600 /明确/文件1
chmod 600 /明确/文件2

不要为了少打几个字符,把生产安全交给 shell glob。

AI coding agent 操作生产服务器的 6 条原则

  1. 内容任务默认不提供 SSH。
  2. SSH 和 sudo 按任务、按范围授权,而不是永久无限授权。
  3. AI 使用独立 SSH Key,不与管理员共用私钥。
  4. 重要修改前先建立 snapshot 或经过验证的 backup。
  5. 涉及 //etc/usr/var/lib/mysql、网络、SSH 或防火墙时,先展示最终命令和影响范围。
  6. 生产操作把数据完整性、可回滚、稳定性与安全性放在便利之前。

遇到 Lightsail 全面失联,可以按什么顺序排查

如果同时出现网站 522、普通 SSH 不通、Browser SSH 不通、Instance Status Check Failed,不要立刻重装,也不要先执行广泛修复命令。我的实际排查顺序是:

1. 查看 Lightsail Metrics
        ↓
2. 创建实例快照和 root volume disk snapshot
        ↓
3. 创建临时 Ubuntu rescue instance
        ↓
4. 将 root volume snapshot 恢复成磁盘
        ↓
5. 只读挂载恢复盘
        ↓
6. 检查磁盘容量与 inode
        ↓
7. 检查 /var/log/auth.log
        ↓
8. 检查 journal / syslog
        ↓
9. 检查根目录权限
        ↓
10. 明确根因
        ↓
11. 再决定离线修复还是从快照重建

不要一开始就盲目执行:

chmod -R
chown -R
fsck -y
rm -rf

在根因不明时,这些操作可能把本来可恢复的问题变成真正的数据灾难。

FAQ

Lightsail 浏览器 SSH 出现 UPSTREAM_ERROR 515,一定是 SSH Key 坏了吗?

不一定。本次事故中,authorized_keys 和 Lightsail CA 配置都正常;真正的问题是系统目录权限破坏导致网络和系统服务异常。Browser SSH 报错只是症状之一,需要结合 Instance Status Check、网络指标和离线日志判断。

Cloudflare 522 是否说明 Cloudflare 配置有问题?

不一定。522 表示 Cloudflare 无法及时连接源站。本次事故中,源站 Ubuntu 的网络服务已经异常,因此即使 Cloudflare 配置不变,也会出现 522。

能不能直接把所有目录 chmod 755 修回来?

不应该。不同目录的正确权限并不相同,例如 /root 通常不是 755,/tmp 还需要 sticky bit。更重要的是,故障原因未确认前进行批量权限修改会覆盖证据并制造新的安全问题。应先做快照、只读检查,再修复已经确认的具体目标。

为什么不直接运行 fsck -y

因为当时没有证据表明文件系统损坏,容量和 inode 也正常。fsck -y 会自动接受修复决定,不适合作为根因未知时的第一反应。先保留现场并确认问题属于磁盘、文件系统、权限、网络还是服务层。

这次事故是否说明 AI 不应该做生产运维?

我的结论不是禁止 AI,而是把它当作真实运维操作者来设计权限:独立身份、最小权限、明确范围、可审计命令、操作前恢复点,以及无法满足边界时的 Stop Gate。

结语

这次事故最终比最坏情况幸运:数据库和 WordPress 数据没有损坏,系统内部文件也没有被递归改权。真正的问题集中在一条危险命令:

危险:不要执行下面这条命令。它会破坏 Linux 根目录下系统目录的权限。

chmod 0600 /*

一条命令足以让网络、SSH、MariaDB、Redis、Nginx 和 WordPress 几乎同时失效。

我最后得到的经验,不是“以后不用 AI 操作服务器”,而是:AI 可以参与运维,但它的权限必须像真实运维工程师一样经过设计、限制和审计。

便利和自动化本身没有问题。真正危险的是为了省事,把生产服务器的无限权限一次性交出去。

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注