
前段时间朋友拉我帮忙看一个线上故障服务在Kubernetes集群里起来又崩崩了又起日志翻半天发现是存储目录没挂上导致初始化失败。再一查镜像里连时区都没配日志时间全是UTC排查问题的时候对不上序。这还算是小的另一个团队更离谱PVC名字不对数据直接写到了别的实例上接口一发布数据清零。我后来总结了一下大多数人学Kubernetes喜欢一上来研究控制器、调度器、CRD这些大词反而把最贴近日常交付的四个基本功给丢了——DockerFile、数据持久化、网络模式、资源配额。这篇就是围绕这四个主题做一次专项复盘。不聊空概念只讲我在实际项目里怎么把一套业务应用完整跑在Kubernetes上从镜像构建到数据落地从流量入口到资源管控中间踩过的坑、验证过的思路都会顺着这条主线展开。不管你是刚开始接触Kubernetes还是已经能让Pod跑起来但对底层逻辑总觉得隔着一层这篇的内容应该都能对得上。1. DockerFile写一份不会“坑自己人”的镜像构建文件1.1 指令背后的执行逻辑先搞懂这一层DockerFile是Docker构建镜像的“剧本”但很多人写DockerFile只停留在“会用几个指令”的水平一旦遇到构建失败、镜像超大、启动异常就完全靠猜。我建议先把它的执行模型刻在脑子里DockerFile里的每一条指令都会生成一个镜像层镜像是一层一层叠起来的洋葱结构。这个模型带来的直接后果就是缓存机制。Docker构建时会逐条执行指令如果某条指令之前的指令都没变它就直接复用之前构造的缓存层不再重新执行。这就解释了为什么很多人改动代码后重新构建发现镜像秒出——因为COPY代码那一步之前的所有层都命中了缓存。反过来如果你把COPY . .写在前面、安装依赖RUN pip install或者RUN npm install写在后面那每改一次代码依赖层也要重新构建一次整个过程慢得要命。正确的顺序是把“轻易不变的东西”放前面“频繁变动的东西”放后面。另一个值得重视的是RUN指令的层数。有些人写DockerFile习惯一个命令一个RUN比如RUN apt-get update RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*这三个RUN会生成三个镜像层而前面已经提过每一层都是基础镜像之上的一次完整存储差异。层数越多镜像越大拉取越慢。我见过一个镜像光安装工具就把镜像做到2GB就是因为每一条RUN都独立成层。正确的做法是用把同一逻辑串在一起确认目录清理也放在同一条指令里减少中间状态残留。还要注意.dockerignore。很多人忽略了它结果把.git目录、node_modules、target、__pycache__全部COPY进构建上下文。小项目看不出问题一旦代码仓库大了打包时传输上下文的耗时能拖到分钟级别。这和git的.gitignore是一个逻辑只把构建需要的东西送进docker daemon别把仓库当U盘整盘拷贝。1.2 从源码到镜像一个生产级DockerFile的完整写法我以一个Spring Boot服务为例结合多阶段构建讲清楚生产环境的写法。先看最终建议的DockerFile# 第一阶段编译 FROM maven:3.8-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 第二阶段运行 FROM openjdk:11-jre-slim RUN groupadd -r app useradd -r -g app app WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar USER app EXPOSE 8080 ENV TZAsia/Shanghai ENTRYPOINT [java, -Xmx512m, -jar, app.jar]这里用的就是多阶段构建。第一阶段用maven镜像完成编译里面包含JDK、Maven以及各种编译工具体积非常大第二阶段只从第一阶段拷一个jar包运行环境换成精简版JRE。这样做的好处是最终镜像只保留运行必需的组件大小能从800MB降到180MB左右安全面也小很多。注意几个细节。COPY pom.xml .和RUN mvn dependency:go-offline -B分开写是为了先把依赖下载做成一层这样不改pom.xml的时候依赖层永远命中缓存只有真正改代码才会执行打包。我实测下来这种写法在频繁迭代时的构建速度比“把源码全部COPY再一步编译”快很多尤其是依赖多的Java项目。USER app是必须的。默认情况下容器以root身份运行这带来的风险在Kubernetes集群里会被放大——一旦容器被打穿攻击者直接拿到的就是节点root权限。所以在DockerFile里创建专用用户、切换到非root运行是生产环境的硬性要求。ENV TZAsia/Shanghai解决的是日志时区问题不设置这个容器日志全是UTC时间对排查线上问题非常不友好。ENTRYPOINT和CMD的区别也值得说清。ENTRYPOINT是容器的固定入口CMD传默认参数两者配合时CMD会被追加为ENTRYPOINT的参数。如果只写CMD [java,-jar,app.jar]那docker run后面追加的任何命令都会把整条CMD覆盖掉比如你想进容器调试时会发现启动命令不是你想的那样。把固定部分写进ENTRYPOINT参数部分留给CMD或运行时指定逻辑更清晰。1.3 初看没问题、实则埋雷的几种写法先说基础镜像tag。很多人写FROM centos或FROM ubuntu不带版本tag这等于告诉构建系统“给我最新的”但最新不确定哪天就变了。我见过一次构建环境里因为基础镜像更新导致老项目突然出问题的案例。生产环境的规范是锁定明确的tag甚至锁定镜像摘要digest保证每次构建的基础环境完全一致。再说ADD和COPY。这两个指令行为上很接近但ADD多了一个特性如果源是本地tar压缩包会自动解压。这个特性听起来方便实际使用中经常造成意外——你只想把一个压缩包放进镜像结果它自己解压了目录结构完全不是预期的。我的习惯是复制文件一律用COPY只有明确需要自动解压时才考虑ADD。这也和社区的最佳实践一致。还要注意构建密钥别写进镜像。有些人图省事在DockerFile里把私有仓库的账号密码、SSH私钥直接写进RUN里构建成功之后镜像里就能翻出来。即使后来删掉了这一层前面有这一信息的层仍然存在于镜像历史里。正确的做法是使用BuildKit的--secret功能或者构建时通过参数注入让敏感信息只存在于构建过程不进最终镜像。2. 数据持久化让数据在Pod“死亡”后依然存活2.1 从emptyDir到PV/PVC存储抽象是怎么一层层演进的容器里的数据有个让人头疼的特点Pod一删容器重新创建之前写在容器里的文件就全没了因为容器本身设计就是“无状态随时可扔”。所以Kubernetes给出了一整套存储抽象核心要理解的是emptyDir、hostPath、PV/PVC/StorageClass这三层。emptyDir是最简单的卷Pod运行期间存在Pod被删除时数据也跟着消失。它适合什么呢同Pod内多个容器共享临时文件、缓存、日志暂存这类场景。比如一个Pod里跑nginx和一个日志采集sidecarnginx把日志写到emptyDirsidecar从同一个目录读走上传这个模式很合适。但如果你指望emptyDir保存数据那就大错特错了。hostPath是把宿主机的一个目录直接挂给Pod数据确实可以持久化但有个严重的副作用Pod被调度到哪台节点数据就在哪台节点。如果Pod重新调度到另一台机器数据就找不到了。而且hostPath本质上绕过了Kubernetes的存储抽象给多节点集群带来了很大的限制。真正用于持久化场景的是PVPersistentVolume和PVCPersistentVolumeClaim这套体系。打个比方PV是仓库里实实在在的一块硬盘PVC是你向仓库申请硬盘时填的一张申请单StorageClass则是仓库管理员根据申请单自动调货的自动化系统。应用不需要关心PV到底是云盘、本地磁盘还是NFS只要声明“我要多大容量、什么样的读写模式”Kubernetes负责把合适的有意思卷分配给它。2.2 动态供给与StorageClass让“发存储”从手工变成自动对小型集群来说管理员手动创建PV再让PVC去绑定是可行的但在稍微大一点的集群里这种方式就撑不住了。所以推荐默认使用动态供给也就是定义好StorageClass让系统在PVC创建的时候自动去云平台或其他存储后端创建PV。一个典型的StorageClass定义apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: kubernetes.io/aws-ebs parameters: type: gp3 fsType: ext4 reclaimPolicy: Delete volumeBindingMode: WaitForFirstConsumerprovisioner决定了谁来真正创建卷。在自建机房或测试环境里比较成熟的方案是NFS类的provisioner比如nfs-subdir-external-provisioner它能按照“PVC名字加命名空间再加随机后缀”的方式自动在NFS服务端创建目录并生成对应的PV实现一个“自动发存储”的效果。这里面有个参数容易被忽略reclaimPolicy。它决定PVC被删除后PV怎么处理Delete表示自动删除整个存储卷Retain表示PV保留下来交给管理员手动处理。生产环境如果对数据安全性要求高建议设置Retain——虽然得手动清理但至少数据不会被一条删除PVC的请求连带销毁。我有一次清理测试环境一个Delete策略的StorageClass直接把两台NFS上的备份目录删了从那以后我养成习惯存储策略宁可保守也不要图省事。WaitForFirstConsumer也是实践里很关键的一项。默认的Immediate模式会在PVC创建时立刻创建存储卷但如果Pod还没调度系统并不知道数据会落在哪台节点上这在本地卷场景下可能把卷建到了错误节点。用了WaitForFirstConsumer之后存储卷会等到Pod完成调度、明确了节点归属后再创建这能避免大量“卷在A节点、Pod在B节点”的尴尬情况。2.3 有状态应用实操MySQL挂载与扩容的真实踩坑记录有状态应用接持久化最常见的就是MySQL。我之前在测试环境搭过一套MySQLPVC的YAML大概长成这样apiVersion: v1 kind: PersistentVolumeClaim metadata: name: mysql-data spec: accessModes: - ReadWriteOnce storageClassName: nfs-client resources: requests: storage: 10Gi然后MySQL的Pod部署里通过volumes和volumeMounts把PVC挂到容器内的/var/lib/mysql目录。这个流程很多文档都有但实际操作中容易栽在几个地方。第一个是目录权限。NFS动态供给创建出来的目录默认属主是root而MySQL官方镜像启动时会用mysql系统用户去写数据目录。结果就是容器反复启动失败日志显示Permission denied。解决方法是初始化时修复目录权限或者通过initContainers先以root身份执行一遍chown。这个坑看起来很基础但团队里三个不同的人先后踩到过因为它只在动态供给NFS环境下出现本地测试时一切正常。第二个坑是扩容。PVC创建时定了10Gi后面数据涨上去了想扩容。如果用的是传统NFS provisioner很多早期版本不支持在线扩容改PVC的spec.resources.requests.storage不会生效需要重新迁移数据再建新的PVC。后来的新版本可以通过调整PVC大小自动调用底层存储扩容但前提是StorageClass里配置了allowVolumeExpansion: true。这个参数默认不开而且有些后端根本不支持所以上线前确认好“这个存储能不能扩容”很重要不然数据量一上来就只能做冷迁移。第三个是想提醒的备份和持久化是两回事很多人以为数据落盘了就高枕无忧了。PVC保证了Pod重建后数据还在但不等于防止误删、防勒索、防存储后端故障。我现在的做法是数据库类应用在PVC之上再加一套定时逻辑备份定期用mysqldump导出并传到对象存储或者备份服务器这个双保险救了不止一次。3. 网络模式从Pod IP到Service的访问链路3.1 Service的三种类型怎么选才不纠结Kubernetes网络里Pod的IP是“短命”的Pod一旦重启IP就变了。为了让客户端稳定访问一组PodKubernetes抽象出了Service它像一个稳定的“前台电话”你只管打这个号码Service把请求转发给后面对应的一组Pod。Service类型最常用的三种ClusterIP、NodePort、LoadBalancer。ClusterIP是默认类型Service会获得一个集群内部IP只能在集群内部访问。适合内部服务之间的调用比如后端API调用Redis、订单服务调用用户服务。NodePort会在每一台节点上开一个高位端口默认范围30000-32767这样集群外部就可以通过“节点IP端口”访问到Service。LoadBalancer在云平台上会调用云厂商的负载均衡服务分配一个公网IP外部流量通过这个IP进来再流转到后端Pod。这里要理解kube-proxy的转发模式。在多数发行版实现里kube-proxy会把Service的虚拟IP翻译成后端的Pod IP常用的实现有iptables和IPVS两种。iptables模式为每个Service生成一系列规则连接数多的时候规则链长性能瓶颈明显IPVS模式工作在Linux内核态用哈希表做负载均衡规则数量大时仍能保持高性能。我在集群规模到了上百个Service时实测过iptables的转发延迟波动明显变大切成IPVS之后稳定很多所以新集群我基本都会在安装时就设置好kube-proxy的mode: ipvs。还有一个藏在细节里的参数externalTrafficPolicy。默认值是Cluster意思是流量进入节点后再被转发到集群内任意一个后端Pod期间会额外做一次SNAT导致后端Pod看到来源IP是中间节点的IP拿不到真实的客户端IP。如果业务需要记录来源IP比如审计、限流就要设置成externalTrafficPolicy: Local。这个设置保证流量只在进入的那个节点上转发给本节点上的Pod不做额外转发用户可以拿到真实IP但代价是如果流量进了没有Pod的节点连接会被丢弃。怎么缓解依赖负载均衡云产品做健康检查只把流量路由到有Pod的节点或者搭配topologyKeys做区域优先调度。3.2 服务发现、Ingress入口与hostNetwork的真实使用场景集群内部服务之间互相访问靠的是DNS。Kubernetes内置的CoreDNS会对Service名.命名空间.svc.cluster.local这个域名做解析。所以处在同一个命名空间的Pod直接写服务名就能互通跨命名空间则加命名空间名比如redis.cache.svc.cluster.local。这套机制让微服务之间不需要硬编码IP服务重建、IP变化都不影响调用方。我之前接手过一个项目里面还在用ConfigMap写死IP做服务发现每发布一次都要改配置我非常建议把它彻底迁移到CoreDNS方案上。集群外部访问HTTP服务通常不用NodePort直接暴露而是走Ingress。Ingress可以理解成“集群内部的七层nginx入口网关”它的核心是Ingress Controller比如nginx-ingress-controller而Ingress资源只是一套转发规则。一个看起来简单但很重要的原则Ingress本身不会监听端口真正接收流量并转发的是Ingress Controller这个Pod。所以你得先把Ingress Controller暴露出来通过LoadBalancer或NodePort再创建Ingress规则。实际工作中我会在同一个集群里至少开两个Ingress Controller实例一个是internal仅集群内网访问一个是public公网流量入口用ingressClassName区分。这样内部管理后台、接口文档走内网入口不暴露公网业务流量走公网入口。从安全角度来说这个隔离比把所有服务都挂公网Ingress可靠得多。hostNetwork是另一个容易被误用的“网络模式”。它让Pod直接使用宿主机的网络栈不经过CNI插件分配的虚拟网卡。在哪类场景会用到比如一些对网络性能极其敏感的中间件、需要绑定固定主机端口且不能做端口转发的系统组件。但代价也很大Pod失去独立网络命名空间端口和宿主机完全共享如果同一个节点上两个Pod都配置hostNetwork且监听相同端口就会冲突。而且hostNetwork绑定的是宿主机IP调度时如果没处理好很容易出现端口占用问题。我之前在测试环境为了让Pod吃到真实客户端IP试过用hostNetwork跑nginx结果被端口冲突折腾了一上午后来还是老老实实换成externalTrafficPolicy: Local干净又省心。3.3 我自己常用的“按场景选网络配置”对照表根据实际项目经验我整理了一张对照表场景推荐配置原因集群内部服务互通ClusterIP CoreDNS稳定、隔离性好不暴露外部外部HTTP/HTTPS流量LoadBalancer Ingress集中入口管理支持路由、TLS、限流公网或跨网段直连NodePort 外部负载均衡适合测试环境或没有Ingress Controller的场景需要客户端真实IPexternalTrafficPolicy: Local避免SNAT保留真实来源IP网络性能要求极高组件尽量不用hostNetwork用性能型CNI端口冲突成本高除非必须否则不推荐StatefulSet稳定域名headless ServiceClusterIP: None每个Pod拥有稳定DNS名称如mysql-0.mysql.default.svc最后补充一下headless Service。把clusterIP设为NoneService就不再做负载均衡而是直接返回每个Pod的DNS记录。配合StatefulSet时每个Pod会得到一个固定且可解析的域名比如mysql-0、mysql-1数据库主从、爬虫分片这类“每个实例都要有名字”的应用就很吃这一套。我搭过的一套主从复制MySQL就是靠这个特性解决实例发现问题的不用额外写服务注册逻辑。4. 资源配额把集群资源管起来4.1 requests和limits到底影响什么别再只当“配置项”背资源配额的第一课是把requests和limits的含义彻底搞清楚。这两个词在Kubernetes里代表完全不同的东西requests是“最低保障”用于调度器决定把Pod放在哪台节点上limits是“最高上限”用于运行时限制Pod最多能用多少。它们不是同一件事很多线上事故都源于混用。先看调度。调度器在为Pod选择节点时会看每台节点上已经分配出去的requests总和再跟节点总容量做比较。只有节点剩余可分配容量大于新Pod的requests才会把这个Pod调度过去。所以requests不写或者写得过低调度器会认为这个Pod“饭量很小”把一堆Pod塞到同一台节点上运行期实际负载一上来整台机器就超卖了。这类“Allocatable资源充足但节点响应越来越慢”的问题我遇到过好几次根因往往都是节点上跑的Podrequests设得太低。再看limits。CPU是“可压缩资源”Pod超过CPU的limits时系统会对它做CPU节流throttle表现为应用偶尔变慢但不会死内存是“不可压缩资源”超过limits后没有节流这种缓冲直接被内核OOM Kill掉Pod进程被杀、容器重启。所以对于内存limits必须认真设置它不是“写一个好看的数”而是“内存一旦到这个数你会死”。有一种很常见的情况JVM应用把-Xmx设成1GB但容器limits只给512MB结果JVM还没跑起来就被内存超限杀掉了。设置limits时必须先搞清楚应用本身对资源的需求反过来也是一样。requests和limits合在一起还把Pod划分成了三类QoS服务质量Guaranteedrequests等于limits、Burstablerequests小于limits、BestEffort都不设置。当一台节点内存不足时Kubernetes会优先杀BestEffort最后才动Guaranteed。所以重要服务尽量设置成Guaranteed即requests和limits写一样这样节点资源紧张时它最不易被回收。4.2 ResourceQuota与LimitRange命名空间级的“闸门”和“默认值”单Pod维度的资源设置解决了“单兵作战”的问题但集群是多人共享的某个部门创建一堆不设requests/limits的Pod整个集群资源照样被吃光。这时就要用到ResourceQuota资源配额和LimitRange范围限制。ResourceQuota是加在命名空间上的“总预算”比如限制这个命名空间内的CPU总和不超过8核、内存总和不超过16Gi、PVC数量不超过20个、Service数量不超过10个。企业项目里给每个团队划分命名空间时配一套合理的ResourceQuota是刚需不然一个测试任务就可能把整个生产集群的资源挤占干净。LimitRange则负责在Pod/PVC没有显式设置资源值时给它们塞一个默认值。这能减少“写了很多YAML但忘配资源”的情况。按照幂等和零信任的思路我习惯在共享集群里对每个命名空间同时启用ResourceQuota和LimitRange前者卡总量后者兜底默认值让“不写资源的Pod”直接创建失败或者自动获得中等的资源声明而不是成为集群里的隐形地雷。一个经常会被忽略的地方ResourceQuota只限制“已创建的资源”不限制节点真实负载。也就是说配额满了一个Pod也建不进去但即使每个Pod的requests都设得合理节点短期流量突增仍然可能造成CPU饱和。所以“配额管控”和“HPA自动扩缩容”要配合使用配额保障上限安全HPA根据真实负载调整副本数两者缺一不可。4.3 一次OOMKilled排查记录从现象到根因说一个真实案例。线上一个Java服务本来很稳某次发布后开始频繁OOMKilled容器反复重启。直觉上肯定是内存不够但奇怪的是这个服务改动不大怎么会突然吃这么多内存我一步步排查。先查YAML里的资源设置发现limits.memory设的是1Gi而JVM参数-Xmx是512MB照理说足够。再看监控面板内存使用曲线在发布后突增稳定超过900MB。问题出在哪后来想到Java应用在容器里的一个经典陷阱JVM在没识别到容器限制的环境里默认堆大小是物理内存的四分之一甚至直接用容器所在节点总内存的1/4来计算。如果基础镜像里的JVM版本较老或者没开启容器内存感知-XX:UseContainerSupportJDK 8u191默认开启JVM就会按宿主机内存大小来分配堆直接把容量干爆。解决方式是双管齐下一是升级基础镜像到JDK 8u191以上的版本确保JVM启用容器感知二是显式设置-Xmx和-XX:MaxRAMPercentage让JVM在容器limits范围内自我约束。我把limits.memory调高到2Gi并把JVM的-XX:MaxRAMPercentage70.0配上之后情况立刻稳定。之后我把这个参数写进团队标准的DockerFile模板作为一条强制规范。这件事给我的教训是资源配额的意义不只是设置几个数字而是要让“Kubernetes的资源声明”和“应用自身的运行时参数”保持一致口径。镜像里JVM内存参数是1GBKubernetes limits写2GBPod就会在“自认为可以吃到2GB”的JVM和“实际上限2GB”的容器之间反复拉扯。先统一口径再谈优化。关于节点设备扩展资源补充一句。如果业务里有GPU、FPGA或者某种专用的加速卡需要用Device Plugin机制把这种资源登记到节点上让调度器像分配CPU一样分配它们。常规Kubernetes安装不会自动识别这些设备必须单独部署对应的device plugin组件。这个属于进阶玩法但思路和资源配额是一脉相承的任何想在集群里被调度、被限制、被统计的资源都要以标准化的方式暴露出去。结尾我的一点实际体会这套内容看起来是四个独立主题实际把它们串起来的只有一件事Kubernetes的价值在于抽象而抽象要落地靠的是规范的使用方式。DockerFile决定了你交付的镜像长什么样数据持久化决定了你的数据会不会跟着Pod陪葬网络模式决定了流量能不能准确找到目标资源配额决定了集群会不会被某个应用拖垮。我踩过太多因为只懂其中一块而在别处翻车的坑所以强烈建议把这一整条链路拉通来看而不是今天补一个DockerFile明天再研究一下PVC。最后分享一个小技巧给集群里的资源对象都打上规范的owners标签比如app.kubernetes.io/name、app.kubernetes.io/managed-by。排查问题的时候光是“这个PVC是谁的、这组Pod属于哪个应用”就能帮你省下很多时间。再配合上文档里记录的StorageClass策略、Ingress入口约定这套基本功才能真正变成你日常战斗力的一部分。