容器与 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 undo1 秒完成) - 健康检查 :
livenessProbe(活没活,死了重启)vsreadinessProbe(准备好没,未就绪不切流量)------前者是存活兜底,后者是流量门神
三、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合集