
1. 先搞清楚Deployment到底在解决什么问题1.1 从Pod到Deployment为什么中间还隔着ReplicaSet很多人刚开始学K8s的时候都会有这个困惑我直接创建一个Pod不就行了为什么非要绕一圈搞个Deployment我当年也踩过这个坑——直接在yaml里写Pod结果Pod一挂业务就断了只能手动去kubectl delete再重新创建折腾几次就明白了单打独斗的Pod在生产环境里根本没法用。Deployment存在的意义本质上就是**“托管”**。它不是一个真正运行着容器的东西而是一个控制器负责声明你想要的最终状态然后由控制器不断对比“现状”和“期望状态”缺了就补多了就回收。Deployment下面管着ReplicaSetReplicaSet再管着Pod这个三层结构很多人觉得多余但正是这层抽象让滚动更新和版本回滚成为可能。ReplicaSet相当于一个版本的“快照”每次你修改Deployment的Pod模板控制器就会创建一个新的ReplicaSet把副本数从0逐步拉起来同时把旧的ReplicaSet慢慢缩到0。这个过程就是滚动更新。而回滚就更简单了——直接把Deployment恢复到上一个ReplicaSet对应的版本就行。1.2 一个最能说明问题的对比场景我给你画个对比场景你就明白为什么需要Deployment了。假设你有个订单服务需要跑3个副本用裸Pod的方式你得维护3份Pod配置任何一个Pod挂了你得人工发现、人工重建。流量进来了少一个副本在服务用户就能感觉到抖动。而用Deployment你只需要写一份replicas为3的声明剩下的交给控制器。Pod被误删了控制器会立刻重新拉起一个。节点宕机了Pod会漂移到别的可用节点上。这背后是K8s的自愈能力而自愈的入口就是Deployment这类工作负载控制器。Deployment适合跑的是无状态的12要素应用——API服务、Web前端、任务Worker、消息消费者这一类。有状态的服务比如数据库、Redis、ZooKeeper建议用StatefulSet因为它能保证稳定的网络标识和持久化存储绑定。这个选型区分至关重要我在生产环境里见过有人把MySQL用Deployment跑结果Pod重建后数据全丢的惨案就是因为没搞清楚两者的边界。2. Deployment核心参数逐个拆解2.1 骨架字段apiVersion、kind、metadata一个标准的Deployment yaml长这样apiVersion: apps/v1 kind: Deployment metadata: name: order-service namespace: production labels: app: order-service version: v1.2.0 spec: replicas: 3 selector: matchLabels: app: order-service template: metadata: labels: app: order-service version: v1.2.0 spec: containers: - name: order-service image: registry.example.com/order-service:v1.2.0 ports: - containerPort: 8080这里有几个容易踩坑的点我逐个说。apiVersion必须是apps/v1这是Deployment稳定版的API。早期用extensions/v1beta1的写法在K8s 1.16之后已经完全移除了我在给老项目做升级的时候就碰见过这种陈年yaml导致无法提交的情况。metadata.name是Deployment的名字这个名字会作为后续创建的ReplicaSet和Pod名称的前缀。注意metadata.labels和spec.template.metadata.labels不是一回事。前者是Deployment自己的标签后者是Pod模板的标签它们可以不一样但spec.selector.matchLabels必须和Pod模板的标签匹配。为什么要强调这个因为一旦Deployment创建成功spec.selector就不可修改了。如果想改selector只能删掉重建Deployment。我遇到过同事改标签时顺手把selector也改了结果Deployment直接报错新ReplicaSet建不出来业务全挂教训很深。2.2 工作负载核心replicas、selector与templatereplicas声明期望的Pod副本数声明了3控制器就会保证有3个健康的Pod在运行。但这个字段不是设置完就不动了。做弹性伸缩的时候HPAHorizontalPodAutoscaler会直接修改Deployment的replicas字段所以我建议线上环境如果有HPA在管理就不要再手动去改replicas否则两边会打架。我在实际运维中碰到过这种情况——HPA根据CPU使用率把副本数从3扩到10结果开发同事手动把replicas改回3HPA检测到期望值被改又立刻扩回去两边来回拉扯Pod频繁创建销毁服务端抖动非常明显。selector的匹配规则看起来简单但有个隐藏细节它支持matchLabels和matchExpressions两种方式。matchLabels是最常用的就是精确匹配键值对matchExpressions可以做In、NotIn、Exists、DoesNotExist这几种操作适合复杂的选择逻辑。不过我的建议是能用matchLabels就别用matchExpressions简洁就是最大的可维护性。Pod模板template里最核心的是containers列表。这里我多说一句镜像版本必须写全不要偷懒写latest标签。生产环境用latest是噩梦——不同的Node上可能缓存了不同时间拉取的镜像版本漂移会让你排查问题时一头雾水。正确的做法是每次发布都打一个不可变的版本号比如v1.2.0并且配合镜像摘要digest来锁定内容。imagePullPolicy如果不写默认规则是镜像标签为latest时用Always否则用IfNotPresent。这个默认行为在发布新版本时有一个坑——如果你在原有tag上覆盖推送了新镜像而节点上已经有旧镜像K8s可能不会重新拉取。所以要么用新tag要么显式设置imagePullPolicy: Always。2.3 更新策略strategy字段里的rollingUpdate与recreateDeployment支持两种更新策略Recreate和RollingUpdate默认是RollingUpdate。Recreate的行为很粗暴先把所有旧Pod全部终止然后一次性创建新Pod。它的优点是部署过程干净不会出现新旧版本同时服务的情况缺点是更新期间服务完全不可用。这个策略适合那种无法平滑切换的场景比如某些需要对数据库做结构变更的初始化任务。但在生产环境的在线服务上谁用谁后悔我见过有人把生产API服务的策略设成Recreate发布时服务直接中断了两三分钟监控告警炸了一屏幕。RollingUpdate才是生产环境的常态它有两个关键参数strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 25%maxSurge表示更新过程中最多可以超出期望副本数的Pod数量maxUnavailable表示最多可以有多少个Pod处于不可用状态。这两个参数是配合使用的理解它们需要一点计算能力。举个例子期望副本数3maxSurge: 25%maxUnavailable: 25%。K8s在计算时会向上取整所以maxSurge是ceil(3 * 0.25) 1意味着最多允许4个Pod同时存在maxUnavailable向上取整也是1意味着最少要保持2个Pod可用。更新过程大致是这样先创建一个新Pod超出期望达到4个等它Ready后缩一个旧Pod再创建一个新Pod周而复始。这样3个副本滚动更新完始终保持至少2个旧Pod在服务整体服务不中断但承载能力是打了一点折扣的。如果业务流量非常大、对容量损耗敏感可以把maxUnavailable设为0maxSurge设为1先起新版Pod再接流量但这样会多占一份资源成本会更高。这两个参数的取舍本质上是可用容量vs资源成本的权衡没有标准答案完全看业务场景。还有一个细节点maxSurge和maxUnavailable不能同时为0否则滚动更新无法推进会一直卡住。2.4 运维保障参数revisionHistoryLimit、progressDeadlineSeconds、paused这三个参数平时不起眼但关键时刻能救命。revisionHistoryLimit控制保留多少个历史ReplicaSet。默认是10意味着你可以回滚到最近10个版本。如果把它设置为0就表示不保留历史记录也就无法回滚了。有些团队为了省etcd资源把历史版本设成1或2结果想回滚到更早版本时傻眼了。我建议线上环境保留5到10个etcd里几个ReplicaSet的元数据开销很小和出故障时的回滚能力相比这点成本完全不值一提。progressDeadlineSeconds是Deployment的“超时判定”参数。它定义了在指定的秒数内如果Deployment没有完成滚动更新控制器就会把Deployment的状态标记为ProgressingFalse并报一个ProgressDeadlineExceeded的错误。默认值我没记错的话是600秒。这个参数在排查问题时非常有用——滚动更新卡住不推进如果总是超时说明你的新Pod一直没法Ready要去查镜像是否存在、探针是否通过、资源是否足够。paused就是暂停字段。设置为true时Deployment暂停滚动更新你可以在保持当前Pod不变的条件下修改Pod模板或扩容但不会触发更新。这个字段在灰度场景下非常实用。比如我想先更新成v1.1版本但又不想立刻全量发布可以先把paused设为true提交新模板观察新ReplicaSet是否正常创建确认没问题后再unpause继续推进。这算是K8s原生自带的一个“半手动灰度”能力。3. 灰度发布从原理到落地3.1 灰度发布的基本思路与K8s原生能力边界灰度发布也叫金丝雀发布Canary Release核心思路是让新版本先承接一小部分流量验证没问题后再逐步放大最终全量替换。它的价值在于把爆雷的范围限制住——即使新版本有Bug受影响的也只是那1%的用户而不是全体用户。经常有人把灰度发布和蓝绿发布混为一谈我简单区分一下蓝绿发布是准备两套完全独立的环境流量从蓝切到绿切换是瞬时的灰度发布是同一套系统内新旧版本并存流量按比例逐步迁移。蓝绿发布切换干净、回滚快但成本翻倍灰度发布成本低、细分度好但对系统的兼容性要求高——新旧版本要能同时服务同一类请求数据库结构要兼容。K8s原生对于灰度发布的能力说句实话是有限的。Deployment本身只能做“滚动更新”也就是渐次替换但它无法精确控制流量比例。比如你想让新版本只接收10%的流量Deployment做不到因为Service的负载均衡是随机分发的权重无法按Pod级别精细控制。所以要做真正的灰度必须引入额外的流量控制机制。下面我给出几种从简到繁的方案你可以根据自己的环境选。3.2 基于多Deployment的手动灰度方案这是最基础、也是我建议初学者先掌握的方式。思路是创建两个Deployment一个跑旧版本一个跑新版本通过调整副本数比例来近似控制流量比例。# 旧版本 v1.0副本数9 apiVersion: apps/v1 kind: Deployment metadata: name: order-service-v1 namespace: production spec: replicas: 9 selector: matchLabels: app: order-service version: v1.0 template: metadata: labels: app: order-service version: v1.0 spec: containers: - name: order-service image: registry.example.com/order-service:v1.0 --- # 新版本 v1.1副本数1 apiVersion: apps/v1 kind: Deployment metadata: name: order-service-v1-1 namespace: production spec: replicas: 1 selector: matchLabels: app: order-service version: v1.1 template: metadata: labels: app: order-service version: v1.1 spec: containers: - name: order-service image: registry.example.com/order-service:v1.1然后让Service的selector只匹配app: order-service不区分version这样流量会在新旧Pod之间按副本数比例随机分配。9比1就是大约10%的流量进新版1比9就是10%进旧版。通过调整两个Deployment的replicas就能实现10%、50%、90%、100%的逐步放量。这个方案的优点显而易见纯K8s原生不需要额外组件逻辑好理解。缺点也很明显控制粒度粗糙Pod级比例不等于流量比例因为每个Pod的QPS和资源利用率不一样。另外扩容缩容过程中Pod数量变化可能导致流量比例抖动。所以这个方案适合灰度要求不高的场景或者作为其他方案的过渡。3.3 基于单Deployment配合Ingress的流量灰度如果想把流量控制做到更精细可以在Ingress/网关层做文章。比如你用的是Nginx Ingress Controller它支持nginx.ingress.kubernetes.io/canary系列注解可以按权重或按Header/Cookie来切分流量。思路是主Ingress指向旧Deployment的Service另外创建一个Canary Ingress指向新Deployment的Service并设置灰度权重。# 主入口流量全量指向旧版本 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: order-service-ingress namespace: production spec: rules: - host: order.example.com http: paths: - path: / pathType: Prefix backend: service: name: order-service-v1 port: number: 8080 --- # Canary入口10%的流量导向新版本 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: order-service-canary namespace: production annotations: nginx.ingress.kubernetes.io/canary: true nginx.ingress.kubernetes.io/canary-weight: 10 spec: rules: - host: order.example.com http: paths: - path: / pathType: Prefix backend: service: name: order-service-v1-1 port: number: 8080除了按权重Nginx Ingress还支持按请求头分流nginx.ingress.kubernetes.io/canary-by-header: canary nginx.ingress.kubernetes.io/canary-by-header-value: true这个用法非常适合内部测试——测试人员发请求时带上canary: true的Header就能稳定命中新版本而普通用户完全不受影响。这也是我最推荐的灰度方式之一因为它逻辑简单、可控性强、不侵入业务代码。需要注意的坑是Canary Ingress和主Ingress的host必须一致并且该host下只能有一个Canary规则配置多了会报错。不同Ingress Controller的注解写法不一样如果你是自建Istio或者Traefik注解名和字段规则都要重新查。3.4 进阶Argo Rollouts的金丝雀发布如果你的团队灰度发布是日常高频操作我建议直接上Argo Rollouts。它不是替换Deployment而是引入一个RolloutCRD资源在原生Deployment的基础上扩展了精细化灰度能力。它的核心设计是把“发布策略”和“流量切换”拆分管理——你可以定义一个步骤列表例如“先切5%流量等2分钟再切50%加一个手动审批最终100%”。Argo Rollouts支持多种流量管理后端包括Nginx Ingress、Istio、ALB等并且带一个Dashboard可以直接看到当前发布进度、每个ReplicaSet的副本数和流量占比。用起来之后你会发现灰度发布从“手工活”变成了“流程化操作”尤其是带审批环节的步骤卡点——新版本发布到一半需要负责人点确认才能继续这对生产环境的安全保障价值极高。不过天下没有免费的午餐引入Argo Rollouts意味着你的集群里多了一组Controller和CRD变更和升级都要考虑兼容性。我的建议是如果集群规模不大、发布频率低用前面两种方案就够如果是发布频繁、业务要求严格的生产环境Argo Rollouts值得投入。4. 生产环境参数调优的实操心得4.1 资源请求与限制requests和limits怎么定Deployment的Pod模板里resources字段是我在排障时最常见的问题来源。很多人不写资源限制容器可以无限使用节点内存结果出现OOM时不是容器被杀而是整个节点内存耗尽触发系统OOM Killer把无辜Pod也一起干掉这是生产环境的大忌。resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1 memory: 1Girequests是调度依据K8s scheduler在把Pod分配到节点时会检查节点的可分配资源是否满足Pod的requests总和。limits是运行时上限CPU的limits有权重压制效果——当节点CPU争抢时超过limits的Pod会被限流内存的limits则是严格限制超过会触发OOM Kill。那参数值怎么定我一般的思路是这样先不加limits只加requests跑几天看监控里的实际用量峰值然后根据峰值乘以1.5到2的余量来设置limits。CPU的requests用500m可能偏保守如果业务是计算密集型的要多留余量。有一个很容易忽视的点limits和requests的比值越大Pod在CPU争抢时被压制得越狠。如果requests设得很小、limits设得很大节点忙起来时这个Pod的性能会非常不稳定。建议两者差距控制在2到4倍以内。4.2 探针参数livenessProbe和readinessProbe的关键字段探针是Deployment能稳定运行的关键防线。readinessProbe决定Pod是否被纳入Service的Endpoints——探针失败流量就不会打到这个Pod上livenessProbe决定Pod是否需要重启——探针失败Kubelet会杀掉容器并重建。readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 2 successThreshold: 1 failureThreshold: 3 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 2 failureThreshold: 3这几个参数里最容易出问题的就是initialDelaySeconds和failureThreshold。initialDelaySeconds太短应用还在启动阶段、健康检查接口还没就绪就会误杀容器我见过Java应用启动需要40秒探针initialDelaySeconds只写了3秒结果Pod一直处于CrashLoopBackOff。反过来failureThreshold设得太大容器真正出问题时响应太慢故障恢复时间长。我的习惯是initialDelaySeconds设为应用平均启动时间的1.2倍failureThreshold保持2到3timeoutSeconds不要超过periodSeconds。还有一个细节readinessProbe和livenessProbe的路径应该区分开。readiness可以检查依赖的下游服务是否就绪比如数据库连接池是否初始化完成liveness只检查进程本身是否活着不要让它依赖外部服务否则数据库抖动时Kubelet会不断重启你的容器造成更大的故障。4.3 优雅终止terminationGracePeriodSeconds与preStopPod被终止时Kubelet会先发SIGTERM给主进程然后等待一段时间超时后发SIGKILL强杀。这个等待时间就是terminationGracePeriodSeconds默认30秒。很多应用收到SIGTERM后会开始清理连接、把未完成的事务提交完再退出这个时间如果不够进程会被强杀造成请求中断。对于请求量大的在线服务我建议在容器里加一个preStop钩子做缓冲等待lifecycle: preStop: exec: command: [sh, -c, sleep 5]为什么要sleep因为Pod从“被删除”到“从Service的Endpoints里摘除”是有延迟的。Kubelet删除Pod时Pod的status会变为Terminating但Endpoints的更新是异步的可能还有几秒的窗口期流量还会继续向这个Pod转发。preStop里的sleep能让新请求在Pod真正停掉之前先等Endpoints更新完成避免连接被直接掐断。这个5秒的值不是拍脑袋定的它应该大于你集群中Endpoints传播的典型延迟我一般根据网络环境设5到10秒。另外注意preStop的sleep时间加上应用自己的优雅退出时间必须小于terminationGracePeriodSeconds否则进程会被强杀preStop白做了。4.4 调度与容灾nodeSelector、affinity、topologySpreadConstraintsDeployment的Pod要落到哪些节点上也是参数调优的重要一环。最简单的nodeSelector是把Pod固定调度到带特定标签的节点上比如GPU节点就通过nodeSelector: gpu: true来指定。但nodeSelector太死板更灵活的是nodeAffinity支持硬性要求requiredDuringSchedulingIgnoredDuringExecution和软性偏好preferredDuringSchedulingIgnoredDuringExecution。我更想强调的是topologySpreadConstraints这个参数在K8s 1.19之后稳定可用它能把Pod均匀打散到不同的拓扑域里——比如不同的可用区或不同的节点。对于多可用区的生产集群这是保证容灾的基础。topologySpreadConstraints: - maxSkew: 1 topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: order-servicemaxSkew: 1表示各个可用区上的Pod数量差异最多为1whenUnsatisfiable: DoNotSchedule表示如果不满足条件就不调度硬性。这样配置后3个副本会尽量分散在3个可用区任何一个可用区挂掉只剩2个副本但服务不会全挂。代价是调度约束变强集群碎片化更严重节点资源利用率会下降。所以DoNotSchedule要和maxSkew配合好否则在节点资源紧张时Pod可能长时间Pending。我见过生产环境因为加了硬性拓扑约束扩容时Pod一直Pending排查半天才发现是某个可用区资源不够。5. 常见故障与排查实录5.1 滚动更新卡住新ReplicaSet不推进Deployment滚动更新时最常见的故障就是卡住不动。现象是新的ReplicaSet已经创建但Pod一直不是Ready状态旧的Pod也不缩容。排查第一步永远是用kubectl get pods看新Pod的状态然后再用kubectl describe pod pod-name看事件的详细内容。我遇到过的典型原因有这么几类镜像地址错误或镜像不存在事件里会出现Failed to pull image或者ImagePullBackOff。如果错误是ErrImagePull先检查镜像仓库地址是否写对、镜像tag是否存在。探针失败Pod虽然在Running但readinessProbe一直失败事件里会看到Readiness probe failed。这种情况要进去看应用的日志多半是数据库连不上、缓存连接失败、或者配置文件不对。资源不足事件里出现FailedScheduling提示Insufficient cpu或Insufficient memory说明节点资源不够跑新Pod。这时要么扩容节点要么临时把maxSurge调小。排查的时候我有个习惯先看kubectl rollout status deployment/name它会直接告诉你卡在哪一步再看kubectl describe deployment/name底部Conditions如果出现ProgressDeadlineExceeded说明超时了ReplicaSet不会继续推进。这时候先去解决Pod不Ready的根因等Pod变健康后滚动更新会自动恢复。5.2 新版本一上就CrashLoopBackOffCrashLoopBackOff表示容器启动后又崩溃反复循环。这个错误的原因五花八门但最常见的是这几个应用启动时依赖的资源不存在比如配置中心连不上、数据库schema没有迁移、依赖的其他服务还没就绪。配置文件不匹配新版本的环境变量、ConfigMap里的配置项缺失代码里读取不到直接panic。资源限制过低启动时内存就超了limits直接被OOM Kill。这时用kubectl describe pod看Last State的OOMKilled状态就能发现。排查CrashLoopBackOff第一步是看日志kubectl logs pod-name --previous注意--previous参数很重要因为容器已经重启了当前日志可能已经是第二次启动的上一次崩溃的信息要看--previous拿。如果日志没有输出就检查容器启动命令和entrypoint是不是有问题。还有一种情况是探针导致的误杀——容器其实正常但livenessProbe由于initialDelaySeconds太短在应用还没就绪时连续失败3次Kubelet就把容器杀了。这种问题看日志会发现应用其实在正常打印启动信息。5.3 灰度流量放大了服务却超时灰度发布时经常遇到的情况新版本承接了10%的流量没问题放大到50%后开始大量超时和报错。这个问题往往是新版本存在性能瓶颈或资源不足而不是功能逻辑问题。我的排查套路是这样先看新版本Pod的CPU和内存监控曲线如果CPU已经打满或内存持续高位说明是资源问题需要调大requests和limits或者增加副本数。如果资源没问题看应用的错误日志和链路追踪判断是否是数据库慢查询、下游服务限流、或者新版本代码里有锁竞争之类的性能隐患。还有一个容易被忽略的坑新旧版本共存时如果两个版本写的是同一张数据库表且schema不兼容新版本写入的数据结构变了旧版本读取时可能会报错。做灰度之前要确保新旧版本的数据兼容性——数据库的变更要向后兼容加字段而不是删字段、加索引而不是改索引这是灰度发布能否成功的前提。5.4 版本回滚的正确姿势灰度发布发现新版本有问题最快的止损方式就是回滚。Deployment回滚有几个层次的命令区别要搞清楚# 回滚到上一个版本 kubectl rollout undo deployment/order-service # 回滚到指定版本 kubectl rollout undo deployment/order-service --to-revision3 # 查看历史版本记录 kubectl rollout history deployment/order-servicerollout undo默认回退到上一个版本本质上是把Pod模板恢复成旧ReplicaSet的模板然后触发一次新的滚动更新。注意回滚也是一次发布它同样受maxSurge和maxUnavailable约束同样可能因为资源不足或镜像问题卡住。关于--to-revision的编号可以通过rollout history查看每个REVISION对应一个ReplicaSet。这里我提醒一句如果revisionHistoryLimit设置过小比如只有2那只能回滚到上一版更早的版本由于历史ReplicaSet被清理就回不去了。所以生产环境即使回滚场景少也建议保留5个以上的历史版本。如果在灰度过程中新版本还没全量、旧版本还有副本在运行那么回滚就更简单——直接把新版本的Deployment副本数缩到0或者直接删掉新版本的Deployment流量自然全部回到旧版本。这也是多Deployment灰度方案的一个优势回滚不触发滚动更新瞬时完成。最后聊点实操上的体感。Deployment的很多参数读文档的时候觉得都懂真正上线跑一轮流量、踩几个故障之后才知道哪些是关键。我在实际维护中发现出问题最多的往往不是Deployment本身的配置而是Pod模板里那些“小字段”——探针的延时设置、资源上下限、优雅退出时长、镜像拉取策略。这些字段单个看都不起眼但组合起来就是服务稳定性的底层保障。灰度发布这个事我也想说一句工具和方法都是次要的关键是流程和纪律。灰度前要确认数据库兼容性灰度中要有监控告警盯流量和错误率灰度后要有明确的全量与回滚标准。把这些实践沉淀成团队的发布规范比纠结用哪个灰度工具重要得多。如果你想把灰度做得更默认更自动化可以试试Argo Rollouts但在那之前先把Deployment这批基础参数吃透它们才是你每天和K8s打交道最常碰到的“肌肉记忆”。