Java镜像1.2GB→200MB,K8s Pod OOMKilled 137半夜报警——容器化的真实踩坑记录

容器与 K8s 核心知识:从 Docker 镜像分层到 Pod 调度

Docker 解决"环境不一致"------但解决不了"内存不够"。半夜 K8s 集群报警:Pod OOMKilled (exit code 137),不是代码的锅,是 memory limits 设太小。这篇文章把从镜像瘦身(1.2GB→200MB,多阶段构建)、到 Pod OOMKilled 排查(137 vs 143 不同根因)、到滚动更新防翻车(maxUnavailable=1 保底),真实踩坑经验全串起来。
阅读约 14 分钟 | 系列第 14/17 篇


一、Docker 基础:容器不是"轻量级虚拟机"

1.1 容器 vs 虚拟机

维度 容器 虚拟机
隔离级别 进程级(共享宿主机内核) OS 级(Hypervisor 虚拟硬件)
启动速度 秒级 分钟级
资源占用 MB 级 GB 级
单机密度 几十上百个 几个

容器的本质是 进程隔离 + 资源限制------Linux 的 namespace(看不见别人)和 cgroup(不能占别人资源)组合而成。它不是"轻量级虚拟机",因为没有自己的内核。

1.2 镜像分层------为什么 Docker 镜像能共享?

镜像由多个只读层叠加,每层是上一层的增量。Dockerfile 每条指令产生一层:

sql 复制代码
FROM openjdk:17-slim        ← 基础层(200MB)
RUN apt-get update           ← 系统层
COPY target/app.jar          ← 应用层(你的代码)
  ↑ 这三层叠加形成最终镜像

分层的核心收益

  • 共享复用 :10 个 Java 应用共享同一个 openjdk:17-slim 基础层,磁盘只存一份
  • 构建缓存 :Dockerfile 中未变的层直接复用缓存,只重建变化的层------COPY app.jar 之前的层全部 hit cache
  • 增量传输:镜像仓库 push/pull 只传缺失的层

1.3 Dockerfile 关键指令速查

指令 作用 注意
FROM 基础镜像 优先 alpine/slim 小体积
COPY vs ADD 复制文件 COPY 只复制;ADD 多了自动解压 tar,少用
RUN 构建时执行 多条合并用 && \ 减少层数
CMD vs ENTRYPOINT 容器启动命令 ENTRYPOINT=主程序,CMD=默认参数

1.4 Docker 实操要点

bash 复制代码
# 常用管理命令
docker ps                    # 运行中的容器
docker logs <容器ID>         # 查看日志
docker exec -it <容器ID> bash  # 进入容器终端
docker stop/start <容器ID>   # 停止/启动

Docker Compose(本地开发一键启动依赖):

yaml 复制代码
version: '3.8'
services:
  web:
    image: nginx:latest
    ports: ["80:80"]
  db:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=secret
    volumes:
      - ./mysql_data:/var/lib/mysql
bash 复制代码
docker-compose up -d    # 后台启动所有服务
docker-compose down     # 停止并清理

网络与安全

bash 复制代码
# 自定义网络(容器间通过服务名通信)
docker network create my-net
docker run -d --network my-net --name app1 myapp

# 资源限制(防止单容器吃光宿主机)
docker run -d --memory=512m --cpus=1 myapp:latest

# 只读文件系统(安全加固)
docker run -d --read-only --name secure-app myapp:latest

二、K8s 核心资源:一张图讲清关系

2.0 K8s 集群架构

K8s 采用控制平面-工作节点架构:

Master 节点(控制平面)

  • kube-apiserver:集群统一入口,所有操作都经过它
  • etcd:高可用 KV 存储,集群的"大脑"------所有状态数据存在这里
  • kube-scheduler:决定 Pod 放在哪个 Node(详见 §三)
  • kube-controller-manager:运行各类控制器,不断调谐实际状态→期望状态

Node 节点(工作负载)

  • kubelet:每个 Node 的"工头",接收 PodSpec 并确保容器健康运行
  • kube-proxy:维护网络规则,实现 Service 的流量转发
  • Container Runtime:真正运行容器的组件(containerd / CRI-O)

