1. 为什么Kubernetes成了容器编排的事实标准
1.1 从单机Docker到集群管理的痛点
我最早接触容器的时候,Docker刚刚火起来,那时候大家觉得“一个应用打成一个镜像,到处都能跑”已经是天大的进步。开发环境、测试环境、生产环境不再需要反复交代“我这边能跑”,直接丢一个镜像过去就完事。但真正到了生产环境,问题很快就浮出来了:你有几十个容器要同时跑,它们分布在几台机器上,怎么做服务发现?怎么做负载均衡?一台机器挂了,上面的容器谁来重新拉起?流量变大了,怎么扩容?今天上线一个新版本,怎么做到不中断服务?
这些问题单靠Docker本身解决不了。Docker Compose只能在单机层面编排,Swarm虽然能跨节点,但功能太弱。那时候的运维同学经常在凌晨做一套手工脚本:检测到进程挂了就重启,检测到流量高了就手动加机器。这套东西极其脆弱,而且换一个人就玩不转。所以Kubernetes的出现,本质上是为了解决“容器多了以后怎么管”这个问题,它把调度、自愈、弹性伸缩、服务发现这些原本要自己折腾的能力,做成了平台级的默认能力。
1.2 Kubernetes的核心价值:声明式而非命令式
刚开始用Kubernetes的时候,我脑子里还是传统的“命令式”思维,总觉得系统应该告诉我“去干这件事”。但Kubernetes的玩法完全不同,它希望你告诉它“我希望达到什么状态”,然后它自己去搞定。举个例子:传统的脚本思路是“启动nginx、发现挂了就重启、流量到了就加副本”,而Kubernetes的玩法是你提交一个Deployment,声明“我要3个nginx副本”,之后系统持续保证这个状态——副本少了会拉起,多了会回收,节点挂了会在其他地方重建。
这个思维转变非常重要,行业内叫“声明式API”。说白了就是:你负责提需求,系统负责干活。刚开始你会觉得不习惯,因为“希望状态”和“实际状态”之间总有偏差,Kubernetes内部通过控制循环(Control Loop)不断比对这两个状态,然后执行动作让它们收敛。理解了这一点,再去看它的各种功能就会豁然开朗:为什么有ReplicaSet?因为在管理副本数。为什么有Service?因为在提供稳定访问入口。为什么有控制器?因为不同的应用生命周期需要差异化管理。
2. 核心概念的一次性理清
2.1 Pod:最小的调度单位,为什么不是容器
很多人接触Kubernetes第一个困惑就是:明明我用的是Docker容器,为什么Kubernetes的最小单位变成了Pod?其实理由很实在。有些应用表面上是一个进程,实际上一拆开是多个关系极其紧密的进程,比如一个主进程配一个日志收集的sidecar,或者一个Web进程配一个本地缓存进程。它们需要共享网络栈、共享存储、最好还被调度到同一台机器上,一起生一起死。如果最小单位是容器,这些紧密关系就得靠外部机制硬凑,非常别扭。
Pod就是把一组“命运共同体”容器打包在一起的抽象层。里面的容器共享同一个IP地址、共享同一个网络命名空间,可以通过localhost互相访问,也能共享同一个Volume。我打个比方:容器是乐高积木里的小块,Pod是提前拼好的一个小组件,Kubernetes调度器只搬运“组件”,不拆散“小块”。这对网络理解也很关键——Pod里的容器端口是共享的,不能冲突,你不能在一个Pod里让两个容器同时监听80端口。
2.2 控制器:Deployment、StatefulSet、DaemonSet怎么选
Pod在Kubernetes里是“可牺牲”的,它会滚动更新、会被重新调度、IP地址会变。所以你在生产环境几乎不会直接去创建裸Pod,而是通过控制器来声明Pod的期望状态。最常见的三个控制器,选型逻辑其实很清晰:
- Deployment:面向无状态应用。Web服务、API服务、任务型服务,这种随时可以替换实例,用Deployment就对了。它支持滚动更新、回滚、副本扩缩容。
- StatefulSet:面向有状态应用。数据库、消息队列、ZooKeeper这类需要稳定网络标识(稳定的Pod名称、稳定的存储)的服务,用StatefulSet。它给每个实例一个固定的序号和独立的存储卷,重启后身份不变。
- DaemonSet:保证每个节点上恰好跑一个Pod。典型场景是日志采集(Fluentd)、监控探针(Prometheus Node Exporter)、网络插件(Calico)。
我最初的项目就是从这三个控制器开始的,核心原则就一句话:你的应用有没有稳定的身份和存储?没有,用Deployment;有,用StatefulSet;要每台机器都有,用DaemonSet。别把无状态应用塞进StatefulSet里自找麻烦,也别指望Deployment能给你数据库实例提供稳定的“名字”。
2.3 Service与网络:ClusterIP、NodePort、Ingress的关系
Pod是会生老病死的,IP也会变。Service就是那个“不变的入口”,它通过标签选择器找到一组符合条件的Pod,并把流量转发给它们。Service有三种常见类型,我见过不少人搞混:
| Service类型 | 作用范围 | 典型使用场景 |
|---|---|---|
| ClusterIP | 集群内部访问 | 后端服务之间的互相调用,只在集群内可达 |
| NodePort | 集群外部通过节点IP+端口访问 | 测试环境临时暴露服务,或者配合Ingress使用 |
| LoadBalancer | 通过云厂商负载均衡器访问 | 公有云上的生产环境直接暴露服务 |
ClusterIP是默认类型,等于给一组Pod提供了一个集群内部的虚拟IP和DNS名字。NodePort是在每个节点上开一个端口,把流量转发到对应的Service上,外部通过节点IP:端口就能访问。但生产环境很少直接用NodePort,因为端口多了难管理、安全性也差。正规做法是上面再加一层Ingress——它本身是一个七层负载均衡器(常见的是nginx-ingress或traefik),根据域名和路径路由到不同的Service上。你只需要开一个外网入口,所有服务都走这一个口进来。
3. 实战:从零把nginx部署到Kubernetes集群
3.1 环境准备:我用的什么方式搭建集群
要实战就得先有一个集群。我在本地阶段用minikube体验,但搞开发调试到一定深度之后,我更推荐用Kind(Kubernetes in Docker),它把整个集群跑在Docker容器里,创建速度快、资源消耗小、还能模拟多节点。生产环境我用的是一套三节点的二进制安装集群,但那个过程太繁琐,不适合新手起步。比较舒服的路径是:本地用Kind或minikube先跑通业务逻辑,再上云或者用kubeadm搭正式的集群。
这里有一个新手很容易踩的坑:装完集群第一件事先检查上下文,别对着错误的集群发命令。kubectl config get-contexts看一下当前用的是哪个集群,kubectl config current-context确认你要操作的正确环境。我在真实环境里就误操作过两次,一次把测试环境的配置丢到了生产集群,还好只是增加了副本数,发现及时撤回了。在给关键集群操作之前,养成这个习惯能省掉很多麻烦。
3.2 编写YAML的完整过程
部署nginx最核心的是两步:Deployment定义“跑什么”,Service定义“怎么访问”。我分享一份我常用的配置,然后逐步解释关键字段。
首先是Deployment的YAML:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo namespace: default spec: replicas: 3 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.27-alpine imagePullPolicy: IfNotPresent ports: - containerPort: 80 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 200m memory: 256Mi readinessProbe: httpGet: path: / port: 80 initialDelaySeconds: 3 periodSeconds: 5我解释几个容易被忽视的点:
replicas: 3:期望的副本数。生产上不要设置1,除非是临时验证。3个副本意味着当节点故障时,Pod有机会在其他节点重建,这是最基础的高可用保障。selector.matchLabels和template.labels必须一致。这个标签选择器决定了ReplicaSet要去管理哪些Pod,我见过有人修改了模板标签但忘了改选择器,结果ReplicaSet创建的Pod完全不匹配,集群里出现了一堆“孤儿Pod”,非常迷惑。resources:requests和limits一定要配。很多新手不配,觉得不配跑得更爽。实际上Kubernetes调度器参考requests来决定把Pod放在哪个节点,如果你不配,调度器会把Pod打散到负载已经很重的地方;而limit不配,意味着这个Pod可以无限占用节点内存,有可能把整个节点打挂。后面我会专门讲这个坑。readinessProbe:就绪探针,滚动更新时至关重要。新Pod只有通过了这个探针才会被纳入Service的负载均衡池,保证流量不会打到还没准备好的实例上。
然后是Service的YAML:
apiVersion: v1 kind: Service metadata: name: nginx-demo-svc spec: type: ClusterIP selector: app: nginx-demo ports: - name: http port: 80 targetPort: 80这里port是Service对外服务的端口,targetPort是Pod里nginx实际监听的端口,selector告诉Service流量该发给哪些Pod。注意Service的selector要跟Deployment里的template.labels一致,也就是app: nginx-demo。如果selector写错了,Service的Endpoints列表会是空的,流量自然转发不出去。
执行命令很简单:
kubectl apply -f nginx-deployment.yaml kubectl apply -f nginx-svc.yaml kubectl get pods -o wide kubectl get svc nginx-demo-svc3.3 访问链路与调试命令
上面的Service是ClusterIP类型,只能在集群内部访问。想从外部访问,有两个方案。方案一是把type改成NodePort,改完后再看Service,会发现多了一个映射端口,比如80:31567/TCP,这时候通过任意节点的IP加31567就能访问。但NodePort端口有范围限制(默认30000-32767),单纯说“外网访问”它不够优雅。方案二是上Ingress,生产环境我一般这么做。
Ingress需要一个Ingress Controller在集群里跑着,最常用的是ingress-nginx。安装完之后,创建一个Ingress资源来定义路由规则:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx-demo-ingress spec: rules: - host: demo.example.com http: paths: - path: / pathType: Prefix backend: service: name: nginx-demo-svc port: number: 80这样外部的请求到了Ingress Controller,根据host字段匹配demo.example.com,再转发给对应的Service。如果你本地做实验没有域名,可以临时在/etc/hosts里把域名指向集群入口IP。
调试是日常高频场景,我列几个常用的命令组合:kubectl logs -f deployment/nginx-demo看日志;kubectl describe pod看Pod事件和容器启动状态;kubectl get endpoints确认Service背后有Pod;kubectl exec -it <pod-name> -- sh进容器里排查问题。遇到Service访问不通,我按这个顺序查:先看Pod是否Running且Ready,再看Endpoints是否有值,再看Service选择器是否匹配标签,最后看节点和网络策略是否拦截。
4. 原理视角:从API Server到etcd,源码阅读的切入路径
4.1 一次kubectl apply背后发生了什么
当你敲下kubectl apply -f nginx-deployment.yaml,背后是一个完整的事件链。先看客户端:kubectl读取你的kubeconfig配置文件,找到API Server地址和认证信息,把YAML转换成JSON格式的请求,通过HTTPS发送给API Server的/apis/apps/v1/deployments接口。API Server是Kubernetes所有请求的总入口,它先做认证(你是谁)、再授权(你能不能干这个事)、最后做准入控制(比如检查命名空间是否存在、资源配额允不允许),全部通过之后才会把数据写入etcd。
etcd是一个分布式键值存储,是Kubernetes唯一持久化数据的地方。你提交的Deployment对象就在这里存着。但存进去不代表运行起来了,接下来才是控制器的重头戏。Deployment控制器一直Watch着etcd里的Deployment数据,发现有了新对象,它会创建对应的ReplicaSet;ReplicaSet控制器发现ReplicaSet的存在之后,会比对“期望副本数”和“实际Pod数”,然后调API Server创建缺失的Pod。
Pod被创建的时候并没有被分配到任何节点,它处于Pending状态。Kubernetes的调度器(scheduler)监听到了这个待调度的Pod,根据节点的剩余资源、亲和性约束、污点和容忍度等条件,选定一个合适的节点,然后通过API Server把这个调度结果写回etcd。节点上的kubelet(每个节点上的“耳目”)通过持续的Watch机制发现Pod被分配到了自己节点,就开始调用容器运行时(containerd或CRI-O)拉取镜像、启动容器。之后kubelet还会持续上报Pod状态,更新到etcd里。
所以整个链路串起来是:kubectl → API Server → etcd → Deployment控制器 → ReplicaSet控制器 → scheduler → kubelet → 容器运行时。理解了这条链路,你排查问题的时候就有方向了:Pod卡在Pending,多半是调度问题;镜像一直拉取失败,多半卡在kubelet那一步;Pod报CrashLoopBackOff,则是容器启动后立刻退出。每个现象对应的是链路中特定环节的异常。
4.2 源码阅读的推荐路径和《深入理解kubernetes源码》这类书的读法
很多人问我源码到底怎么读,我觉得最快路径不是从main函数开始,而是从“你平时最常打交道的对象”反着读。比如你先读kubectl apply这条命令的实现,顺着它走到客户端如何组织请求、如何解析响应;再读API Server的路由注册逻辑,看Deployment这个资源是怎么被处理的;然后看controller-manager里的Deployment控制器,看它内部怎么实现“期望状态比对”。这三层走完,你对Kubernetes的整个骨架就有感觉了。
我大概翻过好几本Kubernetes源码相关的资料,像《深入理解Kubernetes源码》这类书,价值在于给你提供了模块级别的导航图。它会把client-go的Informer机制、WorkQueue、DeltaFIFO这些关键组件拆开讲。但我不建议从头到尾啃代码,Kubernetes目前体量非常大,代码量数千万行,从头看不可能看完。我的读法是把源码当字典用——排查问题时带着具体问题去查,比如“为什么某个事件没有触发我的控制器?”就去看Informer的机制。
源码阅读还有一个核心前置技能:理解client-go这套客户端库。它是Kubernetes内部所有控制器和外部operator与API Server通信的基础库。你先搞懂informer的ListAndWatch机制——控制器启动的时候通过List全量拉取一次数据,之后通过Watch持续监听变化,变化的增量进入DeltaFIFO队列,由自定义的WorkQueue分发到handler处理。Kubernetes的性能优势很大程度上来自这套“本地缓存+增量监听”的机制,控制器不会每次都要查询API Server。哪里读不懂,就先看References文档,再回到源码借助IDE跳转,一点点啃。
4.3 一个必须理解的机制:控制循环与水平Pod自动伸缩
理解了事件链之后,我强烈建议你把“控制循环”这个机制吃透,因为它贯穿了几乎所有核心组件。控制循环的逻辑就是三段:期望状态、当前状态、差异处理。Deployment控制器时刻在算“我期望3个副本,现在只有2个,那我创建一个”。HPA(HorizontalPodAutoscaler)控制器也一模一样,它从metrics-server拿业务的指标,比如CPU使用率或QPS,算出“当前需要的副本数是5,期望副本数是3”,然后调用API Server去更新Deployment的副本数。
我在实际项目里就用HPA做过度量驱动的自动扩容,配置大概是:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: nginx-demo-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nginx-demo minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60这个配置的含义是:当所有Pod的平均CPU使用率超过60%,HPA会逐步增加副本数,最多到10个;低于安全水位后会缩容,最少保留2个。有一个我踩过的小坑:HPA依赖metrics-server正常工作,而metrics-server的数据采集有延迟,所以HPA的响应不是瞬时的。压测的时候刚发起高流量,扩容要等上一两分钟才生效,这是正常现象,别误以为HPA坏了。
5. 这一年多踩过的坑和总结的经验
5.1 资源请求与限制:说不配就不配的代价
前面提到了资源请求(requests)和限制(limits),这里专门展开说。我见过最典型的故障:一个节点上跑了几个Java应用,YAML里都没写limits,某天流量稍微上来一点,其中一个应用库吃内存,直接把节点内存耗尽,触发内核OOM,结果节点上所有Pod一起陪葬。发生事情之后查监控,发现节点内存曲线一路飙升到100%,连kubelet自身都被拖垮了。
正确的做法是:每个容器都写明requests和limits。requests是调度依据,告诉调度器“我这个容器启动至少需要多少资源”,调度器据此保证节点总有足够资源,不会多个容器超配到爆;limits是运行时限制,超了会被系统杀掉或限制CPU。给JVM类应用配内存一定要给JVM堆外留空间,否则limits刚好等于堆大小,一跑起来就OOMKilled。比如一个堆内存512M的应用,我一般把limits的memory设到768Mi或1Gi,甚至更高,给元空间、线程栈留余地。
还有CPU的requests和limits要注意单位:100m等于0.1个CPU核心。如果一台节点是4核,requests总和超过4000m时调度器就会拒绝新的Pod,因为你明确说“我需要至少0.2核”但节点已经分完了。合理预留资源比例很关键,我习惯保留20%~30%的节点余量,防止雪崩。
5.2 镜像拉取策略与版本管理的坑
镜像版本管理是个大坑。很多项目镜像tag用的是latest,看起来方便,实际上等于放弃可重复性。“昨天部署的还能跑,今天重新部署同一个YAML就跑不起来了”——因为latest镜像的内容变了。Kubernetes的imagePullPolicy如果没显式指定,默认规则是:tag为latest时总是拉取,其他tag在节点本地没有时才拉取。所以即便你手动指定nginx:latest,每次部署也可能拿到新内容,行为不可控。
用明确版本的tag是我的底线,例如nginx:1.27-alpine、my-app:v1.2.3,让每次部署都可追溯。更进一步,推荐用镜像摘要(digest),my-app@sha256:xxxxx,这个是最强的一致性保障,镜像仓库里哪怕内容被覆盖,只要digest不变,拉到的内容一定不变。如果非要用latest做本地开发,也请显式设置imagePullPolicy: IfNotPresent,至少避免反复拉取。
还有一个跟镜像相关的常见问题是镜像拉取失败。排查的时候先确认节点能不能访问镜像仓库,再检查私有仓库的认证配置。配置了imagePullSecret的,要用kubectl describe pod看看是不是Secret名字写错了、Secret内容里有没有正确编码账号密码。我在一个环境里遇到过明明在命名空间A配了Secret,Deployment却部署在命名空间B,结果镜像一直认证失败,折腾了半小时才发现命名空间不对。
5.3 我对Kubernetes学习路径的个人建议
最后聊学习路径,因为“Kubernetes知多少”这个问题,最终回答还是拿实践说话,绕不开一条循序渐进的路线。我自己的体会是,别一上来就啃源码,也别只盯着YAML背字段。先掌握三件事:一,把Pod、Service、Deployment相互关系吃透,能在集群里部署一个有完整访问链路的应用;二,理解控制器的控制循环思想,再去看任何自定义资源(CRD/Operator)都会更快;三,学会看日志、看事件、看监控,定位问题的能力比记忆力管用得多。
从入门到能独立排障,我建议按这个顺序推进:本地搭建Kind或多节点集群 → 学会用kubectl操作核心资源 → 手动部署一个包含Deployment、Service、Ingress、ConfigMap、Secrets的完整服务 → 给服务加上资源配额、HPA、探针 → 然后去理解API Server、etcd、调度器之间的关系 → 最后带着具体问题去读源码,比如看调度器如何选节点、控制器如何计算期望状态。
我通过Kubernetes的入门到日常运维,最深的感觉是这个技术栈复杂,但有迹可循。它的所有“高级”能力,几乎都能从“声明式期望状态 + 控制循环”这个最基本的模型推导出来。你把这条主线抓住了,剩下的都是细节填充。如果你现在刚起步,别盯着那些平时用不到的抽象概念,先把一个nginx应用跑通、给它加上各种生命周期保障,再回头看原理,你会发现一切都变得自然很多。