☰
Docker底层架构与K8S集群实战:从容器原理到生产运维
2026/10/23 3:29:29 网站建设 项目流程

前两年我们团队经历了一次很狼狈的扩容事故。当时某个业务模块的流量突然上涨,两台虚拟机CPU直接打满,我们爬起来手动克隆镜像、初始化环境、改配置、再挂到负载均衡后面,前后折腾了四十多分钟。用户比我们先崩溃。也就是那次之后,我才真正意识到,我缺的不是“再买一台机器”的能力,而是镜像化交付、秒级启动的Docker架构,以及能自动调度、自动扩缩容的K8S集群。

这篇文章想换个角度,不谈零零散散的docker run参数,而是把Docker的底层架构、K8S各组件之间的分工,以及一套集群从零到上线的完整链路串起来讲。适合已经会基本Docker操作、但没想清楚“容器凭什么这么快”“kubelet到底在干嘛”的同学,也适合刚准备把业务迁到K8S、想提前知道生产都有哪些坑的团队。读完之后,你应该能解释清楚docker run背后发生了什么,能说明白K8S控制面和数据面是怎么协作的,并且能照着文档落地一套能跑业务的测试集群。

1.1 虚拟机和容器的本质差异:启动为什么快

虚拟机之所以重,是因为每一台都带着完整的客户操作系统。启动一台虚拟机,要经历固件自检、内核加载、系统初始化再到你的业务进程,几十秒起步,配置再高也快不到哪去。容器就完全不同了,它本质上是宿主机上的一个普通进程,只不过被Linux内核的namespace和cgroups包了一层。启动时不需要引导操作系统,只需要拉起进程、挂上它的依赖库和文件系统视图,所以从docker run到进程真正跑起来,常常只有几百毫秒。

如果把部署应用比作搬家,虚拟机是给你准备一间毛坯房,你得自己装水电、买家具、把家电搬进去;容器则是把应用连同它的运行环境打进一个即插即用的模块,跑到哪都能直接插上就亮。这个差异带来的直接收益不光是快,还有一致性:在开发机上能跑,到了测试机和生产环境大概率也能跑,因为整个运行环境都被镜像固化了。

这里要澄清一个常见误解:Docker并没有发明新的隔离技术。namespace和cgroups是Linux内核早就提供的能力,前者负责让进程“只看得到自己该看的”,后者负责限制进程“最多能用多少资源”。Docker做的,是把这两套复杂的内核机制包装成了普通开发者也能轻松使用的接口。

1.2 Docker到底由哪几层组成:client、daemon、containerd与runc

早期Docker是一个相对单体的大守护进程,docker命令行把请求发给dockerd,dockerd自己负责创建容器、管理镜像、处理网络和存储。所有功能挤在一个进程里,升级和排查都很痛苦。后来社区把容器生命周期管理拆给了containerd,把真正和内核打交道的部分收敛到runc,形成了今天四层分工的结构。

层主要职责常见组件
用户入口接收并解析用户命令docker CLI、kubectl、crictl
守护服务镜像构建、API暴露、网络与存储管理dockerd
容器管理容器生命周期、镜像拉取、快照管理containerd
运行时真正创建进程、设置namespace与cgroupsrunc

这套拆分的核心原因是“职责单一化”。dockerd依然是大管家的角色,但容器的创建、停止、销毁这些动作已经交给containerd;containerd再通过shim拉起一个runc进程,由runc按照OCI标准去操作内核。你可能会问,为什么中间还要多一层shim?因为containerd如果重启,shim可以继续保持容器进程存活,不让用户业务跟着一起断。

对做运维的人来说,理解这层关系最大的作用是排错定位。docker ps看不到问题的时候,要去查containerd的状态;containerd正常但容器起不来,问题多半出在runc或者更低层的内核接口上。

1.3 镜像分层的写时复制:为什么一个镜像能瞬时拉起几十个容器

Docker镜像不是一块大饼,而是多层只读层叠起来的。每一条Dockerfile指令基本都会生成一层,容器启动时在最上面加一个可写层。多个容器共用同一个镜像时,共享所有只读层,各写各的可写层,磁盘占用成倍降低,启动速度也因为不需要拷贝整个文件系统而变得非常快。