关键认知:Master 挂了,已有 Pod 不受影响(kubelet 独立运行),但无法创建新 Pod、无法调度、无法滚动更新。这就是为什么生产集群 Master 必须高可用(至少 3 节点)。

2.1 一句话理解

K8s = 容器编排平台------你告诉它"我要 3 个 nginx 实例,每个 512MB 内存",它自动调度、自动重启、自动扩缩。

2.2 核心资源关系

markdown 复制代码
Deployment(声明期望副本数+滚动更新策略)
    │
    └── 管理 ReplicaSet(维护指定数量的 Pod 副本)
            │
            └── 管理 Pod(最小调度单元)
                    │
                    ├── Container(业务容器 + Sidecar)
                    │
                    ├── ConfigMap / Secret(配置与代码分离)
                    │
                    └── Service(给 Pod 挂固定 VIP+DNS)
                            │
                            └── Ingress(七层 HTTP 路由入口)
资源 一句话 技术要点
Pod 最小调度单元,1+ 容器共享网络/存储命名空间 "一组紧密协作容器的逻辑主机"
Service Pod 的稳定访问入口(Pod IP 会变,Service IP 不变) "给一组 Pod 挂个固定 VIP + DNS"
Deployment 声明式管理副本数 + 滚动更新 + 回滚 "你写期望状态,K8s 把实际状态调到期望"
ConfigMap/Secret 配置与代码分离 "ConfigMap 存配置,Secret 存密码/token"
Ingress 七层 HTTP 路由入口 "K8s 世界的 Nginx"

2.3 关键机制

  • 声明式 API:写 YAML 定义期望状态 → Controller 不断调谐使之匹配。你不需要写"怎么启动",只需要写"应该是 3 个 Pod 运行着"
  • 滚动更新 :先启新 Pod → 健康检查通过 → 逐步停旧 Pod,失败自动回滚(kubectl rollout undo 1 秒完成)
  • 健康检查livenessProbe(活没活,死了重启)vs readinessProbe(准备好没,未就绪不切流量)------前者是存活兜底,后者是流量门神

三、Pod 调度:K8s 怎么决定 Pod 放哪个 Node?

调度器(kube-scheduler)分两步:

第一步:过滤------排除不合格的 Node

复制代码
资源不够?→ 排除(Pod 申请的 CPU/Memory 超过 Node 剩余量)
Node 有污点且 Pod 没容忍?→ 排除
不满足亲和性/反亲和性规则?→ 排除
端口被占用或存储卷不匹配?→ 排除
Node 状态 NotReady?→ 排除

第二步:打分------给剩余 Node 排名

打分策略 含义
LeastRequestedPriority 资源剩余越多分越高(默认权重最高)
BalancedResourceAllocation CPU 和内存比例越均衡分越高
NodeAffinityPriority 越匹配亲和性规则分越高
ImageLocality 本地已有镜像的 Node 加分(省拉取时间)

打分计算示例:假设集群中有 Node A 和 Node B 通过过滤阶段,调度器对两者计算加权总分:

ini 复制代码
Node A(剩余 CPU 60%,剩余内存 40%,本地有镜像):
  LeastRequestedPriority:  85 分 × 权重 0.5 = 42.5
  BalancedResourceAllocation: 70 分 × 权重 0.3 = 21.0
  ImageLocality:           100 分 × 权重 0.2 = 20.0
  总分 = 83.5

Node B(剩余 CPU 30%,剩余内存 70%,无本地镜像):
  LeastRequestedPriority:  50 分 × 权重 0.5 = 25.0
  BalancedResourceAllocation: 40 分 × 权重 0.3 = 12.0  (CPU 和内存比例失衡)
  ImageLocality:             0 分 × 权重 0.2 =  0.0
  总分 = 37.0

Node A 总分 83.5 > Node B 37.0 → Pod 调度到 Node A。

分数加权求和 → 最高分当选 → 绑定写入 etcd。

核心原理:"调度器 = 海选(过滤不合格的)→ 排位赛(按资源/亲和性/本地镜像打分)→ 决赛(最高分绑定)。整个过程是声明式的------你写期望状态,调度器找最优落点。"


四、HPA 自动扩缩容

