第一次用kubeadm搭Kubernetes集群是v1.26.0那个版本的事情。当时初始化流程干净利落终端里先是[init] using kubernetes version: v1.26.0然后一串[preflight] running pre-flight checks把swap、cgroup、端口占用这些挨个检查一遍一个基础集群半天就能拉起来。但等真正开始在上面跑业务应用我才发现建集群只是入场应用能不能稳定运行反而取决于四件看起来很基础的事——DockerFile写没写对、数据持久化方案选没选好、网络模式搞没搞明白、资源配额设没设完整。这四块单独拎出来都不难但它们会串起来出问题镜像没打好Pod起不来存储没规划数据说没就没Service类型和CNI选型没搞懂流量根本到不了应用配额放任不管一个异常容器就可能拖垮整台Node。这篇文章就把这四条主线逐一拆开从原理讲到生产环境的取舍再穿插一些我实际踩过的坑。适合刚开始系统学Kubernetes、准备把应用容器化上云的同学也适合已经在集群上跑应用、回头补基础的人。1. DockerFile镜像构建是应用上K8s前最容易被忽视的关卡很多人学Kubernetes的时候一头扎进YAML觉得镜像嘛能跑就行。我的看法正好相反K8s里90%的“应用起不来”问题根子都能追溯到DockerFile写得不严谨。镜像不光是代码的打包产物它直接决定了你的应用在节点上以什么身份、什么进程模型、什么资源开销运行。K8s只能按你给的镜像去调度它不会替你把坏镜像修好。1.1 一个生产级DockerFile的完整结构多阶段构建实例先放一个我常用的Node.js服务DockerFile做示例。这类应用网上教程最多但也最容易写成“能跑就行”的版本# 第一阶段构建依赖 FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction COPY . . # 第二阶段精简运行环境 FROM node:20-alpine WORKDIR /app ENV NODE_ENVproduction RUN addgroup -S appgroup adduser -S appuser -G appgroup COPY --frombuild /app/node_modules ./node_modules COPY --frombuild /app . USER appuser EXPOSE 3000 CMD [node, server.js]为什么非得拆成两个阶段说白了就是“建房子先搭脚手架盖完楼再拆脚手架”。第一阶段用完整的构建环境装依赖、复制源码、执行编译第二阶段只把第一阶段产生的产物拿过来构建过程中那些编译器、包管理器、缓存文件全部留在上一个镜像层里不进最终镜像。最后体积小、攻击面小跑起来也更干净。中间几个细节值得多说一句。addgroup和adduser那段是为了让容器以非root用户运行。很多人忽略这点容器里默认root一旦应用被攻破对方直接就是宿主机的root权限。K8s侧还有securityContext.runAsNonRoot可以配合强制校验但镜像层面先把用户建好这是第一步。另外如果你的服务是Java这类需要完整运行时的多阶段构建的思路完全一样只是第一阶段换成JDKMaven第二阶段用JRE或者直接只拷贝jar包。第二阶段的语法是COPY --frombuild /app/target/app.jar /app/app.jar。用npm ci而不是npm install也是个小细节npm ci会严格按lock文件安装保证构建可复现而且速度更快不会偷偷升级依赖版本造成“本地好好的镜像里就炸了”的灵异现象。1.2 容器启动立即退出的真实排查ENTRYPOINT与CMD的坑“Pod创建成功但一直CrashLoopBackOff”是排障时最常见的问题而且很多情况下kubectl logs里什么都没有。这时候你需要先理解容器生命周期的一个底层逻辑容器主进程一旦退出容器就停止。Kubernetes里的restartPolicy如果配的是Always就会一遍遍重启最终表现为CrashLoopBackOff。我遇到过一个很典型的案例。一个Java应用更新后Pod反复重启日志一点异常都打不出来describe容器时发现exit code是0正常退出。最后排查到镜像里的启动脚本发现脚本内容是java -jar /app/app.jar 问题就在那个。它把Java进程放到了后台启动脚本所在的主进程立刻执行结束并退出。容器只看主进程主进程没了整个容器就跟着退出哪怕后台Java进程还活着。正确做法是去掉让Java进程直接占据前台。在K8s语境里任何把进程往后台推的操作都会让Pod变成“起一下就死”的状态。DockerFile的CMD指令也一样别写成CMD nohup node server.js 这类形式除非你用了成熟的进程管理工具如tini、s6来当主进程。还有个高频误区是CMD和ENTRYPOINT的关系。DockerFile里CMD [node, server.js]这种写法叫exec形式它会直接作为PID 1进程启动信号处理正常是推荐写法。写成CMD node server.js则是shell形式实际执行的是/bin/sh -c node server.jsPID 1变成sh屏蔽了SIGTERM等信号优雅停机可能失效。到K8s里还有一层覆盖机制Deployment YAML中spec.containers[].command覆盖DockerFile的ENTRYPOINTargs覆盖CMD。很多人以为K8s里改command只是“附加参数”实际上是整体替换镜像里原本的启动逻辑全都不算了排查问题时要先意识到这一点。1.3 镜像体积、缓存策略与K8s拉取速度的连锁反应镜像体积和K8s的关系比“占磁盘”要深远得多。节点启动Pod前必须先拉镜像镜像拉了多久Pod就在Pending/ContainerCreating卡多久。正常情况下扩容还好集群空闲带宽够但遇到突发的节点故障几十个Pod要同时转移到新节点新节点首次拉取全是冷镜像1GB和150MB的差别就是几十秒和几分钟的差别线上响应慢了半个多小时都是常事。一个我自己经历的瘦身案例一个内部Java服务最初把Maven仓库、JDK整个装进镜像体积1.4GB改多阶段构建后只留JRE和jar压到220MB启动时间肉眼可见地缩短了。对于Java这类天然就需要运行时的应用做不了Go那种几MB的静态二进制但1.4GB降到220MB是任何团队都能做到的没有借口。基础镜像选型我一般按这个表来基础镜像特点典型体积适用场景scratch / distroless无包管理器近乎空系统几MB到几十MBGo、Rust等静态编译二进制alpine基于musl libc极简约5MB起Python、Node.js等脚本型服务debian slim基于glibc精简版约80MB起Java、依赖原生库的组件ubuntu / centos完整工具链150MB以上需要系统调试工具的运维型镜像选alpine要留个心眼它用的musl libc和大多数编译环境下的glibc不是一回事某些Node.js原生模块、Java本地库在alpine上可能编译过、跑不起来。碰到这种就老实换debian slim别硬扛。镜像层的缓存策略也值得讲。每一行RUN都会产生一个镜像层构建时会逐层检查缓存。层数不是越少越好为了“压层数”把十几个命令串成一个巨型RUN会导致依赖一变整条缓存失效。常规做法是把最不容易变的部分放前面。把COPY package*.json和RUN npm ci放在COPY . .之前就是利用缓存——代码改了依赖层没改构建引擎直接复用依赖层构建速度能快一个量级。提示生产环境永远不要直接用latest镜像tag。K8s有imagePullPolicy如果tag是latest或省略节点每次都尝试重新拉取而固定版本号的镜像只要本地有就不会频繁触发拉取。更重要的是latest会让“回滚”变成玄学——你根本不知道上一个版本是哪个镜像。2. 数据持久化Pod可以重启数据不能跟着消失Kubernetes的Pod设计哲学是“可丢弃的”。Pod可能因为节点故障、发布更新、资源竞争被删除或重新调度它天生不应该承载有状态的东西。但真实业务总需要保存数据于是就有了数据持久化这条独立于Pod生命周期的技术链。很多新手栽在这块根本原因还是没有理解存储的生命周期应该跟Pod解耦。2.1 先认清两个临时方案emptyDir与hostPath的边界Pod里挂存储最直接的是emptyDir它就是一个随Pod创建而创建、随Pod销毁而清空的临时目录。它有两个用处我没有找到更好的替代一个是同Pod多容器之间共享文件比如主容器写缓存sidecar容器读出来上报另一个是给容器做临时溢出和日志暂存。因为它本质上就在节点本地磁盘上速度快但不具备任何持久性。把业务数据库放emptyDir这种事等于默认你随时可以丢掉全量数据。hostPath则是把宿主机指定目录直接挂进Pod。它的宿命感很强Pod被调度到哪个节点用的就是哪个节点的目录如果Pod被重新调度到另一台节点数据完全不可见。多副本Pod同时写同一hostPath目录时还会出现互相覆盖的竞争问题。hostPath的合理场景在我看来只有两个DaemonSet形式的日志采集、监控探针它们本来就要求每节点一份还有一种是为Pod提供节点自身的配置或设备文件。常规业务数据能躲就躲。2.2 PV/PVC存储生命周期从Pod生命周期里解耦真正撑起持久化的核心机制是PV和PVC。一句话解释PVPersistentVolume是集群管理员准备的一块存储资源PVCPersistentVolumeClaim是用户对存储资源提出的申请。类似租房市场PV是“房源”PVC是要约“合同”Kubernetes控制平面是中介负责把满足条件的房源和合同配对。业务方根本不需要关心PV是谁、底层存储在哪只需要在PVC里声明“我要10GB要能同时被多个Pod读”系统就会自动匹配一个符合要求的PV并绑定。apiVersion: v1 kind: PersistentVolume metadata: name: pv-nfs-001 spec: capacity: storage: 10Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain nfs: server: 192.168.1.100 path: /exports/data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-data spec: accessModes: - ReadWriteMany resources: requests: storage: 10GiPod使用PVC时用volumes声明PVC名字再挂到容器路径即可。关键收益是Pod删除、重建、被调度到别的节点PVC还在存储底层也还在新Pod可以像什么都没发生一样把数据重新挂上去。数据持久化的本质不是“把数据放进容器”而是“让容器挂上不随Pod消失的数据卷”。这里要特别解释accessModes。它有三种ReadWriteOnce单节点读写、ReadOnlyMany多节点只读、ReadWriteMany多节点读写。最容易踩坑的是RWO它的语义在大多数块存储上是“同一时刻只能被一个节点挂载”而不是“只能被一个Pod使用”。如果两个Pod被调度到同一节点理论上可以共享同一个RWO卷但跨节点就不行。RSW和RWX针对NFS这类网络文件系统天然支持但NFS的性能上限和单点风险得心里有数。2.3 StorageClass动态供给手动建PV这事为什么该省掉静态PV方案需要管理员提前把存储准备好一条条创建PV然后再让用户去申请。小规模测试没问题规模大了就是灾难——PV数量多、容量难预估、回收策略参差不齐。StorageClass动态供给把这个过程彻底自动化。你只需要在PVC里声明storageClassName系统会调用对应的存储插件云盘、NFS Provisioner、Ceph RBD等实时创建一块存储然后自动生成PV并完成绑定。整个过程对用户透明。apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast provisioner: kubernetes.io/aws-ebs parameters: type: gp3 reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumer生产环境建议把volumeBindingMode设为WaitForFirstConsumer。它的意思是先等Pod调度到某个节点再在这个节点的可用区/区域创建存储。如果省掉这步PV可能被创建在离Pod十万八千里的可用区网络延迟直接拉垮数据库性能。StatefulSet是动态供给的最大受益者。它的Pod各自绑定一份PVC名字带编号比如>apiVersion: v1 kind: Service metadata: name: myapp-svc spec: type: NodePort selector: app: myapp ports: - port: 80 targetPort: 8080 nodePort: 30080LoadBalancer类型则是云厂商的产物创建Service后云环境会自动创建一个外部负载均衡器把公网流量转发到集群内的节点端口。实现原理上它一般是在NodePort外层又包了一层云LB。所以如果你用的是裸机或者自建机房LoadBalancer类型往往无法生效很多team会部署MetalLB这类工具模拟云LB。还有一个细节容易被忽略externalTrafficPolicy。默认Cluster模式下流量进Service后可能被转发到非当前节点上的Pod会多一跳并且丢失客户端源IP。设成Local可以保留源IP但要注意它会把流量固定在收到请求的节点上转发失去均衡性NodePort场景下需要自己评估。3.3 CNI网络插件Flannel和Calico的取舍K8s在安装完成后Pod网络是空的必须装一个CNI插件来提供跨节点连通能力。我在v1.26.0集群上用过的CNI基本是两类Flannel和Calico。Flannel用VXLAN封包技术把Pod流量封装在UDP隧道里跨节点传输配置极为简单几乎一条命令搞定。代价是每个跨节点数据包都要封包、解包性能有明显损耗建连速率和吞吐都会打折扣。适合网络规模不大、性能不敏感、追求快速上手的场景。Calico走的是另一条路线它基于BGP协议直接在节点间路由Pod流量没有VXLAN那层封包开销性能要好不少同时自带NetworkPolicy能力可以精细控制Pod间的访问策略。代价是需要底层网络允许BGP广播配置复杂度更高。更激进的还有Cilium基于eBPF性能更好、可观测性更强但对内核版本有要求团队没有一定功底别轻易上。我的建议一贯是测试环境想省事用Flannel生产环境只要能接受初期配置成本直接上Calico后面加网络安全策略时就会感激当初的选择。如果公司未来要大规模搞零信任网络Cilium值得提前调研。3.4 对外暴露服务的场景化选择以及WSL本地开发环境的差异外部流量怎么进集群取决于你的运行环境和业务形态场景推荐方案理由本地调试kubectl port-forward / NodePort快速、直观、不引入额外组件小规模测试环境NodePort简单但端口管理和安全性差生产HTTP服务Ingress Controller 域名/证书统一入口、TLS终结、路由规则集中管理生产TCP/UDP服务LoadBalancer或NodePort自研LBIngress大多只支持HTTPTCP长连接要走LB云环境生产云厂商LB Ingress Controller高可用、弹性、免运维Ingress值得多说一句它不是一个Service类型而是一组路由规则由Ingress Controller比如ingress-nginx实际执行——Controller本身以NodePort或LoadBalancer形式暴露然后将外部HTTP请求按域名、路径分发到集群内的Service。本地开发环境则有个容易被忽略的变量。如果你像我一样在WSL2里跑minikube、kind或k3sPod对外暴露的方式和WSL2自身的网络模式强相关。WSL2默认是NAT网络模式宿主机Windows和WSL2之间是隔离网段你在WSL2里起的NodePort服务宿主机浏览器不一定直接能访问通常需要做一层宿主机端口映射或者用minikube自带的隧道命令新版WSL支持镜像网络模式Windows和WSL2共享同一网络接口访问方式又会完全不一样。很多人在本地测试时“为什么NodePort打不开”第一反应是怀疑K8s配置实际八成是WSL网络层没打通。先用kubectl port-forward绕过这层把应用逻辑调通了再回来处理端口暴露问题效率会高很多。4. 资源配额没有边界的Pod最容易拖垮整个集群Kubernetes集群是共享资源池假如每个Pod都不设资源上限一台节点上只要出现一个内存泄漏的应用就可能把整台Node的可用内存吃干净同节点的其他Pod全部遭殃甚至触发节点NotReady连锁反应。资源配额不是“性能调优”而是集群存活的基本保障。4.1 requests和limits是两套机制调度与限制不要混为一谈这是K8s资源管理里最重要的概念很多人在这里栽跟头。requests和limits的关注点完全不同。requests是Pod发起的资源“申请”Kubernetes调度器依据它做调度决策。调度器会检查每个Node的已分配requests之和加上新Pod的requests是否超过节点容量。也就是说requests代表“这个Pod至少有这么多资源可用”是调度承诺。它也决定了Kubelet对节点资源的预留水位。limits则是Pod运行时的“硬上限”。CPU是压缩型资源超过limits会被限制执行throttle表现为CPU使用率被打折、RT上升但容器还能跑内存是非压缩型资源一旦超过limits容器会被直接OOMKilled最严重会被整个杀死。如果把集群比作公共食堂requests是你跟食堂说“我至少要吃两碗饭”食堂按这个量备餐limits是厨师最多给你打三碗多一口都不行。但问题在于你如果跟食堂说“我最多吃三碗”却没说至少吃多少食堂不知道给你备多少菜来个人就把锅底掏空——这正是只设limits不设requests的危害调度时没有预留资源运行时却可能超用别人的份。再说requests和limits的关系。requestslimits会得到最稳定的Guaranteed等级服务没有额外竞争力也最容易被保护requestslimits是不合法的配置会直接报错limitsrequests则是Burstable应用可以超用空闲资源但一旦节点压力升高先被回收的就是你。生产环境核心应用我基本都是requestslimits不留“超卖空间”换取稳定性。4.2 QoS优先级OOM驱逐时K8s先杀谁K8s根据requests和limits的配置把Pod划分为三个QoS等级并在节点内存不足时按优先级驱逐QoS等级判定条件驱逐优先级Guaranteed所有容器requests和limits相等CPU和内存都设置最低Burstable至少一个容器的requests低于limits或只设置了其中一个中间BestEffort完全不设置requests和limits最高驱逐顺序是BestEffort先死、Burstable次之、Guaranteed最后。这就意味着如果一台节点内存告急Kubelet优先杀那些没写资源的Pod。你以为“不写资源就万事大吉”实际上是把Pod放在了一个极其危险的优先级上。更隐蔽的是BestEffort的Pod连最基本的资源保障都没有调度器在节点还剩一点点容量时也会把这种Pod塞进去结果就是把节点推向更紧张的边缘反过来又影响Guaranteed的邻居。4.3 ResourceQuota和LimitRange命名空间层面的两道闸门单独给每个Pod设了requests和limits还不够多团队共用集群时必须从命名空间层面做总量控制否则一个团队一口气创建两百个Pod把整个集群的CPU吃光别的团队寸步难行。这就是ResourceQuota和LimitRange的用武之地。ResourceQuota限制的是命名空间内所有Pod的“总量总和”apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: 4 requests.memory: 8Gi limits.cpu: 8 limits.memory: 16Gi persistentvolumeclaims: 5 pods: 20LimitRange限制的是“单容器”或“单Pod”的取值边界同时可以为没写requests/limits的Pod提供默认值apiVersion: v1 kind: LimitRange metadata: name: dev-limit-range namespace: dev spec: limits: - default: cpu: 500m memory: 512Mi defaultRequest: cpu: 100m memory: 128Mi max: cpu: 2 memory: 2Gi min: cpu: 50m memory: 64Mi type: Container细节在于LimitRange的优先级如果Pod自己写了requests/limits以自己的为准如果没写LimitRange里的default和defaultRequest会补上如果Pod的配置超出了max/min直接创建失败。这里有顺序讲究。先上LimitRange再上ResourceQuota是一个比较平滑的路径。因为ResourceQuota在配额耗尽时会直接让新Pod创建失败如果LimitRange还没把默认值兜底一个没写requests的Pod会直接撞到requests.cpu的配额墙上API直接拒绝业务方会一头雾水。4.4 一次Java应用OOMKilled排查经历与生产配额建议我印象最深的一次排障是一个Java应用在limits.memory设为512Mi的Pod里反复OOMKilled。这个配置看起来完全合理——应用平时内存占用400Mi左右留了100Mi余量。可实际上Java虚拟机一启动堆内存只是它总内存占用的一部分Metaspace、线程栈、JIT编译缓存、直接内存这些堆外部分往往被忽略。JVM如果没做容器感知适配会直接按宿主机物理内存的1/4来设默认堆导致Java进程一启动就试图占用远超容器限制的内存还没跑几分钟就被OOMKilled。排障链路可以给新人做个示范先kubectl describe pod看Events确认状态里有OOMKilled而不是普通崩溃再看kubectl logs之前有几行JVM的初始内存日志然后确认dmesg里有没有Out of memory的痕迹最后回到应用配置把-Xmx设置成“容器限额减去堆外内存预留值”。比如容器limit设512Mi-Xmx就设384Mi留128Mi给Metaspace和线程。自从JVM加入了容器感知能力UseContainerSupport默认开启堆分配会参考容器限制但堆外内存依然不受控所以保守设置还是必要的。生产环境的配额建议我总结成下面这张表配置项建议requests.cpu应用日常使用量的基线再加20%缓冲limits.cpu可以等于requests也可以设为基线2~3倍看是否允许突发requests.memory基线使用量 JVM堆外/运行时额外开销limits.memory核心应用建议等于requests避免超用引发邻居受害整节点资源预留建议给节点每个实例都设requests不要把节点资源全分光这里有个很现实的教训requests不能设太大否则调度时会因为节点剩余容量不足而一直Pending。一个只有4C8G的小集群你别上来就写requests.cpu2不然一个Pod就把半个集群包圆了。先跑裸容器用监控工具观察一周负载曲线再把基线定下来别拍脑袋。最后再分享一个我个人的操作习惯新建命名空间之后第一件事永远是先写LimitRange和ResourceQuota再让业务方提交Deployment。因为配额后补必定会有一堆存量Pod在配额之外到时候挨个排查、要求业务方改YAML的工作量比一开始就定规矩大得多。Kubernetes本身不是什么魔法它只是把你写的YAML如实地执行出来。镜像、存储、网络、资源这四块地基打牢了后面的事才会真正省心。如果你看完这篇文章也开始回头检查自己的DockerFile和配额配置那这份经验就算真正落地了。