我在部署比赛的过程中发现,GZCTF 在低并发下运行正常,但超过 100 人同时使用后,常出现页面加载失败、登录异常、Flag 提交超时,或者点击启动题目容器却没有响应的情况。排查时发现,平台容器继承的 nofile 软上限只有 1024,高并发下很容易碰到这个限制。
所以遇到这类问题,除了看 CPU、内存和数据库,也要检查一下平台进程的文件描述符上限。HTTP 请求、WebSocket 长连接和后端连接都要占用文件描述符。额度用完后,即使容器还在运行,平台也可能已经无法正常接收请求。
为什么会影响登录和开题
Linux 里的普通文件、网络 socket、管道等都通过文件描述符访问。nofile 对应进程的 RLIMIT_NOFILE 限制,达到上限后,继续打开文件或创建 socket 就会失败,常见报错是:
| |
对应的错误码是 EMFILE。Linux 文档里有这个限制的具体定义。
GZCTF 用到文件描述符的地方不少:加载程序集、读写日志、访问题目附件,以及浏览器、数据库、缓存服务和 Docker API 的连接。
因此,描述符耗尽后,不只是页面可能打不开。平台也可能无法建立数据库连接,或者无法向 Docker 发出创建题目容器的请求。用户看到的就是登录失败、提交超时、开题没有响应,而后台看容器状态却仍然是 running。
先看看实际限制是多少
进入 GZCTF 的 Docker Compose 目录,获取平台容器 ID:
| |
这里的 gzctf 是 Compose 中的服务名。查看平台主进程的限制和当前打开的描述符数量:
| |
官方镜像直接运行平台程序时,主进程是 PID 1;如果自己加了启动包装器,就把这里的 1 换成实际 dotnet 进程的 PID。
例如输出:
| |
前面的 1024 是软上限,也就是当前生效的限制;后面的 524288 是硬上限。不要只看到硬上限很大,就以为进程已经能使用这么多描述符。
我检查 GZCTF v1.9.0 时,空载状态就已经打开了 471 个描述符,其中 448 个指向程序集文件,socket 只有 4 个。也就是说,在用户连接进来之前,程序自身就已经占用了接近一半的额度。
这里的 471 不是 471 条用户连接。文件和 socket 共用同一个上限,不能直接拿 nofile 的数值当作平台能容纳的用户数。
再看一下容器有没有单独设置 ulimits:
| |
如果返回 null,就是没有显式配置,实际值继续看进程的 limits。不同机器继承到的默认值可能不同,检查时直接读 /proc/<PID>/limits 即可。
另外,宿主机终端里的 ulimit -n 查的是当前 shell。修改它,不会改变已经运行的容器进程。
配合日志检查
出现访问异常时,可以同时看描述符数量和最近的错误日志:
| |
如果描述符数量已经逼近软上限,日志又出现了 Too many open files,就可以沿着这个方向处理。比赛期间最好连续记录一段时间,看看是开赛后连接集中进入时上涨,还是运行过程中一直累积。
修改 Docker Compose 配置
先备份现有文件:
| |
在原来的 gzctf 服务下添加 ulimits,其他配置保留:
| |
这里把软上限调到 65536,硬上限沿用前面查到的 524288。如果原来的硬上限不同,就按实际环境填写,保证 hard 不低于 soft。配置格式可以参考 Docker Compose 的 ulimits 文档。
提高上限不会马上创建 65536 个连接,也不会预先分配这么多文件句柄,只是允许进程在需要时使用更多描述符。
改完先检查配置,再重建平台容器:
| |
ulimits 是容器创建参数,所以这里要重建容器,单独执行 docker compose restart 不会应用新配置。上面的命令只重建平台服务,不重启依赖的数据库;平台会短暂中断,放在赛前操作即可。
重建后再检查一次
容器 ID 会变化,需要重新获取:
| |
按前面的配置,应该能看到:
| |
接着实际走一遍登录、打开题目列表、查看排行榜、提交 Flag、创建和销毁题目容器。赛前压测也尽量覆盖这些操作和 WebSocket 长连接,光测静态首页看不出完整的业务压力。
压测时一起记录描述符数量、内存、数据库连接数、错误率和响应时间。访问量降下来后,再看看描述符数量有没有随着连接关闭而回落。如果一直上涨,就继续检查哪些连接或文件没有释放。
以后部署比赛平台,我会把这两项放进检查清单:进程的文件描述符软上限,以及高峰时实际用了多少。 容器显示 running,并不代表平台还能正常处理新的连接。