ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

麒麟V10下Docker部署kk-fileview报out of memory?排查Linux文件描述符与ulimit限制

麒麟V10下Docker部署kk-fileview报out of memory?排查Linux文件描述符与ulimit限制 在麒麟V10x86_64服务器上用 Docker 部署 kk-fileview 在线预览服务时我碰到了一条非常典型的报错unable to allocate file descriptor table - out of memory紧接着进程直接Aborted容器状态变成 Exited(134)。如果你也搜到了这条错误大概率不是业务代码的问题而是 Linux 进程资源限制与容器内存分配机制之间的隐性冲突。我先交代一下环境服务器是银河麒麟 V10 桌面版/服务器版x86 架构Docker 用发行版自带源安装kk-fileview 官方镜像 keking/kkfileview 默认端口 8012。整个过程相当折腾但定位清楚之后发现原理非常简单。这篇文章会从报错本身讲起拆开 file descriptor table 的分配机制再给出一套可复现的排查链路和三种修复方式最后补充 kk-fileview 在麒麟环境下的部署经验希望能让后来的人少走弯路。1. 先看懂现场这条报错到底在说什么1.1 部署背景kk-fileview 是怎么跑起来的kk-fileview 是一款开源的在线文件预览工具后端是 JavaSpring Boot启动时依赖大量文件句柄来监听端口、加载字体、处理各类文档转换。常规部署方式就是拉镜像直接跑docker run -d --name kkfileview \ -p 8012:8012 \ -e KK_OFFICE_PREVIEW_SWITCHEStxt,doc,pdf,ppt,xls \ keking/kkfileview:v4.1.0命令本身没有任何特殊参数看起来就是一个普通的容器。第一次我执行完后容器没有正常起来docker ps -a看到状态是Exited(134)docker logs kkfileview输出里就躺着那行熟悉的错误unable to allocate file descriptor table - out of memory Aborted这里我先解释一下退出码 134它对应 128 6即进程收到了 SIGABRT 信号。Java 启动器在初始化阶段发现自己无法完成基础设施的创建主动调用 abort 自杀而不是优雅地退出。所以看到 134 基本可以断定是启动早期阶段出了问题跟你的业务代码、预览功能本身无关。1.2 完整报错日志拆解除了那行直接的错误有些环境里还会伴随下面的日志片段# # There is insufficient memory for the Java Runtime Environment to continue. # Native memory allocation (mmap) failed to map N bytes for committing reserved memory. # An error report file with more information is saved as: # hs_err_pid12345.log这里有个很容易混淆的点日志里提到了 insufficient memory很多人第一反应就是给容器加内存比如-m 4g、加 swap但问题并没有解决。原因在于JVM 的 native memory allocation 只是表象真正的根因是基础 C 库在创建进程描述符表时分配内核内存失败。还有个细节值得注意如果你尝试docker exec进入这个已经退出或启动失败的容器也会遇到类似失败甚至可能会看到error: failed to create exec ... cannot allocate memory。这说明问题不仅影响 Java 进程而是容器内任何新进程的创建都受影响这是非常重要的排查信号。1.3 报错的家族特性资源类报错跟代码无关这类unable to allocate xxx - out of memory错误属于进程创建基础设施失败家族跟应用代码没有关系。常见的还有几种表现错误信息出处阶段unable to allocate file descriptor table创建新进程时初始化文件描述符表fork: Cannot allocate memoryfork 子进程时mmap: Cannot allocate memory动态库加载或线程栈创建时pthread_create failed: Resource temporarily unavailable创建线程时它们共同的本质是内核在给你进程分配那些看不见的元数据内存时失败了。你的 Java 代码再正确、再简单只要启动阶段走不到 main 方法就必然在这里倒下。所以排查方向要立刻从业务转到操作系统层面。2. 追根溯源fd table 为什么会分配失败2.1 文件描述符表的内存模型每个 Linux 进程内部都有一张文件描述符表file descriptor table它是一块内核内存区域记录了当前进程打开的所有文件、socket、管道等资源的索引。这张表的大小不是固定的它由进程的RLIMIT_NOFILE软限制也就是我们常说的ulimit -n决定。关键点在于内核在创建进程fork/exec时会一次性为这个进程的 fd table 预留足额的内存空间。假设每个表项在 x86_64 架构下占 8 字节如果你把nofile设成 1048576常见的优化值那么光 fd table 这一块就要预留大约 8MB 的内核内存如果把nofile设成 1073741819有些 systemd 配置里 LimitNOFILEinfinity 会算出来这种天文数字fd table 大小直接逼近 8GB。这就很好理解了你要求的 nofile 越大进程诞生时需要吞掉的内核内存越多。这个分配发生在任何用户态代码执行之前一旦分配失败fork 或 exec 直接返回错误JVM 启动器打印unable to allocate file descriptor table然后 abort。2.2 nofile 越大fork 时越危险很多人对ulimit -n的印象是越大越好在传统服务器上调优时也确有把 nofile 调大的习惯。但这里有个隐藏的矛盾点ulimit -n的限制在数量上指的是进程能打开的最大 fd 数量而不是已经打开的 fd 数量。内核为了性能不会等到你真的用到那么多才扩展 fd table而是按上限一次性预分配当然内核实现上有一些惰性优化但大方向上是一一映射的关系。你可以做一个思想实验假设一个 2GB 内存的容器cgroup 限制很严格里面跑着 Java 进程Java 堆已经占了不少内存。此时系统如果还要为一个新进程分配 8MB 甚至更多的内核内存作为 fd table而内核内存池恰好处于紧张状态分配就失败。小内存机器上更容易触发但大内存机器也会因为cgroup 内内存计数而触发——同样是这条报错。2.3 容器场景下的特殊放大效应这个问题在容器里比在裸机服务器上更容易爆发原因有三层第一容器默认继承 dockerd 进程的RLIMIT_NOFILE。而 dockerd 如果由 systemd 启动systemd service 文件里很可能配置了LimitNOFILEinfinity或一个很大的值于是容器进程也继承了同样的天文数字。第二Docker 容器有自己的内存 cgroup 限制。即使宿主机总共内存很充沛容器 cgroup 允许的那一小块里已经塞满了 Java 堆、线程栈、Metaspace此时再要求预分配 8MB 的 fd tablecgroup 内存控制器直接拒绝。第三麒麟 V10 这种系统往往预装有各类安全加固或性能调优内核参数某些模板里会修改limits.conf把 nofile 全局拉高。宿主机没问题但容器里一旦继承过来启动即爆炸。所以说容器 Java 大 nofile 严格 cgroup 内存这四个条件凑齐kk-fileview 的这条 abort 几乎必然发生。3. 排查链路全记录从怀疑内存到锁定 ulimit3.1 第一轮误判为容器内存不足我一开始看到 out of memory第一反应就是容器内存不够。于是先做了三件事# 查看容器当前占用 docker stats --no-stream # 查看系统可用内存 free -h # 查看 dmesg 有没有触发 OOM Kill dmesg | grep -i oom结果很有意思宿主机内存 32GB可用 20 多 GB容器压根没起来所以docker stats里也没有实时占用dmesg没有任何 OOM Kill 记录。这说明根本不是传统意义的 OOM。我试着给容器加内存限制docker run -d --name kkfileview \ -m 4g \ -p 8012:8012 \ keking/kkfileview:v4.1.0结果依旧 Exited(134)报错一字不差。这时候我确定问题不在容器可用内存大小而在别的地方。3.2 第二轮检查系统级 file-max既然怀疑 fd 相关那就先看系统全局的 fd 上限sysctl fs.file-max cat /proc/sys/fs/file-nrfs.file-nr会输出三个数字已分配 fd 数量、未使用 fd 数量、系统最大 fd 数量。如果第一列接近第三列说明整个系统的 fd 耗尽也会导致分配失败。但这台服务器上 file-nr 显示只用了几千离几十万的上限远得很全局层面没有瓶颈。同时我用ulimit -n查看当前 shell 的 nofile显示 65535看起来也很正常。到这里我一度陷入困惑全局没瓶颈shell 里也是常规值为什么容器里就是起不来3.3 关键突破容器内外 limit 对比实验后来我换了一个思路直接看 dockerd 进程的 rlimit再看容器实际的 rlimit。先找 dockerd 的 PIDsystemctl status docker # 或者 pgrep -f dockerd然后查看这个进程的 fd 限制cat /proc/dockerd_pid/limits这一看就发现问题了Limit Soft Limit Hard Limit Units Max open files 1073741819 1073741819 filesdockerd 的RLIMIT_NOFILE是1073741819这是个严重超大的值。为什么因为麒麟 V10 的 systemd 服务模板把LimitNOFILEinfinity写进去了systemd 在启动 docker 时就把这个极限值传递给了 dockerddockerd 又在启动容器时把它传给了容器内的所有进程。我再用一个最小容器验证docker run --rm alpine sh -c cat /proc/self/limits | grep -i files输出同样是 1073741819。容器内一个新进程一诞生内核就要按这个上限预留 fd table 内存一个 8GB 起步的预分配请求在 cgroup 内存限制下直接失败所有进程统统 abort。这才是根因。3.4 用最小复现容器钉死根因为了彻底确认我做了一个对照实验。第一个容器不修改 ulimit第二个容器显式设小# 实验 A默认继承预期失败 docker run --rm alpine echo hello # 实验 B显式指定 nofile预期成功 docker run --rm --ulimit nofile65535:65535 alpine echo hello实验 A 在我那台机器上直接报错退出了实验 B 正常输出 hello。到这里根因已经板上钉钉。如果你手头也有同样的报错可以先用这两条命令快速复现5 秒钟就能确认是不是同一个问题。4. 三种修复方式与效果验证4.1 方式一docker run 直接指定 --ulimit最直接的方式在启动容器时显式指定 nofiledocker run -d --name kkfileview \ -p 8012:8012 \ --ulimit nofile65535:65535 \ -e KK_OFFICE_PREVIEW_SWITCHEStxt,doc,pdf,ppt,xls \ keking/kkfileview:v4.1.0这里的65535:65535前一个是软限制后一个是硬限制。软限制是实际生效的阈值硬限制是软限制能调整到的上限。两者设成一样避免 Java 运行时自己试图调高时重新撞上大值。我特意把值控制在 65535 而不是 1048576是因为 kk-fileview 在预览场景下同时打开的文件数量非常有限就算并发几十个文档预览fd 用量也就是几百到几千。65535 已经留足了富余量而且 fd table 预分配只有约 512KB再怎么也不会让容器内核内存吃紧。4.2 方式二docker-compose 固定 ulimits如果你和我一样习惯用 docker-compose 管理容器可以在 compose 文件里声明services: kkfileview: image: keking/kkfileview:v4.1.0 container_name: kkfileview ports: - 8012:8012 environment: - KK_OFFICE_PREVIEW_SWITCHEStxt,doc,pdf,ppt,xls ulimits: nofile: soft: 65535 hard: 65535docker compose up -d之后这个配置会在容器创建阶段生效。对比docker run方式compose 的好处是参数固化在配置文件里后续运维同事接手时一眼就能看到 nofile 被设置了不会莫名其妙。这里强调一句compose 里的 ulimits 不是运行时动态调整参数而是容器创建时的初始属性。一旦容器创建了就不能用docker update去改 ulimitdocker update 只支持 CPU、内存这类可在线调整的资源。所以如果是已经创建的容器只能先删掉再用新配置重建。4.3 方式三宿主机侧调整 limits.conf如果你希望所有容器都不再继承那个天文数字可以从源头上改掉宿主机的系统级配置。编辑/etc/security/limits.conf追加* soft nofile 65535 * hard nofile 65535 root soft nofile 65535 root hard nofile 65535但这只对通过 PAM 登录的会话生效dockerd 是 systemd 启动的系统服务不吃 limits.conf 这套。真正管用的是 systemd service 里的LimitNOFILE。所以还要改 docker 的 systemd 服务配置mkdir -p /etc/systemd/system/docker.service.d cat /etc/systemd/system/docker.service.d/override.conf EOF [Service] LimitNOFILE1048576 EOF然后重载 systemd 并重启 dockersystemctl daemon-reload systemctl restart docker这样 dockerd 自身的 nofile 会变成 1048576容器继承的值也会回落到 1048576。虽然还是偏大但至少不会到 1073741819 那种必失败的极端。如果你追求更稳可以直接设成 65535。我个人建议优先使用前两种方式因为改系统级配置影响面大会影响宿主机上所有容器容易引入新的不可控变量。只有当你明确希望统一变更整机容器资源基线时才用方式三。4.4 验证方案/proc/limits 与实际启动测试改完配置后不要只看容器起来了就收工我习惯做三个验证步骤第一步确认容器内实际生效的 limitdocker inspect kkfileview | grep -A 5 Ulimits比较直观的话直接进容器看docker exec kkfileview cat /proc/1/limits | grep -i open files正常输出应该是类似Max open files 65535 65535 files注意这里 PID 1 是容器主进程也就是 Java 进程本身它的 rlimit 才是真正决定生死的值。第二步确认 Java 进程顺利拉起。看启动日志docker logs -f kkfileview当看到类似Started KkFileviewApplication in xx seconds的日志说明 Spring Boot 已经完成启动。第三步做一次真实的文件预览请求。kk-fileview 默认暴露一个预览页面直接 curl 验证curl -I http://localhost:8012/index.html返回 HTTP 200 就大功告成。如果还带上了文件预览请求可以再传一个真实文件试一下转换流程确保文档转换引擎的 fd 使用也正常。5. kk-fileview 在麒麟 V10 上的其他实战提醒5.1 官方镜像的架构适配与选型麒麟 V10 有 x86 和 ARM 两个大版本你的标题如果也搜到了 ARM 相关的内容这里提醒一下镜像一定要选对架构。在 x86_64 的麒麟里直接拉keking/kkfileview:latest默认是 amd64 镜像没问题。但如果你在飞腾、鲲鹏这类 ARM 机器上跑就要用keking/kkfileview:arm64这类标签或者确保你的 Docker 版本支持自动拉取对应架构。此外麒麟系统默认的 Docker 版本可能偏老如果拉镜像或启动时碰到奇怪的兼容性问题优先检查docker versionServer 版本如果在 20.10 以下建议从发行版源升级到较新的 docker 包或者考虑用兼容模式。老版本 docker 对 ulimit 的传递逻辑也有不少 bug不要在这上面浪费时间。5.2 Java 预览服务的资源画像kk-fileview 是 Java 应用启动时对内存控制比较敏感。在麒麟上跑了一段时间我总结出一个比较稳的资源配置基线资源项建议值说明nofile65535预览场景绝对够用避免天文数字容器内存2g 起步默认 JVM 堆 1g处理大 PDF 时需要富余交换分区尽量关闭容器内 swap 会导致响应变慢CPU 限制2 核左右文档转换是 CPU 密集场景如果你希望降低 JVM 堆占用可以追加启动参数environment: - JAVA_OPTS-Xmx1536m -Xms512m注意 kk-fileview 官方镜像是否识别JAVA_OPTS不同版本可能不同。实测 v4.x 版本是识别的但最好先在测试环境验证一下。5.3 我固定下来的一套部署模板踩过这一轮坑之后我现在在麒麟 V10 上部署 kk-fileview 用的都是一套模板docker-compose 内容固定如下services: kkfileview: image: keking/kkfileview:v4.1.0 container_name: kkfileview restart: unless-stopped ports: - 8012:8012 ulimits: nofile: soft: 65535 hard: 65535 environment: - KK_OFFICE_PREVIEW_SWITCHEStxt,doc,docx,pdf,ppt,pptx,xls,xlsx - KK_OFFICE_WEB_INSTALL_SWITCHtrue部署命令只有一句docker compose up -d这套模板避免了所有 inherited ulimit 的坑也固定了端口和文档类型开关。后续如果遇到类似问题我第一件事就是去查宿主机 dockerd 的 limits而不是急着给容器加内存。最后再分享一个小技巧如果你已经在一台麒麟机器上确认了根因可以使用docker run --rm --ulimit nofile65535:65535 alpine echo ok作为快速健康检查命令把它写进运维脚本里每次批量部署前先跑一遍能在面对集群环境时提前把有问题的宿主机筛出来省得挨个容器看日志。
返回列表