“写时复制”听起来是个高级概念,其实逻辑很朴素:当你改一个文件时,系统不会真的去改底层只读层里的那个文件,而是先把它复制到可写层,然后修改副本,并用可写层里的新文件盖住底层视图。删除也不是真的删除底层文件,而是加一个白名单记录,让上层不再看到它。这个设计保证了镜像的只读层永远可以被多个容器复用,可写层则完全独立,互不干扰。

同样的原理也决定了Dockerfile的写法:把不常变的依赖安装放在前,经常改的业务代码放在后。因为每一层缓存依赖前一层和指令上下文,只要COPY的上下文变了,后面的层全部失效。我见过不少项目把npm install和COPY index.js写在一起,每次改一行代码都要重新拉一次依赖,构建时间动辄十几分钟。

实操层还有一个细节容易被忽略:docker build默认会把整个上下文目录发给守护进程,所以一定要写.dockerignore,把node_modules、.git、临时目录排除掉,否则一次构建传输几百兆甚至几个G都很正常。

1.4 隔离不等于安全:namespace与cgroups的边界

容器隔离不等于虚拟机隔离。namespace只是让进程看不到其他东西,cgroups只是限制资源用量,但容器和宿主机共享同一个内核。如果内核出现漏洞,一个容器越权就可能影响整台宿主机,这也是为什么很多安全要求高的业务不敢让容器裸跑在宿主机上。

生产环境里我会默认做这几件事:镜像内用非root用户运行进程;将根文件系统设为只读;不给容器挂载宿主机敏感路径;设置内存限额防止单个容器把节点拖垮;去掉容器用不到的Linux capabilities,必要时套上seccomp profile。

我亲眼见过一个没设内存limit的容器把某节点内存吃满,宿主机OOM Killer直接开始杀进程,最后把关键系统服务也带崩了。容器化不是乱放飞的借口,资源配额在前面省事,后面一定会用更痛苦的方式还回来。

2. 光有容器还不够:K8S各组件的分工与协作逻辑

单机环境下docker run已经够用,可一旦到了“多台机器、多个实例、随时会有节点挂掉”的规模,就会出现单机容器解决不了的三类问题:实例挂了怎么在其他机器自动拉起?多副本流量怎么均匀分发?版本发布怎么做到不停机平滑回滚?K8S的出现就是为了解决这些问题,它不替代Docker的容器能力,而是把自己定位成“容器编排层”。

K8S最核心的思路是声明式管理:你告诉它最终想要的状态,比如“我要3个副本,每个用1核2G”,剩下的创建、调度、健康检查、故障重启全部由它自己观测并补偿。你不需要手动指挥“先创建3个Pod,再删1个”,更不需要写脚本盯着状态反复修改。

2.1 控制面三件套:kube-apiserver、etcd与kube-scheduler的配合

kube-apiserver是集群唯一的入口。kubectl、内部所有组件、甚至外部系统想做任何操作,都要走它这一关。它负责权限校验、并发控制和审计日志,相当于集群的行政窗口。

etcd是集群的账本,所有对象的状态全部存在这里。你提交的Deployment、当前运行的Pod列表、节点心跳、Event事件,最终都会落到etcd里。正因为etcd如此重要,生产环境通常用奇数节点做Raft共识,保证少数节点挂掉时集群依然可以正常读写。

kube-scheduler的工作则简单又关键:它watch到未被分配的Pod,根据资源请求、节点亲和性、污点容忍等约束条件,挑一个合适的节点,然后把结果写回Pod的NodeName字段。注意,scheduler只负责“选人”,不负责“干活”。

一个Pod从提交到创建的链路是这样的:kubectl把Deployment对象发给apiserver,apiserver写入etcd;scheduler watch到新的Pod,完成节点选择;节点上的kubelet watch到Pod被调度到自己,才真正调用容器运行时创建容器。很多初学者以为scheduler直接创建Pod,其实中间隔着一次watch,这也是K8S一切“最终一致”的基础。

2.2 数据面:kubelet、kube-proxy与容器运行时各管什么

控制面负责做决策,数据面负责执行。每个节点上最核心的组件是kubelet,它向apiserver注册节点、上报心跳、执行探针,并且时刻watch自己节点上应该运行哪些Pod。真正和容器打交道的动作,比如拉镜像、启动容器、停止容器,是kubelet通过CRI接口发给containerd去做的。