HPA(Horizontal Pod Autoscaler)核心公式:

scss 复制代码
期望副本数 = ceil(当前副本数 × 当前指标值 / 目标指标值)

例如:3 个 Pod,CPU 使用率 80%,目标 50% → ceil(3 × 80/50) = 5 → 扩到 5 个。

关键机制

  • 指标来源metrics-server(CPU/内存)或 Prometheus Adapter(自定义指标如 QPS/延迟)
  • 冷却时间:扩容后等 3 分钟再扩、缩容后等 5 分钟再缩,防止抖动
  • KEDA:事件驱动扩缩(Kafka 积压、Redis 队列长度),比原生 HPA 更灵活

HPA vs VPA:水平还是垂直?

策略 方式 适用场景
HPA(水平) 增减 Pod 副本数 无状态服务(Web/API),靠多副本分摊流量
VPA(垂直) 调整 Pod 资源 request/limit 有状态服务(DB/Cache),不能简单加副本
联动 HPA 处理突发流量,VPA 优化资源配置 大规模集群降本------VPA 发现 request 设太高了就下调

生产踩坑:扩容不是实时的

vbscript 复制代码
metrics-server 采集间隔 15-30s + HPA 检查间隔 15s
→ 扩容延迟约 1-2 分钟
→ 瞬间流量尖峰需配合提前预热 + 业务层限流

五、滚动更新:先启新再停旧

核心参数

参数 含义 默认值
maxSurge 超出期望副本数的最大 Pod 数 25%
maxUnavailable 允许不可用的最大 Pod 数 25%
minReadySeconds 新 Pod 启动后需等待多少秒才视为就绪 0

更新过程

bash 复制代码
① 创建新 ReplicaSet
② 逐步增加新 Pod、减少旧 Pod(按 maxSurge/maxUnavailable 控制速度)
③ 新 Pod 通过 readinessProbe + minReadySeconds 后加入 Service
④ 旧 Pod 等待 terminationGracePeriodSeconds 优雅退出
⑤ 旧 ReplicaSet 保留(用于回滚)

回滚kubectl rollout undo deployment/xxx → 1 秒切回上一个 ReplicaSet。


六、kubectl 排错三板斧

1. kubectl describe --- 看"发生了什么"

bash 复制代码
kubectl describe pod <pod-name>

关键看 Events 字段:调度失败原因(资源不足/污点)、镜像拉取耗时、容器启动/退出时间、OOMKilled 标记。

2. kubectl logs --- 看"应用说了什么"

bash 复制代码
kubectl logs <pod-name> -c <container-name> --tail=100 -f
# --previous 看上一次崩溃容器的日志(找 CrashLoopBackOff 根因)
kubectl logs <pod-name> --previous

3. kubectl exec --- 看"里面长什么样"

bash 复制代码
kubectl exec -it <pod-name> -- /bin/bash
# 进去后:curl localhost:8080/health | env | cat /etc/hosts | df -h | free -m | ps aux

补充三板斧

  • kubectl get events --sort-by='.lastTimestamp' → 全集群异常全局视图
  • kubectl top pod/node → 实际 CPU/内存使用量(需 metrics-server)
  • kubectl port-forward pod/xxx 8080:8080 → 本地直连 Pod 调试

kubectl 常用命令速查

bash 复制代码
# 资源查看
kubectl get pods -o wide              # Pod 列表 + Node/IP 信息
kubectl get pods -A | grep -v Running # 找出所有异常 Pod
kubectl describe pod <name> -n <ns>   # Pod 详情 + Events
kubectl logs <pod> -f --tail=100      # 日志跟踪

# 操作
kubectl scale deployment/xxx --replicas=5   # 手动扩缩容
kubectl rollout undo deployment/xxx        # 回滚到上一版本
kubectl rollout history deployment/xxx     # 查看历史版本
kubectl edit deployment/xxx                # 在线编辑 YAML

# 调试
kubectl exec <pod> -- env                  # 查看环境变量
kubectl cp <pod>:/path/file ./file.txt     # 从 Pod 复制文件

七、容器排错案例:OOMKilled

症状

kubectl describe pod 的 State 显示:

yaml 复制代码
Last State:   Terminated
Reason:       OOMKilled
Exit Code:    137

