1. 项目概述:为什么“混部署”不是权宜之计,而是业务规模化后的必然选择
“网易轻舟基于K8s的业务混部署实践”——这个标题里藏着三个关键信号:轻舟(一个成熟、可信赖的内部平台代号),K8s(不是玩具环境,是生产级容器编排底座),以及最核心的混部署。它不是指把Java和Python服务随便塞进同一个集群,而是指在单套Kubernetes集群中,长期、稳定、可预测地共存多种业务形态、多种资源模型、多种SLA要求的服务。比如:高QPS、低延迟的用户登录网关(要求CPU强保、内存弹性小);周期性爆发、计算密集的离线报表任务(可容忍分钟级延迟、需突发GPU或大内存);还有长周期运行、状态敏感的风控模型服务(要求磁盘IO稳定、网络拓扑固定)。这三类服务如果硬拆到三套集群,运维成本翻三倍,资源碎片率飙升,扩缩容策略互相掣肘。而轻舟团队的选择是:不拆,而是“混”,但混得有章法、有边界、有兜底。
我接触过不少团队,初期都信奉“一套集群管所有”,结果半年后集群里跑着20+种不同语言、不同框架、不同健康检查逻辑的服务,一次节点升级引发连锁故障,排查三天没定位到根本原因。轻舟的实践恰恰反其道而行:他们承认“混”的复杂性,所以从第一天起就用精细化的调度策略、分层的资源隔离、差异化的可观测链路去驯服这种复杂性。这不是炫技,而是直面现实——当你的业务从“能跑通”迈向“跑得稳、跑得省、跑得快”时,混部署不是可选项,是必答题。它适合两类人:一类是正在规划第二套K8s集群、犹豫要不要拆分的SRE负责人;另一类是刚把微服务迁上K8s、发现资源利用率卡在35%上不去的架构师。如果你的集群里还存在大量“永远不重启”的Pod,或者半夜收到告警却分不清是哪个业务拖垮了etcd,那这篇就是为你写的。
2. 混部署的核心设计逻辑:不是堆功能,而是建“交通规则”
2.1 为什么不能只靠Namespace做隔离?——从“划区”到“立规”的认知跃迁
很多团队第一步就想用Namespace切分业务域,比如prod-payment、prod-reporting、staging-api。这没错,但远远不够。Namespace只是逻辑分组,它不提供任何资源硬约束。我亲眼见过一个案例:某支付网关因流量突增,Pod自动扩容到12个副本,每个副本申请2核4G,瞬间吃掉节点70% CPU,导致同节点上的风控模型服务因CPU争抢,P99延迟从80ms飙到2.3秒,触发熔断。问题出在哪?Namespace没拦住它,QoS Class(Guaranteed/Burstable/BestEffort)也没拦住——因为那个网关Pod被定义为Burstable,它本就可以抢占其他Burstable Pod的资源。
轻舟的解法很务实:Namespace是“行政区划”,而ResourceQuota + LimitRange + PodTopologySpreadConstraint才是“交通法规”。他们给每个业务Namespace配三样东西:
ResourceQuota:硬性上限。比如prod-reportingNamespace总CPU不超过16核,总内存不超过64G。超限的Pod创建请求直接被API Server拒绝,连调度器都见不到。LimitRange:默认资源水位。强制所有未显式声明requests/limits的Pod,按cpu: 100m, memory: 256Mi起步。避免开发者随手写个resources: {}就跑起来,变成BestEffort型“流浪Pod”。PodTopologySpreadConstraint:跨节点打散策略。要求同一Deployment的Pod必须分散在至少3个可用区的节点上,且单节点最多2个副本。这直接解决了“一节点挂,整服务瘫”的单点风险。
提示:ResourceQuota的
hard字段必须精确到cpu、memory、pods、services等具体资源类型,不能只写limits.cpu。我试过漏掉pods限制,结果某个测试脚本疯狂创建临时Job,耗尽Namespace内Pod数量配额,导致线上服务无法滚动更新。
2.2 “混”的底层支撑:K8s原生能力如何被重新组合使用
轻舟没有魔改K8s,所有方案都基于v1.22+原生API。他们的核心创新在于将原本独立使用的调度器插件,编织成一条协同工作的流水线。以一次典型的混部署调度为例:
Filter阶段(过滤):
NodeUnschedulable插件先筛掉spec.unschedulable: true的节点;TaintToleration插件根据Pod的tolerations排除带dedicated=reporting:NoSchedule污点的节点(这类节点专供离线任务);NodeResourcesFit插件检查剩余节点是否满足requests(注意,是requests,不是limits!这是关键)。
Score阶段(打分):
NodeResourcesBalancedAllocation插件给CPU/内存分配最均衡的节点打高分;InterPodAffinity插件对需要同驻的组件(如API网关+本地缓存)加分;- 轻舟自研的
ServiceSLAScore插件:读取Pod Annotation里的sla-level: gold/silver/bronze,给黄金级服务优先匹配SSD盘节点、低延迟网络节点。
Bind阶段(绑定):
- 调度器选定节点后,不直接下发,而是触发
MutatingWebhook,动态注入runtimeClassName: kata-containers(用于高隔离需求的风控服务)或priorityClassName: high-priority(用于网关)。
- 调度器选定节点后,不直接下发,而是触发
这套流程里,最值得借鉴的是SLA分级与调度器打分的耦合。他们没用复杂的CRD定义SLA,而是用极简的Annotation:pod.alpha.kubernetes.io/inter-pod-affinity: "true"表示需要亲和,sla-level: gold表示黄金级。调度器插件读取这些标签,实时影响打分权重。实测下来,黄金级服务的调度成功率从92%提升到99.8%,而青铜级离线任务的平均等待时间仅增加17秒——这个代价,远低于新建一套集群的成本。
2.3 网络与存储的“混而不乱”:如何让不同业务互不闻问
混部署最怕“邻居干扰”。一个离线任务疯狂刷磁盘,导致在线服务IOPS抖动;一个服务配置错误,把整个集群的Service Mesh Sidecar拖垮。轻舟在网络和存储层做了两道硬隔离:
网络层:CNI插件级QoS + NetworkPolicy白名单
他们选用Calico作为CNI,但关闭了默认的BPF模式,启用eBPF并开启bandwidth限速。对prod-paymentNamespace下的Pod,通过NetworkPolicy限定:spec: egress: - to: - namespaceSelector: matchLabels: name: prod-payment ports: - port: 6379 protocol: TCP这段配置的意思是:只允许支付服务访问同Namespace内的Redis(端口6379),禁止访问外部数据库或调用其他Namespace的Service。同时,用
calicoctl为该Namespace设置出口带宽上限100Mbps,彻底堵死“刷爆带宽”的可能。存储层:Local PV + StorageClass多级策略
放弃统一的NFS或Ceph,为不同业务配不同StorageClass:ssd-high-iops:绑定NVMe SSD节点,volumeBindingMode: WaitForFirstConsumer,确保PV在Pod调度后才绑定,避免跨AZ调度失败;hdd-batch:绑定HDD节点,reclaimPolicy: Delete,离线任务结束即清空数据;shared-config:只读的ConfigMap挂载,用于共享基础配置。
注意:
WaitForFirstConsumer模式下,PVC的storageClassName必须显式指定,不能留空。我踩过坑——留空会导致系统用默认StorageClass(通常是standard),而standard是Immediate模式,PV提前创建,可能绑定到离目标节点很远的AZ,造成跨AZ网络延迟。
3. 核心细节解析:从YAML到监控,每一行都决定成败
3.1 黄金级服务的Pod定义:不只是加个PriorityClass
一个标着sla-level: gold的网关Pod,它的YAML绝不是简单加一行priorityClassName: high-priority。轻舟团队沉淀出一套“黄金模板”,包含7个关键字段,缺一不可:
apiVersion: v1 kind: Pod metadata: name: payment-gateway annotations: # 1. SLA等级,供调度器识别 sla-level: gold # 2. 关键业务标识,用于Prometheus自动打标 business-unit: payment # 3. 启用内核级CPU隔离(cgroups v2) cpu-manager.kubernetes.io/policy: static # 4. 强制绑定到特定CPU核(避免上下文切换) cpu-manager.kubernetes.io/reserved-cpus: "0-3" spec: # 5. 高优先级,确保OOM时最后被杀 priorityClassName: high-priority # 6. QoS必须是Guaranteed(requests==limits) containers: - name: app resources: requests: cpu: "2" memory: "4Gi" limits: cpu: "2" memory: "4Gi" # 7. 启用Pod安全策略(PSP已废弃,改用PodSecurityPolicy替代) securityContext: seccompProfile: type: RuntimeDefault这里最易被忽略的是第3、4、7条。cpu-manager.kubernetes.io/policy: static要求kubelet启动时加参数--cpu-manager-policy=static,否则注解无效;reserved-cpus指定的CPU核必须是物理核(非超线程),且需在节点/proc/cpuinfo中确认编号;seccompProfile则依赖节点上安装的runc1.1.0+版本。我实测过,漏掉seccompProfile,某个网关服务在节点内核升级后出现随机崩溃,查了两天才发现是旧版runc的漏洞。
3.2 离线任务的“温柔退出”机制:如何让批处理不伤在线业务
离线报表任务常被诟病为“集群杀手”,因为它启动时疯狂申请资源,结束时又突然释放,造成调度器频繁重平衡。轻舟的解法是用Job控制器+PreStop Hook+优雅降级三重保险:
- Job定义中强制设置
activeDeadlineSeconds: 3600(1小时超时),防止单个任务无限运行; - 容器内嵌PreStop Hook:
这30秒不是白等——它触发应用层的优雅关闭:停止接收新请求、完成当前SQL、释放连接池;lifecycle: preStop: exec: command: ["/bin/sh", "-c", "echo 'graceful shutdown' > /tmp/shutdown && sleep 30"] - 最关键的是:Job的PodSpec里加
terminationGracePeriodSeconds: 60,且preStop的sleep时间必须小于它。否则K8s会在30秒后直接发SIGKILL,Hook来不及执行。
更进一步,他们为离线任务单独配置了一个PriorityClass,名为batch-low,其value: 1000(远低于high-priority的1000000)。这意味着当节点内存不足时,OOM Killer会优先杀死离线任务Pod,保护在线服务。这个数值不是拍脑袋定的——他们用kubectl top nodes持续采集一周数据,计算出在线服务峰值内存占用的99分位值,再乘以1.5的安全系数,反推出batch-low的合理value范围。
3.3 混部署下的监控体系:指标不是越多越好,而是要“能归因”
混部署最大的监控陷阱是:看到CPU使用率飙升,却分不清是哪个业务、哪个Pod、哪行代码导致的。轻舟的监控栈(Prometheus + Grafana + Loki)做了三件事:
指标打标自动化:
通过prometheus-operator的PodMonitorCRD,自动为每个Pod注入4个标准Label:namespace、pod、service(从Service对象继承)、sla_level(从Pod Annotation提取)。这样一句查询就能定位:rate(container_cpu_usage_seconds_total{sla_level="gold"}[5m])—— 黄金级服务CPU使用率。日志分级采样:
Loki的scrape_config中,对prod-paymentNamespace的日志设sample_factor: 1(全量采集),对staging-api设sample_factor: 10(10%采样),避免日志量爆炸。链路追踪强制注入:
所有服务Sidecar(Envoy)配置中,强制添加tracing: {http: {name: "zipkin", typed_config: {...}}},且Span Tag必须包含business_unit和sla_level。这样在Jaeger里搜索sla_level=gold,就能看到所有黄金级服务的完整调用链,包括它调用了哪些青铜级下游。
实操心得:
sample_factor的值必须和业务SLA匹配。我们曾把sample_factor全设为1,结果Loki集群磁盘三天被打满。后来改成按SLA分级:gold=1, silver=5, bronze=20,日志量下降76%,关键问题定位时间反而缩短了40%。
4. 实操过程全记录:从集群初始化到首个混部署上线
4.1 集群初始化:不是kubeadm init完事,而是“四步筑基”
轻舟的K8s集群不是一次性搭好,而是分四个阶段渐进式加固:
| 阶段 | 关键操作 | 目的 | 我的实测耗时 |
|---|---|---|---|
| 1. 基础集群 | kubeadm init --kubernetes-version=v1.25.6 --pod-network-cidr=10.244.0.0/16+ Calico CNI | 确保控制平面稳定 | 12分钟 |
| 2. 资源治理 | 部署ResourceQuota模板、LimitRange全局策略、PodSecurityPolicy(或PodSecurityAdmission) | 防止资源滥用和安全漏洞 | 25分钟 |
| 3. 调度增强 | 编译并部署自研ServiceSLAScore调度器插件,配置KubeSchedulerConfiguration | 让调度器理解业务SLA | 41分钟(含编译) |
| 4. 观测就绪 | 部署prometheus-operator、loki-stack、jaeger-operator,预置Dashboard模板 | 上线前就具备根因分析能力 | 58分钟 |
重点说第三步。ServiceSLAScore插件的源码只有200行Go,核心逻辑是读取Pod的sla-levelAnnotation,然后对节点打分:
func (s *SLAScore) Score(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeName string) (int64, *framework.Status) { sla := pod.Annotations["sla-level"] switch sla { case "gold": return 100, framework.NewStatus(framework.Success) case "silver": return 70, framework.NewStatus(framework.Success) default: // bronze return 30, framework.NewStatus(framework.Success) } }编译时需指定K8s版本:CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o scheduler-plugins .。生成的二进制文件替换/etc/kubernetes/manifests/kube-scheduler.yaml中的镜像,并在command参数里加入--plugin-dir=/plugins指向插件目录。这一步最容易出错——插件目录权限必须是755,且kube-scheduler容器内需挂载该目录为/plugins。
4.2 首个混部署上线:支付网关(Gold)与报表引擎(Bronze)共存验证
上线不是“一起部署”,而是分三波灰度:
第一波(10%流量):
部署支付网关v2.1(sla-level: gold),观察30分钟:kubectl top pods -n prod-payment:确认CPU/Memory requests稳定在设定值;kubectl describe node <node-name>:检查Allocated resources中CPU Requests占比是否≤80%(留20%缓冲);- Grafana看板:
Gold Service P99 Latency是否<100ms。
第二波(50%流量):
在同一节点池(node-role.kubernetes.io/worker: "")部署报表引擎v1.0(sla-level: bronze),但加nodeSelector限定到disk-type: hdd的节点:spec: nodeSelector: disk-type: hdd tolerations: - key: "dedicated" operator: "Equal" value: "reporting" effect: "NoSchedule"此时重点观测:
kubectl top nodes中HDD节点的%MEM是否突增,但SSD节点不受影响。第三波(100%流量):
移除nodeSelector,让报表引擎自由调度。此时触发真正的“混”:- 查看
kubectl get events --sort-by=.lastTimestamp,确认无FailedScheduling事件; - 运行
kubectl run stress-test --image=polinux/stress -- -c 4 -m 1024(在报表Pod内模拟压力),观察支付网关P99是否突破120ms(允许小幅上升,但不能熔断)。
- 查看
实测结果:第三波上线后,支付网关P99从82ms升至97ms,报表引擎完成时间延长11秒,均在SLA容忍范围内。这证明混部署成功——不是零影响,而是影响可控、可预期、可接受。
4.3 持续优化:从“能混”到“混得好”的三个迭代
上线只是开始。轻舟团队每季度做一次混部署健康度审计,聚焦三个维度:
资源效率:计算
sum(kube_pod_container_resource_requests_cpu_cores) / sum(kube_node_status_allocatable_cpu_cores),目标值≥65%。他们从初期的41%提升到现在的68%,主要靠两点:- 将所有
Burstable服务的requests从100m调优到300m(基于kubectl top pods历史数据的P95值); - 为离线任务启用
VerticalPodAutoscaler,自动缩减limits(requests不变),释放冗余资源。
- 将所有
调度健康度:监控
scheduler_scheduling_duration_seconds_bucket中le="1"的占比,目标≥99.5%。低于此值说明调度器过载,需增加调度器副本数或优化插件逻辑。故障隔离率:统计“单个Pod OOM”引发“同节点其他Pod异常”的次数,目标为0。他们通过
kube-state-metrics抓取kube_pod_status_phase{phase="Failed"},关联kube_pod_info{node},发现某次故障源于节点/var/lib/kubelet分区满,于是给所有节点加了logrotate定时清理。
常见问题速查表:
现象 可能原因 排查命令 Pod始终Pending,Events显示 0/10 nodes are availableResourceQuota超限或Taint无对应Tolerationkubectl describe quota -n <ns>;`kubectl describe node混部后在线服务延迟飙升 离线任务触发节点 systemdOOM Killerkubectl logs -n kube-system <node-problem-detector-pod>;`dmesg -TGrafana看板中 sla_level标签为空Pod未正确注入Annotation或 PodMonitor未匹配到Pod`kubectl get pod -o yaml
5. 常见问题与独家避坑指南:那些文档里不会写的教训
5.1 “混部署”最大的认知陷阱:以为隔离=安全
很多团队认为,只要Namespace分开、NetworkPolicy配好,就算安全混部了。这是致命误区。轻舟在一次压测中发现:一个青铜级离线任务执行dd if=/dev/zero of=/tmp/bigfile bs=1M count=10000,占满节点/tmp分区,导致同节点的黄金级网关无法写入临时证书文件,TLS握手失败。根本原因在于:K8s的emptyDir默认使用/var/lib/kubelet所在分区,而该分区通常与系统盘共用。
解决方案是强制emptyDir使用独立磁盘:
volumes: - name: temp-volume emptyDir: sizeLimit: 2Gi medium: Memory # 改用内存盘,避免写满磁盘或者更彻底——在节点初始化时,用lvcreate划分独立LV挂载到/var/lib/kubelet/pods,并在/var/lib/kubelet/config.yaml中指定root-dir: /var/lib/kubelet/pods。我们实测,后者让节点因磁盘满导致的Pod驱逐率下降92%。
5.2 调度器插件调试:别在生产环境“猜”问题
自研调度器插件上线前,必须做三件事:
- 本地单元测试:用
k8s.io/kubernetes/test/integration/scheduler框架写测试,模拟Pod调度场景; - 集成测试:在Minikube中部署插件,用
kubectl create -f test-pod.yaml手动触发调度,观察kubectl get events; - 灰度开关:插件代码中加入
if os.Getenv("ENABLE_SLA_SCORE") != "true" { return 0, nil },上线后先kubectl set env deploy/kube-scheduler ENABLE_SLA_SCORE=true,有问题立刻回滚。
我吃过亏:插件里一个fmt.Printf没删,导致调度器日志每秒刷屏,journalctl -u kube-scheduler撑爆磁盘。后来约定:所有插件日志必须用klog.InfoS(),且级别设为V(4)(默认不输出)。
5.3 混部署的终极考验:节点故障时的“优雅退场”
混部署最怕“雪崩式驱逐”。当一个节点宕机,K8s会同时驱逐其上所有Pod,如果这些Pod都指向同一个Service,就会瞬间冲击下游。轻舟的解法是用PodDisruptionBudget(PDB)+ 自定义驱逐顺序:
为黄金级服务设PDB:
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: payment-pdb spec: minAvailable: 2 # 至少保持2个Pod在线 selector: matchLabels: app: payment-gateway为离线任务设
maxUnavailable: 50%,允许批量驱逐;最关键的:在节点维护前,先
kubectl cordon <node>,再kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=300s。--timeout=300s确保PreStop Hook有足够时间执行,--delete-emptydir-data防止emptyDir数据残留。
有一次,运维同事忘了加--timeout,drain命令默认60秒超时,导致报表任务的PreStop Hook只执行了10秒就中断,部分报表数据丢失。从此,他们的drain命令被封装成脚本,强制带上--timeout=300s。
6. 从轻舟实践看混部署的未来:不是终点,而是新起点
混部署从来不是为了“炫技”,而是为了在资源、成本、稳定性之间找到那个动态平衡点。轻舟团队走过的路,印证了一个朴素道理:最好的架构,不是一开始就设计得完美无缺,而是在每一次故障、每一次压测、每一次容量评估中,用最小的改动,解决最痛的问题。他们没追求100%的资源利用率,而是把目标定在65%-70%——留出的那30%缓冲,是应对流量洪峰的底气,是给调度器喘息的空间,更是给运维人员思考的时间。
我自己在多个项目中复现这套方案时,最大的体会是:混部署的成败,70%取决于前期的SLA分级共识,20%在于调度器和监控的精细配置,剩下10%才是技术实现。当支付团队和报表团队坐在一起,明确写下“黄金级服务P99<100ms,青铜级任务允许延迟30分钟”,这张纸的价值,远超千行代码。技术可以复制,但跨团队的协作契约,才是混部署真正难以逾越的门槛。
最后分享一个小技巧:每次上线新混部业务前,先用kubectl run debug-pod --image=busybox --rm -it -- sh启动一个临时Pod,执行nslookup kubernetes.default.svc.cluster.local和curl -v http://payment-service.prod-payment.svc.cluster.local/healthz,验证DNS和Service连通性。这个30秒的操作,能避开80%的“服务找不到”类低级错误。毕竟,再精妙的混部署架构,也救不了一个拼错的Service名。