kube-proxy负责维护Service的转发规则。Service在K8S里只是逻辑概念,ClusterIP这个虚拟IP并没有真正的网卡进程,它靠的是kube-proxy往iptables或ipvs里写入转发规则。访问ClusterIP的流量会被这些规则转发到后端某个Pod上,负载均衡因此成立。

Service的几种常见类型也要分清楚:ClusterIP是集群内部访问的虚拟IP;NodePort是把端口暴露到每个节点的IP上,外部可以直接访问“节点IP:端口”;LoadBalancer则是在NodePort之上自动对接云厂商的负载均衡器。很多人搞混Service和Ingress,简单说,Service是四层转发,Ingress是七层路由,负责按域名和路径把流量送到不同的Service。

2.3 为什么新版K8S不再直接依赖Docker

早期K8S要管理容器,确实调用的是Docker,当时kubelet里内置了一个叫dockershim的翻译层。后来容器运行时接口CRI逐渐成熟,containerd原生实现了CRI,K8S干脆把dockershim移除了,新版本节点上甚至不再直接调用Docker。

那“我到底还装不装Docker”?如果只是做K8S节点,装containerd就够了;如果你还需要在本地构建镜像,装Docker也没问题,但kubelet并不会直接通过Docker管理容器,真正干活的是containerd。docker CLI和dockerd只是“容器构建工具”,不是K8S的运行时依赖。

这个变化带来的运维习惯调整比想象中大。以前排查容器状态习惯性docker ps,现在要改成crictl ps或ctr;采集容器日志也不能再依赖Docker的日志路径,要走/var/log/containers/下的符号链接。

3. 实操:从零构建一套K8S集群的完整过程

这一节我们走一遍真实的集群搭建流程。建议准备三台Linux节点,每台至少2核4G,控制面节点尤其别低于2核,否则kubeadm初始化会直接报错。下面所有操作都按这个配置来。

3.1 环境准备:哪些参数不调好初始化必失败

节点系统装好后,第一步不是急着装K8S,而是把内核参数和基础环境调对。依次执行这些命令:

hostnamectl set-hostname k8s-master swapoff -a sed -ri 's/.*swap.*/#&/' /etc/fstab cat <<EOF | tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF modprobe overlay modprobe br_netfilter cat <<EOF | tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1 EOF sysctl --system

说下这几个操作背后的原因。kubelet对swap有强约束,默认不允许使用交换分区,不关闭swap,kubeadm init走到检查那一步就会卡住。加载br_netfilter并开启bridge-nf-call-iptables,是为了让iptables规则对容器网桥流量生效,这是Service转发能正常工作的重要前提。net.ipv4.ip_forward是转发的总开关,不开的话Pod之间跨节点通信会出问题。

另外注意检查节点之间端口连通性。控制面要放行6443、2379到2380、10250等端口,工作节点至少放行10250和NodePort范围30000到32767。安全组配不全,集群能初始化成功但业务访问会神秘超时,这种问题定位起来最折磨人。

3.2 安装并配置containerd:cgroup驱动必须和kubelet一致

节点上真正跑容器的引擎是containerd,先安装好,再生成一份默认配置:

apt-get install -y containerd mkdir -p /etc/containerd containerd config default > /etc/containerd/config.toml

拿到默认配置后,至少改两处。第一处把SystemdCgroup从false改成true,因为kubelet默认的cgroup驱动是systemd,而containerd默认用cgroupfs,两者不一致时,kubelet会一直认为节点没有就绪,Pod创建了也起不来。第二处是sandbox_image的地址,默认指向一个官方镜像,如果你所在网络的访问条件有限,换成你们内网仓库里能拉到的pause镜像。

sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml sed -i 's#sandbox_image = .*#sandbox_image = "registry.k8s.io/pause:3.9"#' /etc/containerd/config.toml systemctl restart containerd systemctl enable containerd

改完后用crictl验证一下能不能连通,如果exec插件没装,crictl可能连不上containerd。这个坑我踩过一次,明明containerd运行正常,crictl却报“unable to connect”,排查半天发现是cri插件没启用。确认containerd正常后,再装kubeadm、kubelet、kubectl三个组件,注意版本必须一致。

3.3 kubeadm初始化与网络插件选择

控制面初始化建议把参数写进配置文件,方便后续追踪。Pod网段这里用10.244.0.0/16,这个网段和后面要装的Flannel默认网段能对上,省得改来改去。

kubeadm init \ --apiserver-advertise-address=192.168.1.10 \ --pod-network-cidr=10.244.0.0/16

