`docker compose`与sed -i:为什么配置文件已更改但容器却忽略了它——以及inode如何导致OOM循环
发布于 2026年4月28日
服务器因OOM重启——但配置明明是正确的
一个在AWS上运行Docker栈的WordPress/Drupal发布平台陷入了OOM循环。服务器反复重启。通过SSH连接时,uptime显示'2 minutes'——刚刚从最近一次崩溃中恢复。站点返回502。
服务器是t4g.medium,3.4GB内存。内存检查显示:
free -h
# total used free shared buff/cache available
# Mem: 3.4Gi 3.0Gi 96Mi 12Mi 370Mi 96Mi
# Swap: 1.2Gi 1.2Gi 0Ki
docker stats --no-stream --format "table {{.Name}} {{.MemUsage}} {{.MemPerc}}"
# CONTAINER MEM USAGE / LIMIT MEM %
# cache 1.796GiB / 3.4GiB 52.38%
# php-fpm 406MiB / 3.4GiB 11.56%
# app-firewall 77MiB / 3.4GiB 2.20%
# security-agent 56MiB / 3.4GiB 1.60%
# nginx 52MiB / 3.4GiB 1.49%
# metrics-exporter 13MiB / 3.4GiB 0.38%Redis(compose中称为'cache')消耗1.796GiB——占总RAM的52%。加上PHP-FPM、nginx和其他容器,服务器始终超出容量。内核OOM killer终止最贪婪的进程,容器重启,循环再次开始。
上一次会话中应用的修复——没有生效
在上一次会话中,已通过sed -i应用了修复:
# 上次会话:将maxmemory从2048mb减少到1024mb
sudo sed -i 's/--maxmemory 2048mb/--maxmemory 1024mb/' /opt/myapp/docker-compose.yml
# 即时验证显示文件是正确的:
grep maxmemory /opt/myapp/docker-compose.yml
# command: valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru文件是正确的。1024mb那一行就在那里。修复看起来已经应用。但服务器继续因OOM重启。
当文件显示一个值而容器执行另一个值时,问题通常在于容器读取的是文件的哪个版本——而不是文件的内容。
诊断:两个inode,两个文件版本
通过docker inspect检查揭示了问题:
# 检查cache容器的实际配置
docker inspect cache --format "{{.Config.Cmd}}"
# [valkey-server --maxmemory 2048mb --maxmemory-policy allkeys-lru]
# ^^^^^^ 仍然是2048mb——忽略了文件!
# 检查主机上的文件
grep maxmemory /opt/myapp/docker-compose.yml
# command: valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru
# ^^^^^^ 1024mb——文件是正确的容器在使用--maxmemory 2048mb运行。文件中是1024mb。如果compose是bind-mounted的,它们怎么会不同?
sed -i与inode的行为
sed -i并不是原地编辑文件。它在内部实际做的是:
1. 读取原始文件(inode A)
2. 创建一个带有替换内容的临时文件(新inode B)
3. 将临时文件重命名为原始文件名
4. 如果有人持有文件描述符,inode A(原始)仍然存在
实际上,sed -i之后,文件名docker-compose.yml指向inode B(包含1024mb)。但Docker在创建容器时为inode A(包含2048mb)打开了文件描述符。容器不通过名称读取文件——它在创建时读取内容并保存这些配置。
# 演示sed -i与inode的行为
# 之前:
stat /opt/myapp/docker-compose.yml | grep 'Inode'
# Inode: 1234567
sudo sed -i 's/--maxmemory 2048mb/--maxmemory 1024mb/' /opt/myapp/docker-compose.yml
# 之后:
stat /opt/myapp/docker-compose.yml | grep 'Inode'
# Inode: 1234999 ← 新inode!sed -i创建了一个新文件
# 使用原始inode创建的容器看不到这个变化这适用于compose command:中作为参数传递的任何配置——Docker在容器创建时使用该值,不会在每次启动时重新读取文件。容器内的nginx -s reload通过bind mount读取nginx.conf(这就是为什么使用python3/tee原地编辑有效),但--maxmemory是Redis的启动参数,不是动态重读的配置。
为什么nginx -s reload不够用
在同一栈的另一个容器(运行nginx的WAF)中存在类似问题:通过sed -i添加到主机waf.conf文件的User-Agent配置在nginx -s reload后没有出现在容器内部。
nginx在收到SIGHUP(nginx -s reload)时通过bind mount重新读取配置文件。但只有当bind mount仍指向正确的inode时才有效。当sed -i为waf.conf创建新inode时,Docker的bind mount继续指向旧inode——nginx重新读取旧文件。
# 错误模式:sed -i + nginx -s reload(对bind mounts无效)
sudo sed -i 's/old-value/new-value/' /home/developer/webserver/waf.conf
docker exec app-firewall nginx -s reload # 读取旧inode——变化被忽略
# 正确模式选项1:python3保留inode(写入同一文件)
python3 -c "
with open('/home/developer/webserver/waf.conf', 'r') as f:
content = f.read()
content = content.replace('old-value', 'new-value')
with open('/home/developer/webserver/waf.conf', 'w') as f:
f.write(content)
"
docker exec app-firewall nginx -s reload # 现在有效——同一inode,新内容
# 正确模式选项2:重新创建容器
docker compose up -d --force-recreate app-firewall最终修复:重新创建容器
Redis的解决方案很简单——重新创建容器,使其读取更新后的docker-compose.yml:
cd /opt/myapp && sudo docker compose up -d cache
# 重新创建后验证:
docker inspect cache --format "{{.Config.Cmd}}"
# [valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru]
# ^^^^^^ 现在是1024mb了
docker exec cache valkey-cli config get maxmemory
# 1) "maxmemory"
# 2) "1073741824" ← 恰好1GB容器重新创建后,Redis开始使用1GB maxmemory。内存结果是立竿见影的:
# 重新创建cache容器前后对比
# 指标 之前 之后
# Redis maxmemory 2 GB 1 GB
# Redis实际使用 468 MB 458 MB
# 空闲RAM 370 MB 648 MB
# 可用 ~400 MB 985 MB
# Swap使用 1.2 GB 88 MB648MB空闲对比之前的370MB——这个差距意味着OOM崩溃和稳定服务器之间的距离。重启循环立即停止。
通用规则:sed -i + bind mount = 不持久的变更
这是一个适用于所有使用bind mount的Docker栈的原则:
任何在编辑时创建新文件的工具(sed -i、awk、对原文件的cp、mv)都会创建新inode,Docker的bind mount将继续指向原始inode。变更在主机上存在,但对容器不可见。
保留inode(原地编辑)的工具:
python3 open(path, 'w') — 截断并重写同一inode
tee — 写入现有文件描述符
perl -pi -e — 原地编辑保留inode(与某些系统上的sed -i不同)
vim / nano — 取决于配置,可能保留或不保留inode
确保变更到达容器的最安全、最明确的方式:
# 验证变更在主机文件中
grep '新值' /path/to/config.conf
# 验证容器看到了变更
docker exec <container> cat /path/to/config.conf | grep '新值'
# 如果输出不同:重新创建容器
docker compose up -d --force-recreate <service>完整背景:t4g.medium与2GB Redis
在3.4GB总内存的服务器上将--maxmemory设为2048mb,从一开始就是不可持续的。Redis使用2GB,其他所有容器(PHP-FPM、nginx、WAF、CrowdSec)加上OS只剩1.4GB。在正常流量下,系统处于OOM边缘;在爬虫流量下,越过了临界线。
在共享服务器上调整Redis大小的运维规则:当有多个容器时,Redis不应使用超过总RAM的30-40%。对于3.4GB的t4g.medium,健康上限约为1GB——这正是修复所设定的值。
这个事故有两个层面:直接原因(在3.4GB服务器上Redis maxmemory设为2GB)和隐藏原因(sed -i创建新inode,修复看似已应用但从未生效)。服务器重启是因为修复从未到达容器。不检查docker inspect的OOM诊断无法找到真正原因。
经验教训:更改容器配置后务必验证docker inspect
Docker容器配置变更的正确工作流程:
1. 编辑主机上的配置文件(使用python3/tee保留inode,或接受需要重新创建容器)
2. 验证主机文件有正确的值(grep/cat)
3. 验证容器看到正确的值(docker exec + cat,或docker inspect用于启动参数)
4. 如果值不一致:重新创建容器(docker compose up -d <service>)
第3步是大多数人跳过的——而这正是在这个案例中本可以防止OOM循环的步骤。文件是正确的。容器不是。