137 = 128 + 9(SIGKILL)------是内核 OOM Killer 杀的。

排查思路

bash 复制代码
① kubectl top pod → 看实际内存用量,是否接近 limit 且持续增长(内存泄漏?)
② kubectl logs <pod> --previous → 看上一次崩溃前日志
③ Java 应用 → jmap -histo <pid> 看对象分布(在容器崩之前 exec 进去采样)
④ 检查 spec.containers[].resources.limits.memory → limit 是否设太低?

根因分类

根因 特征 解决
内存泄漏(最常见) 内存持续增长 静态集合/ThreadLocal/连接池排查
Limit 设太小 正常业务峰值就超了 压测确定 peak × 1.2
JVM 不知道容器限制 heap > container limit JDK 10+ 启用 -XX:+UseContainerSupport
大对象突发 某次请求加载巨量数据 业务代码改流式处理

🔑 JDK 版本与容器内存的关键认知

JDK 8 的 JVM 默认以宿主机内存为基准计算堆大小,不知道 cgroup 的 memory limit。在 512MB limit 的容器中,JVM 可能试图分配 1GB 堆 → 超过 cgroup limit → OOMKilled。

JDK 10+ 的 -XX:+UseContainerSupport (默认开启)让 JVM 感知 cgroup 的内存限制,以 limit 而非宿主机内存为基准。JDK 8 需手动加 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap


八、补充概念速览

NetworkPolicy(网络策略)

K8s 的"防火墙规则"------控制 Pod 之间及 Pod 与外部之间的流量。默认所有 Pod 互通,创建 NetworkPolicy 后变默认拒绝+白名单放行。需 CNI 插件支持(Calico/Cilium 支持,Flannel 不支持)。

PV/PVC/StorageClass

  • PV = 物理存储(NFS/云盘/Ceph,管理员创建)
  • PVC = 存储申请声明("我要 5GB 读写一次",用户创建)
  • StorageClass = 动态创建 PV 的模板(用户创建 PVC → SC 自动创建 PV)

StatefulSet 依赖 PVC 保证 Pod 重建后绑到同一个 PV(有状态服务的持久标识)。

RBAC

ServiceAccount(Pod 的身份标识)+ Role(在 Namespace 内什么能做)+ RoleBinding(把两者绑在一起)。

Deployment vs StatefulSet

Deployment StatefulSet
状态 无状态(Pod 可互换) 有状态(持久标识+持久存储+有序启停)
Pod 命名 随机后缀 nginx-7d8f-abc 有序编号 mysql-0, mysql-1, mysql-2
扩缩容 任意顺序 逆序缩容、顺序扩容
典型场景 Web 应用、API 服务 数据库、消息队列、ZK

九、K8s 集群搭建实战(概要)

以下以 kubeadm 为例展示核心流程,帮助理解集群组件如何协作:

环境准备(所有节点)

bash 复制代码
# 关闭 Swap(kubelet 强制要求)
swapoff -a && sed -i '/swap/d' /etc/fstab

# 配置内核参数(bridge 网络所需)
modprobe br_netfilter
cat > /etc/sysctl.d/k8s.conf << EOF
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sysctl -p /etc/sysctl.d/k8s.conf

# 安装组件
yum install -y kubelet kubeadm kubectl
systemctl enable kubelet

Master 初始化

bash 复制代码
kubeadm init \
  --apiserver-advertise-address=<Master IP> \
  --image-repository registry.cn-hangzhou.aliyuncs.com/google_containers \
  --pod-network-cidr=172.20.0.0/16

# 配置 kubectl
mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config

Node 加入 + 网络插件

bash 复制代码
# Node 节点执行
kubeadm join <Master IP>:6443 --token <token> --discovery-token-ca-cert-hash sha256:<hash>

# 安装 Calico 网络插件
kubectl apply -f https://docs.tigera.io/calico/latest/manifests/calico.yaml

验证

bash 复制代码
kubectl create deployment nginx --image=nginx
kubectl expose deployment nginx --port=80 --type=NodePort
# 浏览器访问 http://<任意Node IP>:<NodePort>