初始化成功后会输出一段join命令,里面包含了token和证书哈希,把这段命令保存到记事本,后面添加工作节点要用。初始化完之后,按提示执行mkdir -p $HOME/.kube && cp /etc/kubernetes/admin.conf $HOME/.kube/config,否则kubectl没有权限访问集群。

此时控制面节点的状态大概率是NotReady,因为缺少网络插件,节点之间没法正常通信。网络插件我在测试环境最常用Flannel,配置简单,一条命令就能部署;生产环境我倾向于Calico,因为它支持NetworkPolicy,能在Pod层面做更细粒度的访问控制。无论选哪个,根据kubeadm版本和你自己的网络环境,下载对应manifest,执行:

kubectl apply -f <你的网络插件manifest>

最后跑kubectl get nodes,所有节点都变Ready,集群才算真的活了。

3.4 第一个工作负载验证:Deployment、Service与滚动更新

集群就绪后,部署一个nginx服务验证全链路:

kubectl create deployment nginx --image=nginx:alpine --replicas=3 kubectl expose deployment nginx --port=80 --target-port=80 --type=NodePort kubectl get pods -o wide kubectl get svc

看到Pod分布在多个节点上,并且可以通过任意节点IP加NodePort访问到nginx页面,这条链路就算通了。做一次滚动更新和回滚验证,能直观体会到K8S的解耦能力:

kubectl set image deployment nginx nginx=nginx:1.25 kubectl rollout status deployment nginx kubectl rollout undo deployment nginx

整个过程中,旧版本Pod不会全量消失,新版本Pod逐个创建,流量逐步切换。注意观察kubectl get events --sort-by=.lastTimestamp,能看到这一步操作背后apiserver、scheduler、kubelet之间到底发生了多少轮watch和更新。

4. 生产实战必须搞对的四件事:资源配额、探针、持久化与滚动更新

集群搭起来只是开始,真正要在生产上稳定跑业务,下面四件事没做好,迟早会在半夜收到告警。

4.1 requests和limits不设置好,调度器等于闭眼干活

K8S调度器做节点选择时,依据的是Pod的requests,而不是limits。limits是运行时的资源上限,CPU是可压缩资源,超限会被节流;内存不可压缩,超限直接触发OOMKill。

看一个典型配置:

resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1 memory: 512Mi

如果不写requests,调度器很可能把一堆实例都塞到同一台节点上,表面上Pod都在Running,实际上节点内存早就超卖,某个高峰一来直接OOM。更稳妥的做法是在Namespace上配置ResourceQuota,从入口处限制整体资源水位,防止任何不守规矩的Deployment把集群资源吃穿。

另外一个常见坑是只设limits不设requests。limits超了CPU只会被节流,但Pod调度时不会参考limits,节点可能被调度到一台实际上并不够用的机器上,运行一段时间才发现性能异常。

4.2 探针不只是“活不活”,还要管“能不能服务”

livenessProbe决定容器要不要被杀掉重启,readinessProbe决定Pod要不要被摘掉流量,startupProbe保护启动慢的容器不被liveness误杀。三者的关系是:startup先跑,成功后才轮到liveness和readiness。

readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 20

最常见的问题是只配了liveness没配readiness。liveness探针一旦失败,Pod会被杀掉重建,但readiness缺失意味着Service后端从不会摘除这个Pod,新流量依然会打到一个异常的实例上,表现就是偶发超时。反过来,readiness探针失败导致的“Pod一直Running但不Ready”也经常把新手迷惑住。

探针的检测方式别随便用HTTP根路径,最好让应用暴露一个专门的healthz接口,这个接口不应该做太多业务检查,否则一次数据库抖动会让所有Pod被误杀,形成雪崩。

4.3 持久化:emptyDir、hostPath、PVC和StatefulSet怎么选

Deployment的Pod天生是“随时会替换”的物件,节点挂掉、滚动更新、OOM重启都可能让Pod被重建。这时候如果数据写在Pod本地,就等于写进空气里。emptyDir适合做缓存目录,生命周期跟Pod走;hostPath只能用于单节点测试,或者日志采集这类特例,生产环境千万别把数据库数据直接丢hostPath上。

