做容器运维这几年,我见过太多“docker run一条命令拉起服务,半夜变成事故现场”的案例。尤其在压测和线上流量突增时,没设限额的容器就像没有限速的车,CPU、内存、磁盘IO随便吃,宿主机被拖垮后同机其他容器一起遭殃。这也是我写这篇东西的动机:把Docker资源限制作一次系统性的拆解,包括参数原理、实测方法、踩坑记录,以及容易被忽略的安全加固。适合刚开始给业务容器定规格的开发、准备梳理容器基线的运维,还有想搞清楚“为什么我明明限了内存还是被打挂”的同行们。
1. 默认参数跑容器,本质上是在裸奔
1.1 事故回放:一条docker run引发的宿主机雪崩
前阵子某业务团队的服务出过一次典型事故。他们用Docker部署了八个容器,全部跑在同一台宿主机上,业务增长后高峰期来了。其中一个容器里的应用存在内存泄漏,由于启动时只加了端口映射,没有设置内存限制,泄漏进程一路吃内存。等到宿主机物理内存耗尽,内核开始疯狂回收Page Cache,同一台机器上其他四个服务的响应时间集体飙升,部分请求直接超时,最后只能连夜分流流量、重启恢复。
这个事故不是个例。很多人默认“容器就是轻量虚拟机,跑不起来有平台兜底”,实际上Docker默认参数非常宽松:不设内存上限、不设CPU配额、不设IO带宽,并且默认以root运行、保留大量Linux Capabilities。换句话说,你在用一个自带钥匙、没有限速的租户,跟别人合租同一台物理机。
1.2 容器不是虚拟机,cgroups才是真正的边界
要让“限额”起作用,先得明白容器到底隔离了什么。Docker容器本质上是一组Linux进程,通过Namespace让这些进程看到独立的文件系统、进程表和网络栈;而CPU、内存、IO的用量,则通过cgroups来计量和限制。内核是宿主机共享的,不存在虚拟机那层硬件隔离。
打个比方:虚拟机是带门禁的独立房间,容器是开放大厅里用屏风隔开的工位。屏风能挡住视线,但挡不住噪音。一个容器把CPU跑满,别的容器就得排队;一个容器把内存吃光,内核只能从整个宿主机层面找内存回收,谁都不能独善其身。所以不管是性能还是安全,资源限制都不是“可选优化项”,而是“上线前提”。
1.3 资源限制就是安全基线
行业内聊Docker安全,第一反应往往是“不要用root”“裁剪Capabilities”,这些当然重要。但很多人会漏掉一个更底层的事实:资源限制本身就是安全机制。
假设一个容器被攻破,攻击者第一件事就是尽可能扩大影响。如果没有--memory、--cpus、--blkio这些限制,他可以在几分钟内把宿主机资源吃满,让整个物理节点瘫痪,甚至拖垮其他无关业务。所以,CPU限额、内存限制、IO限制在安全视角下相当于“爆炸半径控制”,它是纵深防御里最基础的一层。先有这层,再去谈用户隔离、内核调用过滤这些更精细的控制。
2. 内存限制的参数细节与OOM处置
2.1 内存三件套:--memory、--memory-swap、--memory-reservation
内存限制最基础的是--memory,也就是常说的-m,它规定容器最多能使用的物理内存量。--memory-swap这个参数非常容易理解错:它表示的是“内存+Swap”的总上限,不是单独指Swap上限。
举个例子:
docker run -d --name mem-demo \ --memory 512m \ --memory-swap 1g \ --memory-reservation 256m \ nginx:stable这里512m内存加上512m的Swap,合计上限才是1g。如果我把--memory-swap也配成512m,等于明确告诉内核“这个容器不可以使用Swap”。实际生产里,不同Docker版本对“不设置--memory-swap”的默认行为有差异,所以线上环境一定显式写清楚这两个参数,别赌默认值。
--memory-reservation是软限制,平时不怎么生效,只有当宿主机内存紧张时内核才会尽量把该容器的内存往这个值附近压缩。它适合作为“温和的提示”,不能替代硬限制。配置时不要把软限制设得比硬限制高,否则会直接报错。
2.2 容器里看到的free不可信
容器内执行free -h,看到的是宿主机的内存总量,不会告诉你这个容器自己能分到多少。想确认容器的真实内存配额和使用量,建议看两层:
docker stats --no-stream cat /sys/fs/cgroup/memory.max cat /sys/fs/cgroup/memory.currentmemory.max就是当前容器的内存硬限制,memory.current是当前实际使用量。如果宿主用的是cgroup v1,路径会变成/sys/fs/cgroup/memory/memory.limit_in_bytes和/sys/fs/cgroup/memory/memory.usage_in_bytes,字段含义相同。新一代Linux发行版基本默认cgroup v2,所以上面两个memory.*文件更常见。
这个差异直接关系到后面的故障排查。很多人在容器里free一看“内存充足”,实际上容器已经贴着限制在跑了,这就是误判的来源。
2.3 内存打爆后会发生什么:OOM Kill的细节
当容器内存超过限制,内核会先尝试回收内存,回收不掉就进入OOM流程。OOM Kill会挑选该容器内oom_score最高的进程干掉,注意被杀的未必是PID 1。所以经常出现这种情况:容器本身还显示“运行中”,但业务进程已经被内核杀掉了,服务悄悄断掉。
一个快速验证方法是直接压爆一个容器:
docker run --rm -m 256m --memory-swap 256m \ stress-ng --vm 1 --vm-bytes 300m然后去宿主机看内核日志:
dmesg | grep -i oom | tail -n 20能看到内核选了哪个进程下手,也能看到容器当时的内存申请情况。另一个值得关注的参数是--oom-score-adj,默认范围内可以调整这个容器在宿主机OOM时的优先级。比如数据库容器如果不想被宿主机OOM选中,可以适当调低评分;--oom-kill-disable则是在设置了-m的前提下,让容器内存耗尽时进入假死而非被杀,这通常用于无法接受进程被杀、宁可等待的场景。
2.4 JVM和缓存类容器的内存坑
内存限额配好之后,还有一个经典坑:应用自身的堆内存设置跟容器配额没对齐。
最典型的是Java服务。JVM默认最大堆是“宿主机物理内存的四分之一”。宿主机256GB,容器限制4GB,如果JVM启动参数里没有适配,它会认为自己最多可以分配64GB堆,结果一启动就往4GB的容器限制上撞,频繁OOM。现代JDK提供了-XX:MaxRAMPercentage=75.0这类参数,可以直接按容器配额百分比取堆内存,强烈建议容器里的Java应用都用这种方式,而不是写死-Xmx。Redis、MySQL这类缓存/数据库类容器同样要留余量,内存配额不要刚好等于业务峰值,给Page Cache和内核结构留出缓冲空间。我见过把Redis容器-m设得特别紧,命中率一上来就直接触发OOM的,非常可惜。
3. CPU限额的三套武器:cpus、cpuset、cpu-shares
3.1 --cpus是硬配额,计算逻辑并不玄
CPU限制里最常用的是--cpus,它表示这个容器最多能用多少个CPU核心。比如一台8核宿主机,想让某个容器最多用2.5核,直接写--cpus=2.5就行。
这个参数背后其实是CFS调度器的配额机制:内核把1个CPU核心的时间周期定义为--cpu-period,默认值通常100000微秒(100ms);--cpu-quota则是容器在一个周期内最多能消耗的CPU时间。--cpus=2.5会自动换算成quota=250000,也就是容器每100ms周期内最多使用250ms的CPU时间。
可以用docker inspect验证:
docker inspect cpu-demo --format '{{.HostConfig.NanoCpus}}'输出会是2500000000纳核数。只要容器实际的CPU消耗没有超过这个硬配额,内核不会额外干预;一旦超过,该容器就会被CFS节流,表现为用户态CPU占比不高但响应变慢。
3.2 --cpu-shares是权重,不是份额
很多文章把--cpu-shares解释成“给容器分配多少CPU”,这是错的。它是一个相对权重,默认值是1024。只有当多个容器同时争抢CPU时,它才决定大家按什么比例分配空闲CPU时间。
做个简单实验:起两个容器,A设置--cpu-shares=1024,B设置--cpu-shares=512,各自压满CPU。在宿主机上持续观察sar或top,大概率看到A的CPU时间约是B的两倍。但如果B是唯一在跑的容器,它即使权重只有512,也可以用满整台机器的CPU。所以它是软性的调度偏好,不是硬性的资源锁定。
理解这一点对容量规划很重要:--cpu-shares适合给同一个宿主上的多个业务容器划定“谁在突发时可以多吃点”,但如果要保证某容器绝对不超过某核数,必须靠--cpus这种硬配额。
3.3 --cpuset-cpus绑定核的场景和注意事项
--cpuset-cpus是把容器进程绑定到指定的物理CPU核心上,例如--cpuset-cpus=0-3表示只用0到3号核心。这种绑核方式能减少CPU核心切换和缓存失效,对CPU缓存敏感的负载有一定收益,比如高频交易、部分实时计算场景。
但要注意一个副作用:容器进程看到的可用CPU数量仍然是宿主机全部的核数,很多运行时(比如Go、Java、Python的某些线程池)会按照宿主机CPU数量去创建线程。如果一台64核机器把容器绑到4个核上,应用可能仍按64核去开线程,线程数严重虚高。这种情况典型表现为容器内线程数暴涨、锁竞争严重,但CPU占用被限制在4核以内。解决办法是显式配置应用的线程池大小,或结合环境变量覆盖CPU核心数。
3.4 延迟敏感容器要当心CFS节流
--cpus给了确定性,但也带来了“节流”副作用。当容器在一个100ms周期内跑满了配额,内核会强制让它在剩余时间里睡眠,下一周期再恢复调度。对普通Web服务影响不大,但对数据库、消息队列这类延迟敏感服务,节流会导致明显的尾部延迟尖刺。
我遇到过一个场景:某数据库容器设置了--cpus=4,平时负载不到30%,看起来毫无问题。但压测时短查询会周期性出现几百毫秒的延迟,查了很久才发现是CFS节流在起作用。处理思路是不要给这类服务卡得太死,最好只设置高于预估峰值20%左右的配额;如果延迟指标很敏感,可以考虑把--cpu-period调成更小的周期,让CPU时间更均匀地分布,但这项操作需要先在测试环境验证。CPU限额不是越低越好,它是一条“保险丝”,你得给它留足正常熔断以上的余量。
4. IO限制是最容易被遗漏的一环
4.1 先把块设备认清楚,再谈限制
相比CPU和内存,IO限制在日常配置里出现频率低得多,但恰恰最容易出事。Docker里IO限制相关参数主要针对块设备(比如/dev/sda),而不是某个文件目录。所以在配置之前,先执行lsblk确认宿主机上数据盘对应哪个设备名。
常用参数有这么几类:
| 参数 | 作用 | 示例 |
|---|---|---|
--device-read-bps | 限制设备每秒读字节数 | /dev/sda:10mb |
--device-write-bps | 限制设备每秒写字节数 | /dev/sda:10mb |
--device-read-iops | 限制设备每秒读次数 | /dev/sda:1000 |
--device-write-iops | 限制设备每秒写次数 | /dev/sda:1000 |
--blkio-weight | 容器间IO权重(10-1000) | --blkio-weight 500 |
我一般建议线上至少把写带宽限制加上,因为写突刺比读突刺更能拖垮整台宿主机。IOPS和BPS可以同时设置,哪个先触达,就以哪个为阈值执行限流。
4.2 为什么用dd测不出限速效果
给容器配置了--device-write-bps之后,很多人会直接进容器执行dd验证,结果发现写入速率完全没被限制,于是怀疑参数没生效。其实不是参数失效,而是dd默认走的是缓冲写入,数据先写进Page Cache,再由内核后台回写到磁盘,整个回写过程并不属于容器进程的直接IO,所以块设备限速对它“看不着”。
要验证限速是否生效,dd必须加上oflag=direct,绕过Page Cache直接写磁盘:
time dd if=/dev/zero of=/tmp/test.bin bs=1M count=2000 oflag=direct实测里,配置--device-write-bps /dev/sda:10mb后,上面命令的写入速率会近似稳定在10MB/s左右。但这引出一个更重要的问题:如果应用大量使用普通缓冲写,Docker的IO限额无法从源头拦截它进入Page Cache,只能等内核回写时形成突发压力。这也是我后面会专门讲的“限额失效”排查案例。
4.3 cgroup v1与v2下IO限制的差异
新一代Linux内核默认使用cgroup v2,Docker 20.10以后也支持在v2下运行,但部分老参数的行为发生了变化。最典型的就是--blkio-weight:在cgroup v2下它大概率会被忽略或直接提示不支持,需要改用cgroup v2的IO权重接口。Docker官方文档也明确过,v2下某些blkio相关参数的支持并不完整。
如果你的宿主是新系统,配置完IO限制后,一定要做一次实测确认参数真的生效。我见过有人在cgroup v2宿主机上设置了--blkio-weight,从表面看没有任何报错,但docker inspect里已经标志为不支持,容器之间IO调度完全没按权重走。IO比CPU和内存更容易出现“配置了但没生效”的假象,这是最需要警惕的。
4.4 日志容器和数据库容器的IO限额参考
不同负载的IO限额策略完全不同。日志采集类容器,比如抓取文件并上报的Agent,属于“写多但可容忍延迟”的场景,建议明确限制写带宽,比如--device-write-bps /dev/sda:10mb,防止它冲散业务流量。数据库容器则需要更高的IOPS、更低的延迟,一般不太建议用很低的BPS硬限,更推荐用--blkio-weight做相对权重,同时依赖宿主机磁盘的整体能力做兜底。
如果宿主机磁盘本身性能有限,我通常会先把总写入带宽估出来,再按业务重要性分配。比如日志容器写带宽限制到10MB/s,普通业务容器限到50MB/s,核心数据库容器限制到200MB/s或者不设硬限只设权重。限额不是越严越好,而是让突发伤害可控,同时又不影响正常流量。
5. 安全加固分层:从非root到Seccomp
5.1 第一层:非root运行
资源限制只是安全基线,接下来这几层才是真正决定“容器被攻破后能造成多大伤害”的关键。
第一层是让进程不要以root身份运行。Dockerfile里显式写USER 1000:1000,或者运行时加--user 1000:1000,都能让容器内进程以非特权用户身份运行。很多人觉得“容器内root又不是宿主机root,无所谓”,这不对。只要能拿到容器内root,攻击者至少可以对容器内文件系统、进程做很多破坏,甚至可能利用内核漏洞进一步扩大权限。
非root运行会带来一个常见麻烦:像Nginx这类服务默认要绑定80端口,权限不够时绑定失败。这时不要直接退回root,合理做法是保留一个能力:--cap-add NET_BIND_SERVICE,让非root进程可以绑定1024以下的端口。
5.2 裁剪Capabilities:最小权限原则
Linux内核把root权限拆成了很多细粒度能力,比如CAP_NET_BIND_SERVICE允许绑定低端口,CAP_NET_RAW允许原始套接字,CAP_SYS_ADMIN则涉及挂载、namespace管理等高风险操作。Docker容器默认会获得一批能力,范围比普通进程大得多。
加固时我通常直接砍到底,再按需加回去:
docker run -d --name secure-web \ --user 1000:1000 \ --cap-drop ALL \ --cap-add NET_BIND_SERVICE \ --security-opt no-new-privileges \ -p 8080:80 nginx:stable--cap-drop ALL之后,容器进程一开始没有任何特权能力,业务需要哪些再通过--cap-add加回来。常用的验证方法是进容器执行ping,由于CAP_NET_RAW被裁剪,ping基本会失败;如果你确实需要容器能ping对端,再单独加CAP_NET_RAW,不要一键开全量。
5.3 只读根文件系统与no-new-privileges
另一个容易被忽视的加固项是让根文件系统只读。容器本身已经在镜像层之上叠加了可写层,但如果业务不需要在运行时写文件,完全可以用--read-only把根文件系统设为只读,这样即使攻击者拿到了容器内权限,也无法轻易植入持久化的恶意文件。
运行时确实需要写临时文件的场景,配合--tmpfs使用:
docker run -d --name secure-web \ --user 1000:1000 \ --cap-drop ALL \ --cap-add NET_BIND_SERVICE \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ --security-opt no-new-privileges \ nginx:stable--tmpfs /tmp把临时目录放到内存文件系统上,写入速度快且重启即失,不给攻击者留落地机会。no-new-privileges也是一道很关键的锁:即使容器内存在setuid程序或攻击者想通过提权操作获取更高权限,内核也会拒绝授予新能力。这两项配合起来,能大幅缩小容器被攻破后继续提权的路径。
5.4 Seccomp、PidsLimit与加固清单
更深的防护是Seccomp,也就是系统调用过滤。Docker默认提供一个Seccomp配置文件,会放行大部分常用系统调用,同时拦截一批高风险的调用。如果你需要更苛刻的配置,可以用--security-opt seccomp=profile.json指定自定义规则。简单来说,它相当于给容器内的内核调用装了门禁,只放行业务需要的那几扇门。
另外别漏了--pids-limit,它限制容器内能创建的最大进程/线程数,防的是“被攻破后拿进程耗尽宿主资源”的下策。一个几十行的加固清单,最终可以收敛成这样:
| 加固项 | 配置方式 | 防护目的 |
|---|---|---|
| 非root运行 | --user 1000:1000 | 降低容器内提权收益 |
| 裁剪Capabilities | --cap-drop ALL --cap-add ... | 限制内核特权操作 |
| 只读根文件系统 | --read-only | 防止恶意文件落地 |
| 临时目录入内存 | --tmpfs /tmp:... | 运行时写入不落盘 |
| 禁止提权 | --security-opt no-new-privileges | 阻止setuid、文件能力提权 |
| 系统调用过滤 | --security-opt seccomp=... | 拦截高风险syscall |
| 进程数限制 | --pids-limit 100 | 防进程/fork耗尽 |
这些项不是选做,而是“能上就上”。尤其在生产环境,我见过太多团队只加了--restart always,其他安全参数一律默认,最后被挖矿脚本盯上时,一个容器就能把宿主IO和CPU全吃掉。
6. 一次限额“失效”的压测排查实录
6.1 现象:容器内存不高,宿主机却濒临OOM
某业务团队的项目代号叫order-svc,部署在容器里,已经配了--memory 4g、--cpus 2、--device-write-bps /dev/sda:20mb。压测时出了怪象:docker stats里order-svc容器内存稳定在300MB左右,CPU也没超过配额,看起来一切正常;但宿主机侧free -h显示Available内存急剧下降,几乎接近0,磁盘IOWait飙升,整台机器上的其他服务全部遭殃。
当时第一反应是“还有哪个容器在吃资源”,结果逐个排查后,其他容器都很干净。问题显然就出在order-svc身上,但为什么它显示的内存不高,宿主内存却在消失?
6.2 排查链路:从free到cgroup统计
排查过程是这样走的:
先看宿主机内存分布,发现大部分内存被Page Cache占掉,其中很大一部分是脏页,也就是等待回写的缓存数据。再看磁盘IO,发现IOWait持续高位,iostat -x 1里磁盘util接近100%。
接着看内核日志,没有OOM记录,但出现了大量内存回收和回写相关的调度痕迹。然后把目标锁定到order-svc的cgroup统计上,发现它的memory.current虽然不高,但io.stat里的写入量明显异常,超过了之前配置的20MB/s限速很多。进一步跟踪,发现回写动作并非由order-svc的进程直接执行,而是由宿主机内核的回写线程在刷Page Cache时批量完成。这批数据虽然源自order-svc的写入,但因为处于“脏页”状态,并不计入容器进程的块设备IO队列。
6.3 根因:缓冲写绕过IO限额引发踩踏
根因逐渐清晰了。order-svc这个服务有一个问题:它把大量业务日志持续写入容器内的/var/log/order-svc/目录,而且是普通的缓冲写。Docker的--device-write-bps对直接IO是有效的,但面对大量缓冲写却基本失灵——数据先进入内存Page Cache,内核稍后统一回写。回写过程由内核线程完成,不受容器cgroup的IO限制针对“进程直接发起IO”的统计方式约束。于是20MB/s的限速形同虚设,宿主机内存被不断产生的脏页吃光,磁盘又因为批量回写被压满。
这其实不是Docker参数写错了,而是理解错了它的工作边界:块设备限速不能作为“缓存写流量的闸门”,它只能作为“直接IO的闸门”。日志一类的高频缓冲写,必须从源头治理。
6.4 修复方案与兜底建议
修复分三层做。第一层是应用层,降低单容器日志写量,把日志输出改为异步或直接流转到统一采集管道,容器内不落盘;如果暂时无法改造代码,就用--tmpfs把日志目录切到内存文件系统,至少不让宿主磁盘承受这批量写。
第二层是正确的日志限制参数:
docker run -d --name order-svc \ --memory 4g \ --cpus 2 \ --log-driver json-file \ --log-opt max-size=10m \ --log-opt max-file=3 \ --device-write-bps /dev/sda:20mb \ ...--log-driver json-file配合max-size、max-file能防止容器日志无上限增长。第三层是宿主机层面,压测后我们把vm.dirty_background_ratio从默认的10调低到5,让内核更早地开始回写脏页,避免一瞬间积压大量Page Cache。这个参数影响整台宿主,非专用节点不建议动,但确实验证了能显著缓解突发IO尖峰。
重构之后,再跑同样的压测,宿主机Available内存不再暴跌,IOWait回落到正常区间。order-svc的限额虽然还是那些数字,但这回是真的在保护宿主了。限额是保险丝,不是流量阀——保险丝的意义在于关键时刻断开,阻止更大的灾难发生。
我个人在实际排查中最大的体会是:不要等出事了再查容器指标,而是要在上线前就问清楚几个问题——这个容器会写哪些目录、写多少、是缓冲写还是直接写、有没有非root运行、有没有裁剪权限。这几个问题回答完,资源限额和安全加固基本就能配得八九不离十了。Docker的资源限制看上去是几个参数,实际上是一套围绕“失控场景”设计的防御体系:先防CPU打满、内存打爆、IO踩踏,再防权限提升、文件落地、进程炸弹。把每一层都补上,才算真正敢把生产负载放进容器里跑。