Buzeli
buzeliSoluções Digitais
事故

`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 MB

648MB空闲对比之前的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循环的步骤。文件是正确的。容器不是。