⚠️ K8s 1.24+ 已移除 Docker 内置支持,需使用 containerd 作为容器运行时。


十、Helm:K8s 的包管理器

bash 复制代码
# 安装 Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

# 一键部署 MySQL(含 PVC/Service/Secret)
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install my-mysql bitnami/mysql --set auth.rootPassword=root123

# 自定义 values 部署
helm install my-app ./my-chart -f values-prod.yaml

# 回滚
helm rollback my-app 1

价值:避免每次部署手写 Deployment+Service+ConfigMap+Secret+Ingress+PVC 六七个 YAML。Helm Chart 把它们打包为一个"应用",一键安装/升级/回滚。


十一、K8s 版本升级注意事项

K8s 版本演进快,以下关键变化影响升级决策:

版本 变化 影响
1.24 移除 Docker 内置支持 必须迁移到 containerd/CRI-O
1.25 移除 PodSecurityPolicy 改用 Pod Security Admission
1.29 移除 in-tree CephFS/RBD 卷插件 迁移到 CSI 驱动

升级铁律 :逐版本升级(1.24→1.25→1.26→...),不可跨多个大版本。升级前用 kubectl convert 检查 API 废弃情况。


核心要点回顾

容器 vs 虚拟机 ------容器是 Linux namespace(进程隔离)和 cgroup(资源限制)的组合,共享宿主机内核,启动秒级、占用 MB 级。镜像分层 每条 Dockerfile 指令产生一个只读层,核心收益为共享复用、构建缓存、增量传输。K8s 集群 采用 Master/Node 架构------apiserver 统一入口、etcd 存储状态、scheduler 调度 Pod、controller-manager 持续调谐;每个 Node 上的 kubelet 管理容器健康、kube-proxy 维护网络规则。Pod 调度分两步:过滤(排除资源不足/污点/端口冲突的 Node)→ 打分(LeastRequestedPriority/BalancedResourceAllocation/ImageLocality 加权求和)。

HPA 公式 ceil(当前副本 × 当前指标/目标指标),冷却时间防止抖动,生产延迟约 1-2 分钟。滚动更新 通过 maxSurge 和 maxUnavailable 控制新旧 Pod 替换速度。排错三板斧 :describe pod 看 Events → logs --previous 看崩溃前日志 → exec 进容器检查。OOMKilled(Exit Code 137 = 128 + 9 SIGKILL)根因四类:内存泄漏、Limit 设太小、JVM 不知道容器限制(JDK 10+ UseContainerSupport 解决)、大对象突发。

集群搭建 以 kubeadm init + kubeadm join 为核心流程,Calico 提供 Pod 网络。Helm 将 Deployment+Service+ConfigMap 等打包为 Chart,一键安装/升级/回滚。版本升级必须逐版本进行,关键变化包括 1.24 移除 Docker 支持、1.25 移除 PodSecurityPolicy。


Docker 解决环境一致性,K8s 解决自动运维------但 OOMKilled 137 告诉你:资源限制设错了,再好的编排也救不了。收藏这篇文章,从镜像瘦身到滚动更新,发布前翻出来逐项对照。
上一篇 :《负载均衡与高可用》 | 下一篇 :《设计模式:GoF经典+SOLID原则》 系列合集掘金Java合集

相关推荐
netCode1 小时前
IDEA使用 Alibaba Cloud Toolkit
后端
foggyprojects1 小时前
业务要给发票明细加 8 个筛选条件,我没有再写 8 个查询接口
后端
爱勇宝2 小时前
人到了一定年纪,才会看懂这些人性真相
前端·后端
阿标在干嘛2 小时前
从RESTful到GraphQL:政策快报平台接口设计的演进
后端·restful·graphql
万少3 小时前
用 TraeWork 给小孩做一个家庭工作台
前端·人工智能·后端
2601_963870183 小时前
【计算机毕业设计】基于Vue + Spring Boot的社区养老服务管理平台设计与实现
spring boot·后端·课程设计
前端开发张小七3 小时前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员
颜进强3 小时前
Calude Code - 23 用 MCP 把 Jenkins 变成 AI 队友:一次对话完成自动化发布
前端·后端
Awna3 小时前
Golang 大小写可见性规范
开发语言·后端·golang