Ubuntu服务器上跑容器,最容易出问题的往往不是镜像拉不下来或者服务起不来,而是资源没做限制。一个容器里的进程突然占满CPU,或者内存持续上涨触发OOM,最后把整台宿主机拖垮,这种情况在运维现场非常常见。默认情况下Docker容器对CPU和内存的使用几乎没有硬性边界,它能看到宿主机的全部资源,也能尝试去消耗全部资源。所以部署完容器之后的第一件事,就应该是把资源限制配置好,而不是等到业务高峰出问题再补救。

先搞清楚cgroup和Docker资源限制的关系

Ubuntu服务器上的Docker资源限制,底层依赖的是Linux内核的cgroup机制。cgroup v1和v2在Ubuntu不同版本上默认启用情况不一样。Ubuntu 20.04默认还是cgroup v1为主,到了Ubuntu 22.04及之后,很多镜像和Docker版本已经切到cgroup v2。你可以通过检查/sys/fs/cgroup目录结构来判断当前系统用的是哪个版本。如果看到/sys/fs/cgroup/cpu、/sys/fs/cgroup/memory这样分开的目录,就是v1;如果只有一个/sys/fs/cgroup/cgroup.controllers文件,那就是v2。这个判断很重要,因为有些资源限制参数在v1和v2下行为不一样,比如内存的swap限制。Docker本身会自动适配cgroup版本,但运维人员需要知道底层是哪个版本,这样排查问题的时候才不会误判。

内存限制要优先配置,避免OOM拖垮宿主机

内存是最容易把服务器搞挂的资源。容器内存限制的核心参数是-m或者--memory。比如限制一个容器最多使用512MB内存,可以这样启动:

docker run -d --name web-app --memory=512m nginx:latest

这个限制的是容器能使用的物理内存上限。但光限制物理内存还不够,还要考虑swap。默认情况下Docker不会给容器分配swap,但如果宿主机本身有swap分区,容器在内存压力大的时候可能会触发宿主机级别的swap使用,导致整体性能下降。更稳妥的做法是同时限制内存和swap总量。例如:

docker run -d --name web-app --memory=512m --memory-swap=1g nginx:latest

这里--memory-swap=1g表示物理内存512MB加上512MB的swap,总共1GB。如果希望容器完全不用swap,就把--memory-swap的值设置成和--memory一样大,比如都设置成512m。这样容器一旦内存超限,就会直接触发OOM,而不是去用swap拖慢整个系统。对于生产环境的Java应用或者Node.js应用,建议把--memory和--memory-swap设置成相同值,让OOM尽早暴露问题,而不是让服务在swap里半死不活。

CPU限制要区分相对权重和绝对配额

CPU限制比内存复杂一点,因为Docker提供了两种机制。一种是相对权重,用-c或者--cpu-shares参数,默认值是1024。这个参数只有在宿主机CPU资源发生争抢的时候才会起作用,它决定的是容器之间按什么比例分配CPU时间。比如两个容器,一个--cpu-shares=512,另一个--cpu-shares=1024,那么当CPU满载时,后者能拿到的CPU时间是前者的两倍。但如果宿主机CPU很空闲,这个参数不会限制任何容器,它们都能跑满。

另一种是绝对配额,用--cpus参数,这是更直观也更常用的方式。比如限制容器最多使用1.5个CPU核心:

docker run -d --name api-server --cpus=1.5 your-image:tag

这个参数在cgroup v1下会被转换成cpu.cfs_quota_us和cpu.cfs_period_us,在cgroup v2下也有对应的实现。--cpus=1.5意味着容器最多只能使用1.5个核心的计算能力,即使宿主机有32个核心空闲,这个容器也突破不了这个上限。对于需要严格隔离的多租户环境,建议直接用--cpus做硬限制。另外还可以配合--cpuset-cpus把容器绑定到指定的CPU核心上,比如--cpuset-cpus=0-3表示只允许容器使用0到3号核心。绑定核心的好处是可以减少CPU缓存失效,对延迟敏感的应用有帮助,但要注意不要多个容器绑定到同一组核心导致争抢。

磁盘IO限制经常被忽略,但同样重要

很多运维人员配置容器资源限制时只关注CPU和内存,完全忽略了磁盘IO。如果一个容器在做大量的日志写入或者数据库落盘操作,它可能把磁盘IO带宽占满,导致其他容器读写变慢。Docker提供了--device-read-bps、--device-write-bps、--device-read-iops、--device-write-iops这些参数来限制磁盘IO。例如限制容器对/dev/sda的写入速度不超过50MB/s:

docker run -d --name log-service --device-write-bps=/dev/sda:50mb your-image:tag

限制IOPS的写法类似:

docker run -d --name db-service --device-write-iops=/dev/sda:1000 your-image:tag

需要注意的是,这些参数需要指定具体的块设备路径,而且不同存储驱动下效果可能有差异。对于使用overlay2存储驱动的容器,限制的是容器读写层所在的块设备。如果宿主机用的是云盘或者SSD,要先确认设备名和实际性能上限,再设置合理的阈值。磁盘IO限制在数据库容器、日志收集容器、备份任务容器上尤其重要,这些容器很容易因为大量顺序写或者随机写影响整体磁盘性能。

用Docker Compose统一管理资源限制