真正要给有状态应用做持久化,应该用PV和PVC,再配合StorageClass实现动态供给。PVC是申请单,PV是实际分配的存储,StorageClass负责根据申请动态创建后端存储卷。小型团队我建议直接选一个长期维护的开源存储方案,比如NFS这类容易理解、故障模型清晰的方案,别自己写半吊子脚本去管理磁盘。

StatefulSet和Deployment的区别也要想清楚。Deployment的Pod是等价牛羊,谁顶替谁都可以;StatefulSet的Pod有稳定标识、稳定存储和顺序启停,适合数据库这类需要身份和持久状态的应用。这里讲一个真实教训:某同事把MySQL跑成Deployment,节点宕机后Pod漂到新节点,数据库文件却留在原节点的本地磁盘上,恢复数据花了一整个下午。他后悔没早用StatefulSet加PVC。

4.4 滚动更新:maxSurge和maxUnavailable决定了发布风险

Deployment默认是滚动更新,控制策略的两个核心参数是maxSurge和maxUnavailable。maxSurge表示更新期间最多可以多出几个Pod,maxUnavailable表示最多允许几个旧Pod先停掉。默认值通常是25%,对小规模应用够用,但压测过的高并发业务,往往会把maxUnavailable设成0,保证滚动过程中永远有足够的副本在接流量。

滚动更新真正安全的保障是readinessProbe。新Pod只有通过readiness检查之后,才会被加入Service后端并摘除旧Pod,否则更新会卡住。配置错误、镜像拉取失败这些情况,K8S都会暂停发布并保留旧版本Pod继续服务,业务基本无感。

日常发布后,养成用kubectl rollout history deployment/xxx查看版本历史的习惯,出问题就kubectl rollout undo deployment/xxx --to-revision=<版本号>回滚。命令能不能执行成功是一回事,思路清不清楚是另一回事。

5. 跑生产半年后,我总结出的五个K8S运维经验

集群稳定跑了一段时间之后会发现,K8S本身不容易挂,但人的操作很容易让它挂。下面这些经验,几乎每一个都是从线上事故里换来的。

5.1 别什么都往default命名空间里丢

没有命名空间概念的人,会把所有Deployment、ConfigMap、Secret都堆在default里。刚开始觉得方便,等到业务多了,资源配额没法按团队或环境划分,权限也没法隔离,整个default变得像一盘散沙。生产环境最好按业务线或环境拆分成多个namespace,配合ResourceQuota和RoleBinding做限制。

5.2 镜像tag别用latest

latest是一个没法追踪的标签。发生问题想回滚时,你根本无法判断当时拉取的到底是哪一版。生产镜像必须用唯一的构建号或commit短sha打tag,流水线把镜像地址和部署yaml一起归档。这样每次发布都有据可查,回滚也只需要改一个镜像版本号。

5.3 节点容量别靠“预感”,用describe数据说话

很多实例看着堆在同一台节点上没什么问题,其实节点早就被requests和limits填满了。遇到资源不均,先执行kubectl describe node看分配水位,再考虑加节点还是调整容灾。更规范的做法是给高可用要求的Deployment设置Pod拓扑分布约束,让副本尽量散到不同节点或不同可用区,避免一台节点挂了带走整个服务。

5.4 升级前先查兼容性矩阵

K8S、containerd、网络插件之间存在明确的版本兼容性要求,跳过兼容性检查直接升级,很容易出现Control Plane正常但数据面Pod一直异常的情况。有个真实案例我只升级了网络插件的小版本,结果和kubelet的接口对不上,整个期间Ingress流量大面积超时。后来果断回滚旧版本,业务才恢复。升级这种事,慢就是快。

5.5 备份etcd比备份任何yaml都重要

所有资源对象都存在etcd里,Ingress、Secret、Deployment、Namespace这些配置,本质上都是etcd里的数据。很多团队认真备份数据库,却完全不管etcd,结果节点磁盘故障导致etcd数据损坏,整个集群的配置灰飞烟灭。用etcdctl定期做快照,恢复到临时目录验证可读性,再收进备份体系。这些操作平时不起眼,真正要救火的时候,就是唯一的救命稻草。

从那次手忙脚乱的扩容事故,到今天集群能自动调度、滚动发布、故障恢复,整个过程里最值钱的其实不是某个命令,而是对组件分工和故障边界的理解。Docker负责让应用“打包即走”,K8S负责让集群“声明即达”,两者一个管单机,一个管集群,边界清晰,各司其职。希望这篇能帮你少踩几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询