为了帮助读者深入了解Kubernetes在各种应用场景下所面临的挑战和解决方案,以及如何进行性能优化。我们推出了<<Kubernetes经典案例30篇>>,该系列涵盖了不同的使用场景,从runc到containerd,从K8s到Istio等微服务架构,全面展示了Kubernetes在实际应用中的最佳实践。通过这些案例,读者可以掌握如何应对复杂的技术难题,并提升Kubernetes集群的性能和稳定性。
- Containerd CVE-2020–15257细节说明
- OpenAI关于Kubernetes集群近万节点的生产实践
- 一条K8s命令行引发的血案
- 揭开K8s适配CgroupV2内存虚高的迷局
- 探索Kubernetes 1.28调度器OOM的根源
- 解读Kubernetes常见错误码
- RLIMIT_NOFILE设置陷阱:容器应用高频异常的隐形元凶
- 容器干扰检测与治理(上篇)
问题描述
针对该问题的信息做了部分加工处理,thanks polarathene
我们在Fedora系统上将containerd.io从1.4.13版本升级到了1.5.10之后,发现多个项目中所有MySQL 容器实例消耗内存暴涨超过20GB,而在此之前它们仅消耗不到300MB。同事直接上了重启大招,但重启后问题依旧存在。最后选择回滚到1.4.13版本,该现象也随之消失。
值得注意的是,在Ubuntu 18.04.6系统上运行相同版本的containerd和runc时,MySQL 容器实例一切工作正常。只有在Fedora 35系统(配置相同的containerd与runc版本),出现了内存消耗异常的情况。下面是出现异常的容器组件版本信息:
1 | go1.16.15 |
在Fedora 35上,执行以下命令执行会引发系统崩溃:
1 | docker run -it --rm mysql:5.7.36 |
但是mysql 8.0.29版本在Fedora 35上却运行正常:
1 | docker run -it --rm mysql:8.0.29 |
OOM相关信息:
1 | 2023-06-06T17:23:24.094275-04:00 laptop kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=user.slice,mems_allowed=0,global_oom,task_memcg=/system.slice/docker-xxx.scope,task=mysqld,pid=38421,uid=0 |
原先在空闲状态下,mysql
容器使用内存大约在200MB左右;但在某些操作系统上,如RedHat、Arch Linux或Fedora,一旦为容器设置了非常高的打开文件数(nofile
)限制,则可能会导致mysql
容器异常地占用大量内存。
cat /proc/$(pgrep dockerd)/limits | grep "Max open files"
cat /proc/$(pgrep containerd)/limits | grep "Max open files"
如果输出值为1073741816或更高,那么您可能也会遇到类似异常。
在相关社区,我们发现了类似的案例:
xinetd slowly
xinetd服务启动极其缓慢,我们查看了dockerd的系统设置如下:
1 | $ cat /proc/$(pidof dockerd)/limits | grep "Max open files" |
1 | $ systemctl show docker | grep LimitNOFILE |
但是,在容器内部,则是一个非常巨大的数字——1073741816
1 | $ docker run --rm ubuntu bash -c "cat /proc/self/limits" | grep "Max open files" |
xinetd
程序在初始化时使用setrlimit(2)
设置文件描述符的数量,这会消耗大量的时间及CPU资源去关闭1073741816个文件描述符。
1 | root@1b3165886528# strace xinetd |
yum hang
从docker社区获取Rocky Linux 9对应的Docker版本,在容器中执行yum操作时速度非常缓慢。
在CentOS 7和Rocky Linux 9宿主机上,我们都进行了以下操作:
1 | docker run -itd --name centos7 quay.io/centos/centos:centos7 |
在CentOS 7宿主机上,耗时在2分钟左右; 而在Rocky Linux 9上,一个小时也未能完成。
复现步骤如下:
1 | docker run -itd --name centos7 quay.io/centos/centos:centos7 |
rpm slow
在宿主机上执行下述命令:
1 | time zypper --reposd-dir /workspace/zypper/reposd --cache-dir /workspace/zypper/cache --solv-cache-dir /workspace/zypper/solv --pkg-cache-dir /workspace/zypper/pkg --non-interactive --root /workspace/root install rpm subversion |
消耗的各类时间如下:
1 | real 0m11.248s |
在容器中执行测试
1 | docker run --rm --net=none --log-driver=none -v "/workspace:/workspace" -v "/disks:/disks" opensuse bash -c "time zypper --reposd-dir /workspace/zypper/reposd --cache-dir /workspace/zypper/cache --solv-cache-dir /workspace/zypper/solv --pkg-cache-dir /workspace/zypper/pkg --non-interactive --root /workspace/root install rpm subversion" |
消耗的各类时间激增:
1 | real 0m31.089s |
我们找到了RPM的触发问题的根因,其属于RPM内部POSIX lua库 rpm-software-management/rpm@7a7c31f
。
1 | static int Pexec(lua_State *L) /** exec(path,[args]) */ |
类似的,如果设置的最大打开文件数限制过高,那么luaext/Pexec()
和lib/doScriptExec()
在尝试为所有这些文件描述符设置FD_CLOEXEC
标志时,会花费过多的时间,从而导致执行如rpm
或dnf
等命令的时间显著增加。
PtyProcess.spawn slowdown in close() loop
ptyprocess存在问题的相关代码:
1 | # Do not allow child to inherit open file descriptors from parent, |
当处理文件描述符时,为了提高效率,应避免遍历所有可能的文件描述符来关闭它们,尤其是在Linux系统上,因为这会通过close()
系统调用消耗大量时间。尤其是当打开文件描述符的限制(可以通过ulimit -n
、RLIMIT_NOFILE
或SC_OPEN_MAX
查看)被设置得非常高时,这种遍历方式将导致数百万次不必要的系统调用,显著增加了处理时间。
一个更为高效的解决方案是仅关闭那些实际上已打开的文件描述符。在Python 3中,subprocess
模块已经实现了这一功能,而对于使用Python 2的用户,subprocess32
的兼容库可以作为回退选项。通过利用这些库或类似的技术,我们可以显著减少不必要的系统调用,从而提高程序的运行效率。
技术背景
1. RLIMIT_NOFILE
https://github.com/systemd/systemd/blob/1742aae2aa8cd33897250d6fcfbe10928e43eb2f/NEWS#L60..L94
当前Linux内核对于用户空间进程的RLIMIT_NOFILE资源限制默认设置为1024(软限制)和4096(硬限制)。以前,systemd在派生进程时会直接传递这些未修改的限制。在systemd240版本中,systemd传递的硬限制增加到了512K,其覆盖了内核的默认值,并大大增加了非特权用户空间进程可以同时分配的文件描述符数量。
注意,为了兼容性考虑,软限制仍保持在1024,传统的UNIX select()调用无法处理大于或等于1024的文件描述符(FD_SET宏不管是否越界以及越界的后果,fd_set也并非严格限制在1024,FD_SET超过1024的值,会造成越界),因此如果全局提升了软限制,那么在使用select()时可能出现异常(在现代编程中,程序不应该再使用select(),而应该选择poll()/epoll,但遗憾的是这个调用仍然大规模存在)。
在较新的内核中,分配大量文件描述符在内存和性能上比以前消耗少得多。Systemd社区中有用户称在实际应用中他们使用了约30万个文件描述符,因此Systemd认为512K作为新的默认值是足够高的。但是需要注意的是,也有报告称使用非常高的硬限制(例如1G)是有问题的,因此,超高硬限制会触发部分应用程序中过大的内存分配。
2. File Descriptor Limits
最初,文件描述符(fd)主要用于引用打开的文件和目录等资源。如今,它们被用来引用Linux用户空间中几乎所有类型的运行时资源,包括打开的设备、内存(memfd_create(2)
)、定时器(timefd_create(2)
)甚至进程(通过新的pidfd_open(2)
系统调用)。文件描述符的广泛应用使得“万物皆文件描述符”成为UNIX的座右铭。
由于文件描述符的普及,现代软件往往需要同时处理更多的文件描述符。与Linux上的大多数运行时资源一样,文件描述符也有其限制:一旦达到通过RLIMIT_NOFILE
配置的限制,任何进一步的分配尝试都会被拒绝,并返回EMFILE
错误,除非关闭一些已经打开的文件描述符。
以前文件描述符的限制普遍较低。当Linux内核首次调用用户空间时,RLIMIT_NOFILE
的默认值设置为软限制1024和硬限制4096。软限制是实际生效的限制,可以通过程序自身调整到硬限制,但超过硬限制则需要更高权限。1024个文件描述符的限制使得文件描述符成为一种稀缺资源,导致开发者在使用时非常谨慎。这也引发了一些次要描述符的使用,例如inotify观察描述符,以及代码中频繁的文件描述符关闭操作(例如ftw()
/nftw()
),以避免达到限制。
一些操作系统级别的API在设计时只考虑了较低的文件描述符限制,例如BSD/POSIX的select(2)
系统调用,它只能处理数字范围在0到1023内的文件描述符。如果文件描述符超出这个范围,select()
将越界出现异常。
Linux中的文件描述符以整数形式暴露,并且通常分配为最低未使用的整数,随着文件描述符用于引用各种资源(例如eBPF程序、cgroup等),确实需要提高这个限制。
在2019年的systemd v240版本中,采取了一些措施:
- 在启动时,自动将两个系统控制参数
fs.nr_open
和fs.file-max
设置为最大值,使其实际上无效,从而简化了配置。 - 将
RLIMIT_NOFILE
的硬限制大幅提高到512K。 - 保持
RLIMIT_NOFILE
的软限制为1024,以避免破坏使用select()
的程序。但每个程序可以自行将软限制提高到硬限制,无需特权。
通过这种方法,文件描述符变得不再稀缺,配置也更简便。程序可以在启动时自行提高软限制,但要确保避免使用select()
。
具体建议如下:
- **不要再使用
select()
**。使用poll()
、epoll
、io_uring
等更现代的API。 - 如果程序需要大量文件描述符,在启动时将
RLIMIT_NOFILE
的软限制提高到硬限制,但确保避免使用select()
。 - 如果程序会fork出其他程序,在fork之前将
RLIMIT_NOFILE
的软限制重置为1024,因为子进程可能无法处理高于1024的文件描述符。
这些建议能帮助你在处理大量文件描述符时避免常见问题。
select
supervisord
- 在2011年,
supervisord
报告了一个与select()
相关的问题,并在2014年得到修复。这表明supervisord
早期版本可能使用了select()
,但后续版本已更新。
Nginx
- Nginx允许用户通过配置提高文件描述符的软限制。2015年的bug报告指出了Nginx在某些情况下使用
select()
并受限于1024个文件描述符的问题。目前,提供了多种方法来处理高并发场景。
Redis
- Redis文档建议使用高达2^16的文件描述符数量,具体取决于实际工作负载。
- 2013年12月,
redis-py
的select()
问题,在2014年6月修复。 - 2015年
redis/hiredis
的问题,用户依赖select()
。 - 2020年11月的文章提到Redis仍将
select()
作为后备方案,参考了ae_select.c
文件。
- 2013年12月,
Apache HTTP Server
- 2002年的commit显示了Apache HTTP Server早期使用
select()
。尽管Apache后续增加了对其他I/O多路复用机制的支持,但在处理较低并发连接时,仍可能使用select()
。
PostgreSQL
- PostgreSQL没有硬限制,以避免对其他运行的软件产生负面影响。在容器化环境中,这个问题不太严重,因为可以为容器设置适当的限制。PostgreSQL提供了一个配置选项
max_files_per_process
,限制每个进程可以打开的最大文件数。 - PostgreSQL的源代码中仍然有使用
select()
的地方。
MongoDB
- 2014年,MongoDB仍在使用
select()
。在3.7.5版本中,select()
仍在listen.cpp
中使用,但在3.7.6版本(2018年4月)中被移除。不过,MongoDB的源代码中仍然存在select()
的调用。
寻根溯源
虽然 cgroup 控制器在现代资源管理中起着重要作用,但 ulimit
作为一种传统的资源管理机制,依然不可或缺。
在容器中,默认的 ulimit
设置是从 containerd
继承的(而非 dockerd
),这些设置在 containerd.service
的 systemd 单元文件中被配置为无限制:
1 | $ grep ^Limit /lib/systemd/system/containerd.service |
虽然这些设置满足 containerd
自身的需求,但对于其运行的容器来说,这样的配置显得过于宽松。相比之下,主机系统上的用户(包括 root 用户)的 ulimit
设置则相当保守(以下是来自 Ubuntu 18.04 的示例):
1 | sh复制代码$ ulimit -a |
这种宽松的容器设置可能会引发一系列问题,例如容器滥用系统资源,甚至导致 DoS 攻击。尽管 cgroup 限制通常用于防止这些问题,但将 ulimit
设置为更合理的值也是必要的。
特别是 RLIMIT_NOFILE
(打开文件的数量限制)被设置为 2^30(即 1073741816),这会导致一些程序运行缓慢,因为这些程序会遍历所有可能打开的文件描述符,并在每次 fork/exec 之前关闭这些文件描述符(或设置 CLOEXEC 位)。以下是一些具体情况:
- rpm:在安装 RPM 以创建新的 Docker 镜像时性能缓慢 #23137 和 Red Hat Bugzilla #1537564 中有报告,修复方案为:优化并统一在文件描述符上设置 CLOEXEC 的 rpm-software-management/rpm#444(在 Fedora 28 中修复)。
- python2:在 Docker 18.09 上 PTY 进程的创建速度大大降低 #502 中有报告,建议的修复方案为:subprocess.Popen: 在 Linux 上优化 close_fds python/cpython#11584(由于 python2 已经冻结,所以此修复方案不会被采用)。
- python 的 pexpect/ptyprocess 库:在 PtyProcess.spawn(以及因此 pexpect)在 close() 循环中速度降低 #50 中有报告。
逐一解决这些问题既复杂且收益低,其中一些软件已经过时,另外有一些软件难以修复。上述列表并不全面,可能还有更多类似的问题尚未觉察到。
探究资源消耗
2^16
(65k)个busybox
容器的预估资源使用情况如下所示:
- 在
containerd
中,共需 688k 个任务和 206 GB(192 GiB)的内存(每个容器约需 10.5 个任务和 3 MiB 的内存)。 - 至少需要将
containerd.service
的LimitNOFILE
设置为 262144。 - 打开的文件数达到 249 万(其中
fs.file-nr
必须低于fs.file-max
限制),每个容器大约需要 38 个文件描述符。 - 容器的 cgroup 需要 25 GiB 的内存(每个容器大约需要 400 KiB)。
因此LimitNOFILE=524288
(自 v240 版本以来,systemd 的默认值)对于大多数系统作为默认值已经足够,其能满足 docker.service
和 containerd.service
支持 65k 个容器的资源需求。
从GO 1.19开始将隐式地将 fork
/ exec
进程的软限制恢复到默认值。在此之前,Docker 守护进程可以通过配置 default-ulimit
设置来强制容器使用 1024
的软限制。
- 测试详情
1 | Fedora 37 VM 6.1.9 kernel x86_64 (16 GB memory) |
在Fedora 37 VM上大约有 1800 个文件描述符被打开(sysctl fs.file-nr
)。通过 shell 循环运行 busybox
容器直到失败,并调整 docker.service
和 containerd.service
的 LimitNOFILE
来收集测试数据:
docker.service
-6:1
的比例(使用--network=host
时是5:1
),在LimitNOFILE=5120
下大约能运行 853 个容器(使用主机网络时为 1024)。containerd.service
-4:1
的比例(未验证--network=host
是否会降低了比例),LimitNOFILE=1024
能支持 256 个容器,前提是docker.service
的LimitNOFILE
也足够高(如LimitNOFILE=2048
)。
每个容器的资源使用模式:
- 每个容器的 systemd
.scope
有 1 个任务和大约 400 KiB 的内存(alpine
和debian
稍少)。 - 每个容器增加了 10.5 个任务和 3 MiB 的内存。
- 每个正在运行的容器大约打开了 38 个文件。
在 docker.service
中设置 LimitNOFILE=768
,然后执行 systemctl daemon-reload && systemctl restart docker
。通过 cat /proc/$(pidof dockerd)/limits
确认该限制是否已应用。
运行以下命令列出:
- 正在运行的容器数量。
- 打开的文件数量。
containerd
和dockerd
守护进程分别使用的任务和内存数量。
1 | # Useful to run before the loop to compare against output after the loop is done |
运行以下循环时,最后几个容器将失败,大约创建 123 个容器:
1 | # When `docker.service` limit is the bottleneck, you may need to `CTRL + C` to exit the loop |
可以添加额外的选项:
--network host
:避免每次docker run
时向默认的 Docker 桥接器创建新的 veth 接口(参见ip link
)。--ulimit "nofile=1023456789"
:不会影响内存使用,但在基于 Debian 的发行版中,值高于fs.nr_open
(1048576)将失败,请使用该值或更低的值。--cgroup-parent=LimitTests.slice
:类似docker stats
但与其他容器隔离,systemd-cgtop
报告内存使用时包括磁盘缓存(可使用sync && sysctl vm.drop_caches=3
清除)。
为更好了解所有创建容器的资源使用情况,创建一个用于测试的临时 slice:
1 | mkdir /sys/fs/cgroup/LimitTests.slice |
显示整个 slice 和每个容器的内存使用情况,一个 busybox
容器大约使用 400 KiB 的内存。
- 限制对子进程的影响
我原以为子进程会继承父进程的文件描述符(FD)限制。然而实际却是,每个进程继承限制但有独立的计数。
- 可以通过以下命令观察
dockerd
和containerd
进程打开的文件描述符数量:ls -1 /proc/$(pidof dockerd)/fd | wc -l
。 - 这不适用于负责容器的
containerd-shim
进程,所以ls -1 /proc/$(pgrep --newest --exact containerd-shim)/fd | wc -l
不会有用。
为了验证这一点,可以运行以下测试容器:docker run --rm -it --ulimit "nofile=1024:1048576" alpine bash
。然后尝试以下操作:
1 | # 创建文件夹并添加许多文件: |
- 结果观察
- 每个进程将这些文件描述符添加到
fs.file-nr
返回的打开文件计数中,并在该进程关闭时释放它们。 - 重新运行同一进程的循环不会变化,因为文件已经被计算为该进程打开的。
- 这涉及到内存成本:
- 每个通过
touch
创建的文件大约占用2048
字节(仅在打开前占用磁盘缓存)。
- 每个通过
- 每个打开的文件(每个文件描述符引用都会使
fs.file-nr
增加)大约需要512
字节的内存。- 以这种方式创建 512k 个文件大约会占用 1.1 GiB 的内存(当至少有一个文件描述符打开时,使用
sysctl vm.drop_caches=3
也不会释放),每个进程打开等量的文件描述符还会额外使用 250 MiB(262 MB)。
- 以这种方式创建 512k 个文件大约会占用 1.1 GiB 的内存(当至少有一个文件描述符打开时,使用
- 错误处理
这些问题主要与系统服务的文件描述符限制有关,不同服务的限制耗尽会导致不同错误。
有时这会导致任何docker
命令(如docker ps
)挂起(守护进程耗尽限制)。常见现象包括:
- 容器未运行(*
pgrep containerd-shim
没有输出,但docker ps
列出的容器超出预期的退出时间*)。 - 容器在
containerd-shim
进程中占用内存,即使执行了systemctl stop docker containerd
。有时需要pkill containerd-shim
来清理,并且systemctl start docker containerd
会在journalctl
中记录错误,处理已死的shims的清理(根据容器数量,这可能会超时,需要再次启动containerd
服务)。 - 即使排除了所有这些因素,仍然有额外的几百MB内存使用。由于它似乎不属于任何进程,推测是内核内存。我尝试运行的最大容器数量大约是1600个左右。
docker.service
超出限制
每次docker run
时,系统会输出不同的错误:
1 | ERRO[0000] Error waiting for container: container caff476371b6897ef35a95e26429f100d0d929120ff1abecc8a16aa674d692bf: driver "overlay2" failed to remove root filesystem: open /var/lib/docker/overlay2/35f26ec862bb91d7c3214f76f8660938145bbb36eda114f67e711aad2be89578-init/diff/etc: too many open files |
1 | docker: Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error running hook #0: error running hook: exit status 1, stdout: , stderr: time="2023-03-12T02:26:20Z" level=fatal msg="failed to create a netlink handle: could not get current namespace while creating netlink socket: too many open files": unknown. |
1 | docker: Error response from daemon: failed to initialize logging driver: open /var/lib/docker/containers/b014a19f7eb89bb909dee158d21f35f001cfeb80c01e0078d6f20aac8151573f/b014a19f7eb89bb909dee158d21f35f001cfeb80c01e0078d6f20aac8151573f-json.log: too many open files. |
containerd.service
限制超出
我也观察到一些类似的错误:
1 | docker: Error response from daemon: failed to start shim: start failed: : pipe2: too many open files: unknown. |
总结
- 2023年8月:在
docker.service
中移除了LimitNOFILE=infinity
。 - 2021年5月:
LimitNOFILE=infinity
和LimitNPROC=infinity
重新添加回docker.service
,以与Docker CE的配置同步。- 这个PR是一个合并提交,源自2018年9月的提交。
- 2016年7月:
LimitNOFILE=infinity
更改为LimitNOFILE=1048576
。- 讨论引用了2009年StackOverflow上的回答,关于特定发行版/内核中
infinity
被限制为2^20
。今天的一些系统上,这个上限可以是1024倍更高(*2^30 == 1073741816
,超过10亿*)。
- 讨论引用了2009年StackOverflow上的回答,关于特定发行版/内核中
- 2016年7月:
LimitNOFILE
和LimitNPROC
从1048576
更改为infinity
。- 这个PR不久后撤回了对
LimitNOFILE
的更改。
- 这个PR不久后撤回了对
- 2014年3月:原始
LimitNOFILE
+LimitNPROC
以1048576
添加。- 链接的PR评论提到这个
2^20
的值已经高于Docker所需。
- 链接的PR评论提到这个
当前状态:
- 在Docker v25之前,
LimitNOFILE=infinity
仍然是默认设置,除非将其回退。 containerd
已经合并了相应的更改,从他们的systemd服务文件中移除了LimitNOFILE
设置。
Systemd < 240
在某些systemd版本中,设置LimitNOFILE
为无穷大可能导致它被限制为65536。请检查服务配置:
1 | [root@XXX ~]# ulimit -n -u |
containerd的systemd服务配置如下:
1 | cat /usr/lib/systemd/system/containerd.service |
查看配置对docker和containerd进程的影响:
1 | [root@XXX ~]# cat /proc/$(pidof dockerd)/limits |
1 | [root@XXX ~]# cat /proc/$(pidof containerd)/limits |
这个补丁使systemd查看/proc/sys/fs/nr_open
来找到内核中编译的当前最大打开文件数,并尝试将RLIMIT_NOFILE
的最大值设置为此值。这样做的好处是所选的限制值不太随意,并且改善了在设置了rlimit的容器中systemd的行为。
详细讨论见:systemd GitHub issue。
参考链接
- https://github.com/moby/moby/issues/45838
- https://github.com/moby/moby/issues/23137
- https://0pointer.net/blog/file-descriptor-limits.html
- https://www.codenong.com/cs105896693/
- https://github.com/moby/moby/issues/38814
- https://github.com/cri-o/cri-o/issues/7703
- https://github.com/envoyproxy/envoy/issues/31502
- https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Process%20Properties