生产环境很少用docker run命令直接启动单个容器,更多是用Docker Compose来编排多个服务。Compose文件里配置资源限制的语法和docker run参数基本对应,但要注意版本差异。Compose v3版本之后,资源限制配置放在deploy.resources下面,但deploy字段原本是给Swarm模式用的,在docker-compose up启动时部分参数会被忽略。更通用的做法是使用Compose v2.4+的语法,直接在service下面用mem_limit、cpus、cpu_shares这些字段。例如:

version: "2.4"
services:
  web:
    image: nginx:latest
    mem_limit: 512m
    memswap_limit: 1g
    cpus: 1.5
    cpu_shares: 1024
  db:
    image: mysql:8.0
    mem_limit: 2g
    memswap_limit: 2g
    cpus: 2

如果你的Compose文件版本是3.x,并且用docker-compose up启动,建议改用version: "2.4"来确保这些限制字段生效。或者使用docker compose v2命令,它对deploy.resources.limits的支持比旧版docker-compose更好。实际运维中,建议把资源限制写进Compose文件并纳入版本管理,这样每次重新部署都不会丢失限制配置,也方便团队其他人review。

运行时动态调整资源限制

容器跑起来之后,有时候需要动态调整资源限制,比如业务流量突然上涨需要临时放宽CPU限制,或者发现内存限制太紧需要调大。Docker提供了docker update命令来修改运行中容器的资源限制。例如把容器web-app的内存限制从512MB调整到1GB:

docker update --memory=1g --memory-swap=2g web-app

调整CPU限制:

docker update --cpus=2 web-app

docker update可以修改内存、CPU、重启策略等,但不是所有参数都能动态改。比如--cpuset-cpus在cgroup v1下可以动态修改,但有些存储相关的限制就不能改。另外要注意,动态调大内存限制一般没问题,但调小内存限制时要谨慎,如果容器当前内存使用量已经超过新的限制值,可能会触发OOM。所以调小内存之前最好先观察一下容器当前的实际内存占用。

监控和验证资源限制是否真正生效

配置完资源限制之后,一定要验证是否真正生效。最简单的验证方式是进入容器内部查看cgroup的配置。在Ubuntu宿主机上,可以用docker inspect查看容器的资源限制配置:

docker inspect --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' web-app

这个命令会输出容器的内存限制和CPU配额。NanoCpus是纳秒单位的CPU配额,1个CPU核心等于1000000000纳秒,所以如果输出1500000000就表示限制为1.5个核心。更直接的验证方式是在容器内跑压力测试工具,比如用stress命令:

docker run -d --name stress-test --memory=256m --cpus=0.5 polinux/stress stress --cpu 4 --vm 2 --vm-bytes 200M

然后在宿主机上用docker stats观察容器的CPU和内存使用情况。如果限制生效,CPU使用率不会超过50%(0.5个核心),内存使用量会在256MB附近波动而不是持续上涨。docker stats是运维人员最常用的监控命令,但它只能看实时数据,如果要长期监控,建议配合Prometheus的cAdvisor或者node-exporter来采集容器级别的资源指标。

常见问题排查思路

资源限制配置之后,可能会遇到一些奇怪的问题。比如容器启动失败,报错信息里提到cgroup相关的错误。这种情况大概率是cgroup版本和Docker版本不匹配,或者系统缺少某些cgroup子系统。在Ubuntu上可以通过安装cgroup-tools和cgroupfs-mount来解决部分问题,但更彻底的方案是升级Docker到最新稳定版,并确保内核版本支持当前的cgroup模式。

另一个常见问题是容器内看到的CPU核心数还是宿主机的全部核心。这是因为--cpus限制的是CPU使用时间配额,并不会改变容器内/proc/cpuinfo显示的核心数量。如果应用根据CPU核心数来设置线程池大小,可能会创建过多线程。对于这种情况,可以用--cpuset-cpus来限制可见的CPU核心,或者通过环境变量告诉应用实际可用的CPU数量。比如Java应用可以用-XX:ActiveProcessorCount参数来指定。

内存限制方面,如果容器内的应用频繁被OOM kill,先确认是宿主机的OOM killer在杀进程,还是容器自己的cgroup OOM。可以通过dmesg命令查看内核日志,如果看到oom-kill相关的记录,并且被杀进程的cgroup路径指向某个容器,就说明是容器内存超限。这时候需要分析应用的内存使用模式,是内存泄漏还是正常业务增长,然后决定是调大限制还是修复应用。

结合systemd和cgroup做更细粒度的控制

对于有更高要求的场景,可以在宿主机层面用systemd来管理cgroup,然后把容器放到特定的cgroup层级下。Ubuntu的systemd支持创建自定义slice,比如创建一个专门给容器用的slice,限制这个slice的总资源使用量,这样即使某个容器突破了Docker层面的限制,也不会超过slice的总配额。具体做法是在/etc/systemd/system下创建自定义slice单元文件,然后在Docker daemon配置里通过cgroup-parent参数指定父cgroup。这种方案适合多租户环境或者需要跨容器做资源隔离的场景,但配置复杂度较高,需要运维人员对systemd和cgroup有比较深入的理解。

资源限制配置不是一次性的工作,而是需要持续调整的过程。业务负载会变化,应用版本会迭代,资源限制也要跟着调整。建议把资源限制配置纳入容器部署的标准流程,每次上线前都检查一遍CPU、内存、磁盘IO的限制是否合理,并且在监控系统里设置相应的告警阈值。只有把资源限制做成常态化的工作,才能避免容器化环境里因为资源争抢导致的稳定性问题。