MySQL 8.0容器反复崩溃?OOM内存问题排查与配置优化实战
从本地开发机到测试环境,MySQL 8.0容器反复崩溃这个问题,我前前后后折腾了快一周。最迷惑人的地方在于,表面上看它就是个“端口冲突”问题,修完端口之后容器继续崩,最后才定位到是内存OOM在作怪。整个过程里踩了不少坑,也有几个排查工具和命令帮了大忙。今天把这些经历完整写下来,希望能让遇到类似问题的人少走几步弯路。
1. 现象还原:容器反复退出,“崩溃”到底长什么样
1.1 业务侧先报错:应用连不上MySQL的两种典型表现
容器崩溃这种事,通常不是DBA先发现的,而是业务侧先炸。我这次遇到的场景是Django服务在启动阶段就报错,错误信息五花八门,其中最刷存在感的一条是:
后来还有一次更离谱,连接居然成功了,但抛出的是:
这套组合拳打下来,很容易让人误判。第一条像是端口没通,第二条又像是连到了错误的MySQL实例上。我当时的第一直觉就是去查端口,觉得一定是宿主机上有什么东西把3306占了,容器没起来。
但实际上,第一条报错的原因很有可能是MySQL容器已经因为OOM被杀了,根本没有进程在监听3306;第二条报错则可能是宿主机上还残留了一个老版本的MySQL服务,端口映射错位后,应用连到了那个老实例上。两个症状交错出现,特别容易把人带偏。
1.2 docker ps与logs里的关键信号(exit code 137、Killed、OOMKilled)
当时我执行docker ps -a,看到MySQL容器的STATUS栏停留在Exited (137)。这个137就是第一个重要信号。
Linux下,进程退出码如果大于等于128,通常意味着进程是被信号杀死的,退出码等于128加上信号编号。137 = 128 + 9,9号信号就是SIGKILL,也就是进程被内核直接强杀,没有机会做任何清理工作。如果是MySQL主动退出、正常shutdown,退出码一般是0;如果是配置错误导致的启动失败,通常是1或2。所以看到137,基本可以断定不是MySQL自己不想干,而是被外力杀掉的。
再配合docker logs <container>看日志,末尾几行非常干净,就是简单一个Killed,后面连InnoDB的恢复记录和关闭日志都没有。这说明进程在收到SIGKILL之前,根本没来得及执行任何关闭逻辑。
这里还有一个非常关键的排查字段,藏得比较深但极其有用:
返回true,基本实锤了容器是因为内存超限被内核杀掉的。这个字段我后来在多个容器故障排查里都用上了,比翻日志快得多。
1.3 第一反应查端口:合理但不充分
很多人(包括我)遇到容器起不来,第一反应就是查端口冲突。这个思路不是错的,但不充分。端口冲突通常会导致容器启动失败,或者映射失败报port is already allocated,但它不会导致一个运行中的MySQL容器反复崩溃、退出。
也就是说,端口问题更像一个“启动期故障”,而OOM是“运行期故障”。如果容器起来之后跑了一阵又挂,或者挂掉之后docker restart又能活一段时间,这时候重点应该放在运行期的资源问题上,而不是一直盯着端口看。
不过也不能完全跳过端口排查,因为端口误用产生的报错太有欺骗性了,而且它确实会和OOM问题叠加,让现象变得更加复杂。下面详细说说我这次端口误用的完整链路。
2. 端口误用的完整排查链路:为什么“端口没被占”也是坑
2.1 复盘误区:3306映射到了3307,应用却还在连3306
我这次的误用场景很典型。开发机原本装了一个系统级的MySQL 5.7,端口3306一直被占用。为了不和老实例冲突,我在docker-compose里把MySQL 8.0容器映射成了宿主机3307。配置是这样的:
容器启动完全正常,docker ps里能看到3307端口已经映射好了。问题出在Django的配置里,我并没有把数据库端口改成3307,而是保留着默认的3306,然后HOST写的是127.0.0.1。
这就出现了一个非常隐蔽的错位:应用连的是宿主机127.0.0.1的3306端口,这个端口上跑着的是老MySQL 5.7实例。如果老实例没启动,应用就报Can't connect;如果老实例起来了,应用连接成功,但因为版本不匹配,报出了类似MySQL 8.4 or later is required (found 8.0)这种让开发者摸不着头脑的提示。
而且还有一个更隐蔽的坑:宿主机上如果同时存在IPv4和IPv6监听,127.0.0.1:3306和[::1]:3306可能分别指向不同的服务。在某些Linux发行版上,MySQL 5.7可能只监听了IPv6的3306,而Docker端口映射只绑了IPv4,这种半通不通的“幽灵连接”能折腾人一整天。
2.2 用三组命令从容器内外确认端口归属
遇到端口相关的问题,别靠猜,直接上命令。我这里分享三组非常实用的命令,可以快速理清“宿主机端口被谁占着”“容器端口映射到了哪里”“容器内部实际监听了哪个端口”。
第一组,看宿主机端口归属:
ss是现在Linux上推荐用的套接字查看命令,比老的netstat更直观。输出里能看到监听地址、端口和进程名。如果3306端口上显示的是mysqld,但PID对应的不是容器内的进程,那就要注意,你连接的很可能是宿主机上残留的老MySQL。
第二组,看Docker端口映射关系:
这条命令会输出容器端口和宿主机端口的映射关系。比如3306/tcp -> 0.0.0.0:3307,这就能立刻发现映射错位的问题。
第三组,进到容器内部确认MySQL实际监听情况:
正常情况容器内部mysqld监听3306。如果你在容器里用ss看到工具没装,可以用cat /proc/net/tcp临时顶一下,或者直接安装iproute2。
用这三组命令交叉验证,能很快确定端口映射到底对不对,也能排除“容器内MySQL没起来”的干扰项。我当时排查完,发现问题不是“端口冲突”,而是“端口映射和客户端连接串对不上”。
2.3 修复端口配置,但崩溃并没有停止
修复方式不复杂,把Django连接配置里的端口改成3307,或者干脆把容器映射改回3306并停掉宿主机老MySQL。因为开发环境用不到老实例,我选择了停掉宿主机MySQL,把容器映射改回3306。
改完配置,Django能正常连上MySQL 8.0了。我以为问题到此结束。但接下来不到半天,容器又崩了,而且这回是真的Exited (137),docker inspect显示OOMKilled: true。
到这里我才意识到,端口误用只是把真正的内存问题掩盖住了。或者更准确地说,端口误用是“显性问题”,OOM才是“核心问题”。在修好端口之前,业务一直连不上,MySQL负载低,内存压力小;一旦正常使用,业务请求开始打进来,MySQL内存涨上去,容器就扛不住了。两个问题是一先一后暴露的,这才是整段排查最绕的地方。
3. 内存OOM:真正按住MySQL 8.0喉咙的元凶
3.1 OOM Killing的底层机制:cgroup、内核与exit code 137
先理清一个概念:Docker容器本身没有独立内核,它共享宿主机的Linux内核。Docker对内存的限制是通过cgroup实现的,你给容器设置多少memory限额,cgroup就限制这一组进程最多能用多少物理内存。
当容器内所有进程的内存使用量超过cgroup限额时,内核的OOM Killer会被触发。OOM Killer会从超限的cgroup里挑选一个进程杀掉。在单进程容器里,被杀的基本就是主进程,也就是mysqld。mysqld被SIGKILL杀死后,整个容器退出,退出码是137。
这里有个容易忽略的细节:即便你创建容器时没有手动设置--memory,也不意味着容器可以无限使用内存。Docker默认情况下不对容器做内存限制,容器里的进程会用多少算多少,直到宿主机物理内存和Swap耗尽,再触发系统级的OOM Killer。
所以在本地开发机上,MySQL容器常常不是撞在cgroup限额上,而是把宿主机的内存吃爆了,被系统全局的OOM Killer选中杀掉。区别在于:如果是cgroup限额触发,日志里通常能看到明确的memory limit信息;如果是宿主机内存不足,dmesg日志里会记录被杀的进程名和当时的内存状况。
验证OOM最直接的三个地方,我个人建议按顺序查:
docker inspect --format='{{.State.OOMKilled}}' <container>,返回true基本可以确认;dmesg -T | grep -i killed,看内核日志里有没有Out of memory: Killed process <pid> (mysqld)之类的记录;- 如果系统用了cgroup v2,到容器对应的cgroup目录里看
memory.events文件,里面会有oom_kill计数。
我当时在dmesg里看到了这样一行:
总内存用了约1.6GB,匿名RSS约900MB。看起来不算夸张,但当时宿主机的内存余量非常少,因为开发机同时开着IDEA、浏览器、Docker里其他几个服务,物理内存早就告急。
3.2 MySQL 8.0在容器里的“隐形”内存开销
MySQL 8.0和5.7在内存行为上有一个非常大的差异,就是performance_schema默认开启。5.7时代,performance_schema默认是关闭的,很多人对它的内存开销没有概念。到了8.0,它默认开启,而且会在启动时根据一些performance_schema_max_*参数预分配内存。
在容器场景下,这个预分配内存很容易被忽略。很多人在宿主机上跑MySQL 8.0感觉不到问题,是因为宿主机内存充裕;一旦挪到小内存的容器里,performance_schema占用的那几百MB就很要命了。
除了performance_schema,MySQL 8.0常见的内存消耗点还有:
innodb_buffer_pool_size,默认128MB,但这是MySQL自己管理的内存池,实际使用过程中会根据page分配情况动态变化;innodb_log_buffer_size,默认16MB,不算大;table_open_cache,默认4000个表缓存,每个缓存项会占用一定内存,虽然单个体积不大,但数量上去之后总量可观;max_connections,每一个连接都有自己的thread stack、net buffer、sort buffer、join buffer等,连接越多内存消耗越大;- 一些运行时临时结构,比如临时表、排序操作,在高并发查询时也会吃内存。
这几个因素叠加起来,一个“配置文件看起来什么都没配”的MySQL 8.0容器,跑起来之后占用上GB内存非常正常。如果你宿主机本身只有8GB,再跑IDEA、浏览器、若干Docker容器,那么MySQL容器就是最容易被OOM Killer盯上的目标。
3.3 用日志和指标实锤OOM的验证路径
定位OOM不能靠猜,我总结了一套比较完整的实操路径。假设你的容器已经以一种“说不清道不明的方式”崩掉了,按下面这个顺序排查:
先查退出码和OOM标记:
如果输出是137 | true,基本锁定内存问题。
再查内核日志:
这一步能看到被杀进程的详细信息,尤其是PID和内存占用。如果能匹配到mysqld,那就非常明确了。
接着看容器运行期间的实际内存占用曲线。如果你的环境里已经部署了Prometheus + cAdvisor,直接查容器内存指标;如果没有监控体系,可以用一个很轻量的方式:
这条命令能看当前所有容器的CPU和内存使用量。虽然它看不了历史曲线,但配合docker events或者自己写个脚本定时采集,也能分析出崩溃前的内存趋势。
最后,也是最容易漏掉的一步:检查宿主机的Swap配置。如果宿主机Swap为0,内存吃紧时内核会直接走OOM Killer;如果Swap真的存在,MySQL容器可能会把大量匿名页换到Swap里,性能急剧下降,但不会崩溃。我这次就是踩了Swap为0的坑,导致内存一紧就直接被杀。
4. 从docker run到compose:让MySQL 8.0稳定跑在容器里的配置
4.1 内存限制与MySQL参数怎么配合
定位到OOM之后,修复方向就清晰了:一是限制MySQL容器的内存使用,二是优化MySQL自身的参数减少内存占用,三是避免宿主机整体内存被耗尽。
我最终的方案是在docker-compose里给MySQL容器加内存限制,同时关闭一些开发环境用不到的功能。这里有一个很常见的误区:有人只给容器设置mem_limit,但MySQL那边依然按照宿主机内存大小去配置buffer pool和performance_schema,结果容器一启动就撞内存上限。正确做法是两端配合,MySQL参数直接决定它能吃多少内存,mem_limit是兜底保险。
具体来说,MySQL侧我做了这些调整:
| 参数 | 原值 | 调整后 | 原因 |
|---|---|---|---|
innodb_buffer_pool_size |
128M | 128M | 开发环境够用,不必再调大 |
performance_schema |
ON | OFF | 开发环境关闭可省几百MB内存 |
table_open_cache |
4000 | 1000 | 降低表缓存内存开销 |
max_connections |
151 | 100 | 限制连接数,减少每个连接的内存开销 |
innodb_log_buffer_size |
16M | 8M | 降低预分配内存 |
注意:
performance_schema=OFF在开发环境没问题,但如果有监控系统依赖performance_schema采集数据,就不要关。生产环境也建议保持开启,DBA做性能分析时很依赖它。
容器侧我用mem_limit: 1g限制整个容器最多使用1GB内存,同时设置pids_limit防止进程数异常暴涨。mem_limit的值可以根据你的机器配置调整,但建议至少给MySQL留出800MB以上的空间,否则buffer pool 128MB加上各种临时结构,很容易碰壁。
4.2 docker-compose完整配置与healthcheck
这是我在这次事故后稳定运行的docker-compose配置:
几个细节值得说一下。
restart: unless-stopped能保证容器在非手动停止的情况下,崩溃后自动重启。这在本地开发环境很实用,但在生产环境要谨慎,因为MySQL崩溃后如果数据目录损坏,自动重启可能导致恢复逻辑反复执行。
stop_grace_period: 30s是给Docker发送SIGTERM后最多等待多少秒再强制SIGKILL。MySQL关闭时需要flush dirty pages、清理buffer pool,如果这个值设得太短(默认10s),可能没有足够时间完成优雅关闭,下次启动时需要做更长的崩溃恢复。
healthcheck里的$$MYSQL_ROOT_PASSWORD是重点。在docker-compose里,$MYSQL_ROOT_PASSWORD会被compose解析成变量,如果要让命令在容器内部读取环境变量,必须写$$转义。mysqladmin ping返回成功不代表数据库可用,但作为连通性检查足够了。如果业务要求更高,可以用mysqladmin status或者跑一个SELECT 1。
4.3 验证:重启后如何确认不再崩溃
配置改完之后,需要做两轮验证。
第一轮验证基础功能:
等到日志里出现ready for connections,说明启动完成,再用客户端连一次库,跑几个查询确认业务正常。
第二轮验证稳定性,也是最容易被跳过的。本地开发环境没那么大压力,但至少要跑一晚上的持续观察。我的做法是写了一个简单的监控脚本,每隔30秒记录一次容器内存使用和宿主机的可用内存:
第二天早上看日志,容器内存稳定在一个区间内,没有持续上涨的趋势;dmesg也没有新的killed process记录。到这里我才确认修复生效。
5. 同类坑位复盘:容器里跑数据库还有哪些“假崩溃”
5.1 数据卷权限与初始化失败
OOM是典型的运行期崩溃,但还有一类情况是容器启动后立刻退出,看起来也像“崩溃”,其实是数据卷权限问题。MySQL 8.0的官方镜像在启动时会检查/var/lib/mysql目录的属主和权限,如果挂载了宿主机目录但权限不对,mysqld会初始化失败,然后退出。
排查方法很简单,看日志里有没有[ERROR] Permission denied,或者[Warning] Insecure permissions之类的提示。遇到这种情况,不要给777权限,正确的做法是让容器内的mysql用户(UID 999)拥有数据目录:
5.2 磁盘写满导致的“僵死”退出
还有一种“假崩溃”是磁盘写满触发的。MySQL运行时要写redo log、binlog、undo log,如果数据目录所在的磁盘满了,InnoDB无法完成checkpoint,mysqld会主动报错甚至直接退出。容器不会自动恢复,除非配置了restart策略。
这种情况的表现是日志里出现[ERROR] InnoDB: Write to file ./ib_logfile0 failed或者No space left on device。解决方案除了扩容磁盘,更重要的是在开发环境直接关掉binlog,或者设置一个很短的过期时间:
或者:
另外别忽略Docker的日志驱动,如果容器一直往标准输出打日志,/var/lib/docker/containers目录下的*-json.log文件会无限增长。我后来给MySQL容器加了Docker daemon级别的日志轮转配置,避免再出现“日志把磁盘塞满”的尴尬:
5.3 连接打满与pids_limit的隐藏风险
如果你在compose里设置了pids_limit,要留意MySQL在高并发下的线程数量。mysqld内部会为每个连接创建线程,再加上后台的IO线程、刷盘线程、purge线程等,进程数很容易超过默认的pids_limit: 512。一旦超过限制,进程创建失败,mysqld可能直接抛错退出。
这类问题和OOM的表现非常像,都是容器退出、退出码异常、重启后能好一阵子。但排查方向完全不同。遇到容器崩溃,先看dmesg有没有OOM记录,如果没有,再看系统日志里有没有resource temporarily unavailable或者fork failed。这次排查MySQL崩溃,我总结了三个“先查”的顺序:
- 先查
docker inspect --format='{{.State.OOMKilled}}',确认是不是内存问题; - 再查
dmesg -T | grep -i 'killed process',看内核有没有杀进程记录; - 最后看
docker logs和MySQL错误日志,排除权限、磁盘、配置等问题。
这套顺序能快速排除80%的“看起来像崩溃”的情况。
这次折腾下来,我最大的体会是:容器化部署MySQL,真正要管的不只是“能不能启动”,而是“启动之后能不能稳定运行”。端口映射错了,业务侧一开始可能根本察觉不到,直到某个深夜连接全部失败才意识到问题;而内存OOM的爆发则更像温水煮青蛙,平时一切正常,一旦业务流量上来或者宿主机内存被其他进程吃紧,MySQL容器就成了那个被祭天的对象。如果你也在本地用Docker跑MySQL 8.0做开发,建议现在就去看一眼:容器设了内存限制没有,performance_schema开着没有,宿主机还有多少内存余量。等到容器真的崩溃再查,就轮到你熬夜看dmesg了。