简介:这份文档面向希望深入理解容器与Kubernetes底层逻辑的开发者、运维人员及架构学习者,从开发过程、应用架构、部署打包三条主线梳理容器技术的发展脉络,帮助读者弄清容器真正解决什么问题、在技术演进中处于何种历史定位。资源包内含1个docx文档,约311KB,以图文结合的方式展开叙述,便于通读与查阅。内容从瀑布式、敏捷式到DevOps的开发过程演变切入,分析容器在各阶段扮演的角色,再延伸至单体架构、多层架构与微服务架构的变迁,说明容器为何成为微服务的理想载体,最后落到Dockerfile标准化构建与Kubernetes编排管理,讲清容器化打包如何解决版本依赖与环境一致性问题。目前已有241人学习,适合作为理解容器技术来龙去脉、建立知识框架的入门与进阶参考。
1. 容器这二十年:从 chroot 到 Kubernetes,一条被反复重写的路
2006 年前后,Google 内部已经在用 cgroups 做进程组的资源限制,那时候没人管它叫「容器」。真正让这个词出圈的是 2013 年 Docker 把镜像、仓库、运行时打包成一套开发者能上手的工具链。再往后 Kubernetes 在 2015 年开源,容器编排从「自己写脚本」变成「声明式 API」。这条线看起来是工具迭代,本质是隔离边界和交付契约两件事被反复重写。
今天你打开任何一个 Java 微服务项目,大概率会看到 Dockerfile、docker-compose.yml、k8s 的 deployment.yaml 三件套。容器已经不是「要不要用」的问题,而是「怎么用得不翻车」的问题。这篇笔记按时间线拆开:每一代容器解决了什么、留下了什么坑、今天做 DevOps 落地时哪些历史包袱还在影响你。适合正在把单体拆微服务、或者第一次搭 Kubernetes 集群的工程师。
2. 从 chroot 到 Docker:隔离技术是怎么一层层堆出来的
2.1 chroot、namespace、cgroups 各自解决什么问题
容器不是一项技术,是三项 Linux 内核能力的组合。理解这一点,后面所有「容器里为什么看不到宿主机进程」「为什么内存限制不生效」的问题都能自己推出来。
chroot(1979,Version 7 Unix)只改根目录。它把进程看到的/换成另一个目录,但进程仍然共享网络、PID、用户。所以 chroot 逃逸是经典问题——只要进程有 root 权限,就能chroot回真实根目录。它解决的是「文件系统视图」,不是隔离。
namespace(2002 起,Linux 2.4.19 引入 mount namespace)解决「看到什么」。每个 namespace 类型隔离一类全局资源:
| namespace | 隔离内容 | 内核版本 |
|---|---|---|
| Mount | 挂载点 | 2.4.19 |
| UTS | hostname/domain | 2.6.19 |
| IPC | 信号量、消息队列 | 2.6.19 |
| PID | 进程 ID 空间 | 2.6.24 |
| Network | 网卡、路由、端口 | 2.6.29 |
| User | 用户/组 ID 映射 | 3.8 |
| Cgroup | cgroup 根目录视图 | 4.6 |
cgroups(2007,Linux 2.6.24)解决「能用多少」。CPU、内存、IO、PID 数量都能限。注意 cgroups v1 和 v2 的差异:v1 每种资源一个层级,v2 统一成单一层级树。Kubernetes 1.25 之后默认走 v2,这也是为什么老教程里的--cgroup-driver=systemd参数在新集群上行为不一样。
三者叠加才是「容器」。Docker 早期用 libcontainer,后来拆出 runc,本质就是「调这三样内核能力 + 打包镜像」。
2.2 手写一个最小容器:不用 Docker 也能跑起来
想真正理解容器,最快的办法是绕开 Docker,用unshare和chroot手动拼一个。下面这段在 Ubuntu 22.04、内核 5.15 上验证过。
# 准备一个最小根文件系统(用 busybox 静态编译版最省事) mkdir -p /tmp/miniroot/{bin,proc,sys,dev} cp /bin/busybox /tmp/miniroot/bin/ cd /tmp/miniroot && ./bin/busybox --install -s ./bin # 用 unshare 创建新的 PID/Mount/UTS/IPC namespace # --fork 让 unshare 自己 fork,--mount-proc 自动挂载 /proc sudo unshare --pid --mount --uts --ipc --fork --mount-proc \ chroot /tmp/miniroot /bin/sh # 进入后验证:hostname 是独立的,ps 只看到自己 hostname mini-container hostname # 输出 mini-container,宿主机不受影响 ps -ef # 只有 sh 和 ps 两个进程逻辑说明:unshare负责创建 namespace,--fork是关键——不 fork 的话 PID namespace 里第一个进程还是当前 shell,ps会看到宿主机进程。--mount-proc让新 PID namespace 里的/proc正确反映隔离后的进程树,否则ps读到的还是宿主机的。
参数说明:--pid隔离进程号,--mount隔离挂载点,--uts隔离 hostname,--ipc隔离进程间通信。没加--net是因为手动配 veth 网卡比较绕,Docker 帮你做了这部分。想加资源限制,在unshare外面套systemd-run --scope -p MemoryMax=128M,这就是 cgroups 的入口。
跑通这个,你就明白 Docker 的docker run背后大概做了哪些 syscall。后面遇到「容器里 PID 是 1 的进程为什么不能随便 kill」这类问题,答案就在 PID namespace 的 init 语义里。
2.3 Docker 做对了什么:镜像分层与 OCI 标准
内核能力 2008 年就齐了,为什么容器 2013 年才火?因为 Docker 解决了交付问题。
镜像分层(UnionFS/AUFS/OverlayFS)让「基础镜像 + 应用层」可以复用,拉取时只传增量。Dockerfile 的每条指令生成一层,层是只读的,容器启动时在最上面加一个可写层。这个设计带来两个后果:一是镜像层数太多会拖慢启动,二是可写层的数据随容器删除而消失——这就是为什么必须挂 volume。
2015 年 OCI(Open Container Initiative)成立,把镜像格式(image-spec)和运行时(runtime-spec)标准化。今天 containerd、CRI-O、Podman 都能跑同一份镜像,Docker 只是其中一个实现。Kubernetes 1.24 移除 dockershim 就是这个标准化的结果——kubelet 直接通过 CRI 调 containerd,不再经过 Docker daemon。
# 看镜像分层,理解为什么你的镜像 2GB docker history --no-trunc myapp:latest # 用 dive 工具逐层看哪些文件被加进来 docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \ wagoodman/dive:latest myapp:latest常见做法是把RUN apt-get install和RUN apt-get clean写在同一层,否则清理动作在下一层,前一层的大小照样算进镜像。这是镜像瘦身最容易被忽略的一条。
3. 编排时代:Kubernetes 把容器从「能跑」推到「能管」
3.1 为什么单机 Docker 撑不住微服务
一台机器跑十几个容器没问题,但微服务架构下你会遇到:容器挂了谁重启、扩容时新容器调度到哪台、服务之间怎么发现、滚动更新怎么保证不中断。这些是编排问题,Docker Compose 只能解决单机,Swarm 没打赢,最后 Kubernetes 成了事实标准。
Kubernetes 的核心抽象是声明式:你写 YAML 描述「我要 3 个副本、镜像版本 v2、暴露 80 端口」,控制器循环对比期望状态和实际状态,差多少补多少。这跟 Docker 的「我执行 run 命令」是两种思路。声明式的好处是自愈和幂等,代价是调试时你得理解控制器在干什么——「为什么 Pod 一直 Pending」这类问题,答案往往在调度器或 PVC 绑定逻辑里,不在你的 YAML 表面。
3.2 一个能跑的最小 Deployment:从 YAML 到 Service
下面这份 YAML 在 Kubernetes 1.26 上验证过,包含 Deployment、Service、探针三个最常被写错的点。
apiVersion: apps/v1 kind: Deployment metadata: name: demo-api spec: replicas: 3 selector: matchLabels: app: demo-api template: metadata: labels: app: demo-api spec: containers: - name: api image: demo-api:v1 ports: - containerPort: 8080 resources: requests: # 调度依据,必须设 cpu: 100m memory: 128Mi limits: # 上限,超了 OOMKilled cpu: 500m memory: 512Mi readinessProbe: # 就绪探针,没过不接流量 httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: # 存活探针,没过重启容器 httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 20 --- apiVersion: v1 kind: Service metadata: name: demo-api-svc spec: selector: app: demo-api ports: - port: 80 targetPort: 8080 type: ClusterIP逻辑说明:requests决定调度到哪台节点,limits决定 cgroup 上限。CPU 超 limits 会被 throttle(不杀进程),内存超 limits 直接 OOMKilled。readinessProbe 和 livenessProbe 的区别是新手最容易翻车的地方——readiness 没过只是不接流量,liveness 没过会重启。把 liveness 的initialDelaySeconds设太短,应用还没启动完就被反复重启,日志里看到的就是 CrashLoopBackOff。
参数说明:cpu: 100m是 0.1 核,memory: 128Mi是 128 MiB。生产环境 requests 和 limits 不要设成一样,除非你确定应用内存曲线平稳。periodSeconds和timeoutSeconds要配合应用启动时间调,Java 应用冷启动 30 秒很常见,initialDelaySeconds至少给 20。
# 部署并观察 kubectl apply -f demo-api.yaml kubectl get pods -w # 看 Pod 从 Pending 到 Running kubectl describe pod <pod-name> # Pending 时看 Events 段 kubectl logs <pod-name> --previous # 容器重启后看上一次的日志kubectl describe的 Events 段是排查第一现场,调度失败、镜像拉取失败、探针失败都会写在这里。养成先看 Events 再看日志的习惯,能省一半时间。
3.3 微服务拆分与容器粒度的对应关系
微服务架构图里画的服务边界,落到 Kubernetes 上就是 Deployment 的边界。一个常见误区是按「一个进程一个容器」拆得太细,结果 20 个微服务对应 20 个 Deployment,每个都要配 Service、ConfigMap、HPA,运维成本爆炸。
我一般按团队边界 + 数据边界来定容器粒度:同一个团队维护、共享同一个数据库 schema 的模块,先放一个 Deployment 里用多容器 Pod 或者干脆一个进程多模块。等团队和流量都涨起来再拆。微服务拆分不是越细越好,拆分成本包括网络调用、分布式事务、链路追踪,这些在单体里是不存在的。
容器资源隔离在这里有个实际影响:如果两个微服务放同一个 Pod,它们共享 network namespace,localhost能互通,但 CPU/内存 limits 是各自独立的。想省资源可以放一起,想故障隔离就分开。这个取舍没有标准答案,取决于你的故障域要求。
4. 避坑与排查:容器落地时最常翻车的 5 个场景
4.1 镜像越做越大,拉取慢到超时
现象:kubectl describe pod显示ImagePullBackOff,或者拉取耗时几分钟。docker history看到镜像 2GB+。
原因:基础镜像选了ubuntu:latest而不是alpine或distroless;RUN apt-get update && apt-get install和RUN rm -rf /var/lib/apt/lists/*分成两层,清理不生效;构建时把.git、node_modules、测试文件都 COPY 进去了。
解决:用多阶段构建,构建阶段用完整镜像,运行阶段只 COPY 产物。加.dockerignore排除无关文件。基础镜像优先gcr.io/distroless或alpine,Java 应用可以用eclipse-temurin:17-jre-alpine。改完镜像通常能从 2GB 降到 200MB 以内。
4.2 容器内存超限被 OOMKilled,但监控看不到峰值
现象:Pod 反复重启,kubectl describe显示Last State: Terminated, Reason: OOMKilled。但应用自己的监控面板内存才用了 60%。
原因:JVM 在容器里默认按宿主机内存算堆大小,-Xmx没设或者设得比 limits 大。Java 8u191 之前不认 cgroup limits,之后需要-XX:+UseContainerSupport(默认开)。另外堆外内存(Metaspace、直接内存、线程栈)不算在堆里,但算在 cgroup 内存里。
解决:显式设-XX:MaxRAMPercentage=75.0,让 JVM 按容器 limits 的 75% 算堆。limits 设 512Mi 的话,堆大概 384Mi,剩下留给堆外。用kubectl top pod看实际用量,配合-XX:NativeMemoryTracking=summary排查堆外泄漏。
4.3 时区不对,日志时间差 8 小时
现象:容器里date显示 UTC,应用日志时间戳跟宿主机对不上,排查问题时时间线错乱。
原因:基础镜像默认 UTC,容器不继承宿主机时区。/etc/localtime在镜像里是 UTC 版本。
解决:Dockerfile 里加ENV TZ=Asia/Shanghai并RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime。或者在 Pod spec 里挂载宿主机的/etc/localtime。注意 Alpine 镜像要先apk add tzdata,否则/usr/share/zoneinfo是空的。
4.4 Service 访问不通,但 Pod 本身正常
现象:kubectl exec进 Pod 能 curl 通自己,但从另一个 Pod 访问 Service 的 ClusterIP 超时。
原因:Service 的selector和 Pod 的labels对不上,Endpoints 为空。或者targetPort写错,指向了容器没监听的端口。也可能是 NetworkPolicy 挡了。
解决:kubectl get endpoints <svc-name>看有没有后端。为空就是 selector 问题。kubectl get svc <svc-name> -o yaml核对 targetPort 和 containerPort。NetworkPolicy 用kubectl describe networkpolicy看规则。DNS 问题用nslookup <svc-name>.<namespace>.svc.cluster.local验证。
4.5 容器里改的文件重启就没了
现象:进容器改了配置文件,docker restart或 Pod 重建后改动消失。
原因:容器的可写层随容器生命周期存在,删除即丢。Kubernetes 里 Pod 重建会创建新容器,可写层是全新的。
解决:配置文件用 ConfigMap 挂载,敏感信息用 Secret,持久数据用 PVC。改 ConfigMap 后需要kubectl rollout restart deployment让 Pod 重建才能生效(除非用了 reloader 这类工具)。记住一个原则:容器里除了临时文件,什么都不该写。
5. 进阶:用 kind 在本地复现一套多节点集群,验证你的 YAML
生产集群不好随便试错,本地用 kind(Kubernetes in Docker)起多节点集群是最省事的验证方式。它把每个节点跑成一个 Docker 容器,支持多节点、端口映射、本地镜像加载。
# 安装 kind(macOS/Linux) brew install kind # 或 go install sigs.k8s.io/kind@latest # 写一个三节点集群配置:1 control-plane + 2 worker cat > kind-config.yaml <<'EOF' kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane extraPortMappings: - containerPort: 30080 # 映射到宿主机,方便访问 NodePort hostPort: 30080 - role: worker - role: worker EOF # 创建集群,指定 Kubernetes 版本 kind create cluster --name demo --config kind-config.yaml \ --image kindest/node:v1.26.0 # 把本地构建的镜像直接加载进集群,不用推仓库 docker build -t demo-api:v1 . kind load docker-image demo-api:v1 --name demo # 部署并验证 kubectl apply -f demo-api.yaml kubectl get nodes -o wide kubectl get pods -o wide # 看 Pod 分布到两个 worker 上逻辑说明:extraPortMappings把集群内 NodePort 映射到宿主机,本地浏览器能直接访问。kind load docker-image解决本地镜像拉取问题——kind 节点是 Docker 容器,默认拉不到你本地 build 的镜像,必须先 load 进去。--image指定节点镜像版本,对应 Kubernetes 版本,v1.26.0 的节点镜像就是kindest/node:v1.26.0。
参数说明:--name给集群命名,多集群共存时用kubectl config use-context kind-demo切换。kind delete cluster --name demo清理。节点数按需加,但注意每个节点是一个 Docker 容器,内存占用不小,8GB 内存的机器建议不超过 3 个节点。
验证 YAML 时重点看三件事:Pod 是否调度到不同节点(验证亲和性和资源 requests)、Service 是否能跨节点访问(验证 kube-proxy 和网络插件)、滚动更新是否平滑(kubectl rollout status deployment/demo-api观察)。kind 默认用 kindnet CNI,够用但不支持 NetworkPolicy,要测网络策略得换 Calico。
我自己的习惯是:任何要上生产的 YAML,先在 kind 里跑一遍kubectl apply --dry-run=server,再实际部署看 Events。本地翻车比生产翻车便宜太多。这套流程跑熟之后,从写完 YAML 到验证通过大概 5 分钟,比直接改生产配置再回滚快得多。希望帮到你。
本文还有配套的精品资源,点击获取