Skip to main content

4. Pod:最小的调度单元

为什么不能直接调容器​

你可能会想:K8S 直接编排容器不行吗?为什么中间要插一层 Pod?

一个生产场景:nginx 需要把 access log 写到文件,旁边跑个 fluentd 把日志扫走推给 Kafka。

如果 K8S 直接管容器,你需要手动保证:(1)nginx 和 fluentd 跑在同一台机器上;(2)它们共享一个日志目录;(3)网络互通(localhost 通信)。

这堆东西本质上是一个**"需要一起跑的单位"**。K8S 干脆把这个单位抽象成一个对象——Pod。

但在讲 Pod 之前,有一个更基础的概念——这些 Pod 放在哪?

Namespace:集群内的分组隔离​

Namespace 是 K8S 里最朴素也最容易被低估的概念。它不隔离 Pod 之间的网络(默认所有 Namespace 的 Pod 网络互通),也不隔离节点资源。它只隔离名字和 RBAC 权限。

kubectl create namespace team-a
kubectl create namespace team-b

kubectl -n team-a run nginx --image=nginx
kubectl -n team-b run nginx --image=nginx
# 两个 Pod 同名但相安无事——因为在不同的 namespace

Namespace 只隔离了三样东西:

隔离什么不隔离什么
资源名称(同 namespace 不能重名)Pod 网络(默认所有 Pod 互通)
RBAC 权限(Role 绑定到 namespace)Node 资源(不按 namespace 划分)
ResourceQuota(CPU/内存限制)PersistentVolume(PV 是集群级的)

底层实现很简单——API Server 把对象存在 etcd 时,key 上带了 namespace:/registry/pods/team-a/nginx vs /registry/pods/team-b/nginx。两个路径互不相干。

什么是"default" namespace? 不写 -n 默认操作的就是 default namespace。所有 K8S 集群都有一个,它不是特殊的——只是名字叫 default。你可以把默认 context 改成其他 namespace。

kube-system / kube-public / kube-node-lease 是什么? 系统 namespace:

Namespace放了什么
kube-system系统组件(CoreDNS、kube-proxy、CNI、Device Plugin)
kube-public集群信息 ConfigMap(cluster-info)
kube-node-leaseNode 心跳 Lease——代替旧版的 Node Status 心跳,更轻量

思考题:Namespace 看起来就一个文件夹隔离——跟 Docker 的 namespace 是同一件事吗?提示:Docker 用的是 Linux kernel namespace,K8S 的 Namespace 是 API Server 层的概念。

Pod 是一组共享 namespace 的容器的集合。

思考题:为什么叫 "Pod" 这个名?Docker 社区里 whale(鲸鱼)跟 container(集装箱)的隐喻都说得通——那 Pod 呢?去查一下 "pod of whales" 或者 "pea pod"。

共享哪些 namespace?

namespace效果
net共享 IP 和端口空间,localhost 即可通信
IPC共享信号量和共享内存
Volume挂同一个 Volume,共享文件
apiVersion: v1
kind: Pod
metadata:
name: nginx-with-fluentd
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: log-volume
mountPath: /var/log/nginx
- name: fluentd
image: fluentd
volumeMounts:
- name: log-volume
mountPath: /var/log/nginx
volumes:
- name: log-volume
emptyDir: {}

nginx 写 /var/log/nginx/access.log,fluentd 在同一个路径直接读到。因为它们挂在同一个 log-volume 上,而这个 volume 本质上是个主机上的临时目录,被 bind mount 到两个容器的 namespace 里了。

源码视角:Pod 怎么"组装"出来​

kubelet 收到调度来的 Pod 之后,调用 CRI 的 RunPodSandbox。这个概念很重要——Pod Sandbox 先创建出来,提供一个共享的"壳",容器再放进去。

// kubelet/pkg/pod_workers.go 简化
func (p *podWorkers) syncPod(ctx context.Context, update workerUpdate) {
// 1. 确保 Pod Sandbox 存在
podSandboxID := c.RuntimeService().RunPodSandbox(ctx, podSandboxConfig)

// 2. 为每个容器创建并启动
for _, container := range pod.Spec.Containers {
containerID := c.RuntimeService().CreateContainer(ctx, containerConfig, podSandboxID)
c.RuntimeService().StartContainer(ctx, containerID)
}
}

RunPodSandbox 在 containerd 里实际做的事情(containerd/pkg/cri/server/sandbox_run.go):

  1. 创建 net namespace(给 Pod 分配独立 IP)
  2. 配 CNI 网络(这个网络是给 Pod 的,不是给单个容器的)
  3. 起一个 pause 容器(一个什么都不做的程序,只是占着 namespace 不被回收)

pause 容器就是 Pod 的"壳"。它不执行业务逻辑,一直 sleep,保证 net namespace 不被内核回收。所有业务容器通过 docker run --net=container:<pause_container_id> 加进这个 namespace,于是它们共享了同一个 IP。

Container Lifecycle Hooks:容器级别的钩子​

Pod 下的每个容器可以注册两个钩子——在容器启动后(postStart)和容器停止前(preStop)执行:

spec:
containers:
- name: nginx
image: nginx
lifecycle:
postStart:
exec:
command: ["/bin/sh", "-c", "echo 'Container started' >> /var/log/lifecycle.log"]
preStop:
exec:
command: ["/bin/sh", "-c", "nginx -s quit && sleep 10"]

postStart——容器创建后立刻执行,跟容器的主进程并行跑。不能用来做初始化(那个应该放 Init Container)——因为它跟主进程的启动竞速。适合做健康注册、通知外部系统"我起来了"。注意:postStart 失败不阻止容器启动——它会继续跑主进程,但 kubelet 会记录失败事件。

preStop——kubelet 发送 SIGTERM 之前执行。这才是生产上最常用的:给进程一个优雅退出的机会。

preStop + terminationGracePeriodSeconds 的配合:

kubectl delete pod → Pod 状态变为 Terminating
→ 同时发生两件事:
1. Pod 从 Service Endpoints 移除(不再接收新流量)
2. kubelet 执行 preStop hook(同步等待)

→ preStop 执行完后 → kubelet 发 SIGTERM 给主进程
→ 主进程处理退出(等待 terminationGracePeriodSeconds)
→ 超时还没退出 → SIGKILL

关键点:preStop 消耗的时间算在 terminationGracePeriodSeconds 里。如果你设了 terminationGracePeriodSeconds: 30,但 preStop 跑了 25 秒——主进程只有 5 秒处理 SIGTERM。所以 preStop 要短,gracePeriod 要够:

spec:
terminationGracePeriodSeconds: 60 # 总共给 60 秒
containers:
- name: nginx
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # preStop 5 秒
# SIGTERM 之后主进程有 55 秒退出

Init Containers:容器启动前的"前置任务"​

Init Container 跟在 Pod 里的常规 Container(主进程)不一样——它是 Sequential 的,跑完退出才拉下一个:

spec:
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z db-service 3306; do sleep 2; done']
- name: init-config
image: my-init-image
command: ['sh', '-c', 'curl -o /config/app.yaml http://config-server/config']
volumeMounts:
- name: config
mountPath: /config
containers:
- name: app
image: my-app
volumeMounts:
- name: config
mountPath: /etc/app
volumes:
- name: config
emptyDir: {}

运行顺序:wait-for-db 跑完退出 → init-config 跑完退出 → 主容器 app 启动。如果一个 Init Container 失败——这个 Pod 处于 Init:N/M 状态,不会进入 Running。kubelet 按 restartPolicy 重试(默认 Always)。

为什么不用主容器的 start 脚本来做? 分离关注点。Init Container 有自己的镜像、自己的资源限制——你可以用一个"等数据库就绪"的容器(带 nc)跟主应用(带 Python/Java 环境)分离开。主应用不需要装 nc、不需要担心启动脚本失败的状态处理。

常见的 Init Container 模式:

模式做什么例子
等待依赖就绪数据库、Redis 没 Ready 之前不启动主容器nc -z / curl 轮询
拉取外部资源从配置中心/S3 下载文件到共享 Volumecurl / aws s3 cp
预处理数据解压、转码、复制文件tar -xzf / java -jar processor.jar
等待另一个 Pod等待一个特定的条件满足kubectl wait --for=condition=Ready pod/other-app

所以你进入 Pod 里的一个容器 ip addr,看到的就是 Pod 的 IP。进入另一个容器,看到的是同一个 IP。这就是 "Pod 内 localhost 互通" 的底层实现。

从代码看 Pod 的生命周期​

Pod 的状态流转在 k8s.io/api/core/v1/types.go 里定义得很清楚:

type PodPhase string
const (
PodPending PodPhase = "Pending" // 被 API Server 接受了,但有一个以上容器没 Ready
PodRunning PodPhase = "Running" // 所有容器都 Ready 了,至少有一个在跑
PodSucceeded PodPhase = "Succeeded" // 所有容器成功退出
PodFailed PodPhase = "Failed" // 有容器非 0 退出
PodUnknown PodPhase = "Unknown" // kubelet 失联,状态未知
)

每个 Phase 的触发条件在 kubelet/pkg/kubelet/kubelet.go 的 generateAPIPodStatus 里:

// 计算 Pod 状态的最核心逻辑简化版
switch {
case pod.DeletionTimestamp != nil:
phase = computeTerminationStatus(...)
case hasRunningContainer(pod):
phase = PodRunning
case hasPendingContainer(pod):
phase = PodPending
}

这里没有"中间态"(比如 Terminating 不是一个 Phase,而是 DeletionTimestamp 非空但 Phase 仍是 Running)。这个设计权衡在很多源码注释里都有讨论,理解它的代价比困惑要小。

思考题:K8S 里删除一个 Pod,不是"立刻消失",而是先打上 DeletionTimestamp 标记,等优雅关闭逻辑跑完才真删。为什么不直接删掉?标记删除跟 Finalizer 机制有什么关系?

基础配置注入:ConfigMap / Secret​

ConfigMap 和 Secret 原本想单独开一章,后来发现没必要。它们就是把配置注入 Pod 的三种方式:

# 方式一:环境变量
spec:
containers:
- envFrom:
- configMapRef:
name: app-config

# 方式二:挂载文件
spec:
containers:
- volumeMounts:
- name: config
mountPath: /etc/config
volumes:
- name: config
configMap:
name: app-config

# 方式三:命令行参数
spec:
containers:
- args: ["--port=$(SERVICE_PORT)"]
env:
- name: SERVICE_PORT
valueFrom:
configMapKeyRef:
name: app-config
key: port

三种方式看起来只是 YAML 的写法不同,但底层实现路径完全不一样。

数据存在哪、怎么拿​

ConfigMap 和 Secret 写进去之后,API Server 把它们存进 etcd——跟 Pod、Deployment 一样的路径结构:

/registry/configmaps/default/app-config
/registry/secrets/default/db-password

Secret 在写 etcd 时比 ConfigMap 多了一步:API Server 把 data 字段的每个 value 做 base64 encode(stringData 用明文写,API Server 帮你转成 data)。注意,这不是加密——base64 是编码,任何人拿到 etcd 数据都能解码。如果你需要加密存储,要配 API Server 的 EncryptionConfiguration,让 etcd 落盘前用 AES 加密 Secret 的 value。

kubelet 不直接从 etcd 读 ConfigMap。它 Watch API Server 的 ConfigMap 变更事件,本地维护一份缓存。当 kubelet 需要给一个 Pod 注入 config 时,它从本地缓存拿数据,不经过 etcd。

三种注入的底层路径​

环境变量方式——kubelet 在 syncPod → startContainer 时,从本地缓存拿到 ConfigMap 的 key-value,直接拼到 OCI spec 的 process.env 字段里:

// kubelet/pkg/kubelet/kubelet_pods.go 简化逻辑
func MakeEnvironmentVariables(pod, container, configMaps) []v1.EnvVar {
for _, envFrom := range container.EnvFrom {
cm := configMaps[envFrom.ConfigMapRef.Name]
for key, value := range cm.Data {
env = append(env, v1.EnvVar{Name: key, Value: value})
}
}
return env
}

然后 containerd 启动容器时,这些 env 就跟 Dockerfile 里写的 ENV 一样,放到进程的环境变量里去了。

但环境变量方式有一个坑:ConfigMap 更新之后,已经在跑的 Pod 不会自动拿到新值。进程启动时环境变量就确定了,kubelet 不会热更新已运行容器的 env。你想让 Pod 看到新配置,得触发 Pod 重建。

文件挂载方式——kubelet 把 ConfigMap 的每个 key 写成一个文件,放到 Node 上一个临时目录,然后 bind mount 到容器的 mountPath:

# Node 上的真实路径
/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~configmap/config/
├── app.properties ← ConfigMap 里的 key="app.properties"
└── log-level ← ConfigMap 里的 key="log-level"

# 容器内看到的是 mount 后的结果
/etc/config/
├── app.properties
└── log-level

kubelet 在 volume_manager.go 里管这个——先把 configmap 数据写到 Node 上的 tmpfs/hostPath,再用 mount --bind 挂进容器的 mount namespace。

跟环境变量最大的区别是:文件挂载会自动更新。kubelet Watch 到 ConfigMap 变了之后,会更新 Node 上的文件内容,容器里通过文件系统读到的内容也跟着变。不过这个更新有延迟——kubelet 的同步周期默认 1 分钟(--sync-frequency),不是实时的。

思考题:kubelet Watch 到 ConfigMap 变更后会更新文件——那你的应用怎么"感知"到配置文件变了?是用 inotify 还是定期 stat 文件?在 Go 里怎么写一个通用的配置热更新检测逻辑?

命令行参数方式——底层用的是环境变量,只不过多了一层 $(VAR_NAME) 模板展开。kubelet 在起容器前先把 args 里的 $(SERVICE_PORT) 替换成 env 的值,然后传给 CRI。

Secret 的额外安全处理​

Secret 跟 ConfigMap 唯一的实现级区别在于挂载方式。Secret 挂载时,kubelet 用的是 tmpfs(内存文件系统),不会落到磁盘:

// kubelet/pkg/volume/secret/secret.go 挂载逻辑
func (b *secretVolumeMounter) SetUpAt(dir string) error {
// dir = /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~secret/db-password
// 挂载时的 options: tmpfs, nodev, nosuid, noexec
wrappedVolumeSpec := &volume.Spec{
Volume: &v1.Volume{
VolumeSource: v1.VolumeSource{
EmptyDir: &v1.EmptyDirVolumeSource{
Medium: v1.StorageMediumMemory, // 内存,不落盘
},
},
},
}
}

StorageMediumMemory 意味着 Secret 文件存在于 RAM 中。容器里 cat /etc/secret/db-password 能读,但宿主机的磁盘上找不到明文。容器删了,内存释放——这是 Secret 跟 ConfigMap 最底层的差异。

安全的底线:tmpfs 不是银弹​

tmpfs + base64 encode 这套组合给了很多人"Secret 很安全"的错觉。实际上:

  1. base64 不是加密——echo "cGFzc3dvcmQ=" | base64 -d 一秒还原
  2. tmpfs 只防磁盘残留,不防容器内进程读——Pod 里跑 cat /etc/secret/db-password 一样能看到
  3. etcd 里存的 Secret 默认明文——除非你配了 EncryptionConfiguration

真正防读写靠的是 RBAC。没 RBAC 限制谁 kubectl get secret,tmpfs 只是让你在宿主机硬盘上找不到明文——但如果能调 API,所有 Secret 随便读。Secret 的安全核心在第 8 章展开,这里先把实现细节讲清楚。

K3S 的 Secret 处理​

K3S 的 kubelet 用的跟 K8S 是同一套代码。ConfigMap 的三种注入、Secret 的 tmpfs 挂载——底层实现完全一致。

如果 K3S 用 SQLite 后端(默认),Secret 经过 base64 encode 后存在 SQLite 表里,跟 etcd 一样是明文。如果 K3S 配了 etcd 后端 + EncryptionConfiguration,一样能加密存储。

K3S 里的 Pod 处理​

K3S 在 Pod 的处理上没有对逻辑做任何改动。kubelet、CRI、Pod Sandbox 模型全部跟 K8S 一样。K3S 做的是把 containerd 内置了,所以 RunPodSandbox 最终走到同一个 CRI 实现。

// k3s/pkg/agent/containerd/containerd.go 中连接 containerd
func Client(ctx context.Context) (*containerd.Client, error) {
return containerd.New("/run/k3s/containerd/containerd.sock")
}

无论你用的是 K8S + 外部 containerd 还是 K3S 内置的 containerd,kubelet 调的都是同一套 CRI 接口。Pod 这个概念不因发行版而不同。

小结​

  • Pod 是一组共享 net/IPC/Volume namespace 的容器
  • Pod Sandbox 是"壳"——先建壳、再放容器
  • pause 容器占着 namespace 不让它被回收
  • Pod Phase 只有 5 个,Terminating 不是一个 Phase(DeletionTimestamp + Running)
  • ConfigMap/Secret 有三种注入方式:env、file、args
  • K3S 在 Pod 层面没做任何"优化",全部借用了 K8S 的实现

解答​

0. Docker 的 namespace 跟 K8S 的 Namespace 是同一件事吗?

不是,完全两个东西。

Docker 的 namespace 是 Linux kernel namespace——第一章讲的那个 clone(CLONE_NEWPID) 创建出来的隔离空间。它是内核特性,让不同进程看到不同的 PID 树、网络栈、文件系统。

K8S 的 Namespace 是 API Server 层面的逻辑分组——etcd 里 key 的一个前缀。它不隔离网络、不隔离进程、不隔离内核。它只隔离名称和 RBAC。

两者唯一的共同点是"名字都叫 namespace"——这是最坑新人的命名巧合。

1. 为什么叫 Pod?

Pod 这个词有两个来源,都挺形象。

一个是"pod of whales"(鲸群)。Docker 的标志是鲸鱼,容器是 whale 驮着的集装箱。一组紧密协作的容器就像一个"鲸群"(pod),一起游,共享同一片水域(namespace)。K8S 社区里 Joe Beda(K8S 创始人之一)确认过这个灵感来源。

另一个是"pea pod"(豌豆荚)。豌豆荚里几颗豌豆挤在一起,共享同一个外壳(Sandbox)。容器也一样——多个进程共享 Pod 的 net/mount/IPC namespace,外面看不出来里面有几个。

两个比喻指向同一个概念:一个外壳包着几个紧密协作的东西。

2. 资源为什么是标记删除而不是直接删除?

K8S 里 kubectl delete pod 不会让 Pod 立刻消失,而是给 Pod 加一个 metadata.deletionTimestamp 字段,Pod 进入 Terminating 状态(仍算 Running Phase),等优雅关闭流程跑完才真正删除 etcd 里的记录。

这个设计背后是 Finalizer 机制。

kubectl delete pod nginx
│
├─ API Server 给 Pod 打上 deletionTimestamp
│
├─ 所有 Controller Watch 到这个 Pod 有 deletionTimestamp 了
│ 每个 Controller 检查自己的 Finalizer 还在不在
│ └─ "volume-protector" Finalizer 还在?→ detach PV → 移除它
│ └─ "foregroundDeletion" → 等所有 dependents 删完 → 移除它
│
└─ 所有 Finalizer 都被移除了 → API Server 真正删除 etcd 记录

Finalizer 是挂在资源上的"清理清单"。每个 Controller 可以在对象上注册自己的 Finalizer——"这个对象要删了?我先把我负责的外部资源清掉(detach PV、删云上 LoadBalancer、通知外部系统),再移除我的 Finalizer"。

只有所有 Finalizer 都被移除之后,API Server 才会真正从 etcd 删掉这个对象。

Pod 最常见的 Finalizer 是 kubernetes.io/pv-protection(由 PV Controller 注册)——Pod 删之前要先 detach 上面的 PVC 对应的 PV。如果直接删 etcd 记录,PV 还挂着但没人知道,云盘永远 detach 不掉。

直接删 vs 标记删的区别:

直接删标记删
etcd 记录立刻不见先打 deletionTimestamp,等 Finalizer 清空
外部资源(PV/云 LB)可能泄漏每个 Finalizer 对应一个清理步骤
简单粗暴优雅关闭 + 资源回收

3. Go 里怎么检测配置文件变动?

ConfigMap file mount 更新后,文件内容变了,但你的进程不会自动知道。需要自己实现热更新检测。

Go 标准库没有直接的文件变更通知——你是用 os.Stat() 定期 poll,还是用操作系统层面的文件变更通知(inotify/FSEvents)?答案是:用 fsnotify 库,它会封装不同 OS 的底层文件监视机制(Linux 用 inotify,macOS 用 kqueue/FSEvents):

import "github.com/fsnotify/fsnotify"

func watchConfig(path string, reload func()) {
watcher, _ := fsnotify.NewWatcher()
defer watcher.Close()
watcher.Add(path)

for {
select {
case event := <-watcher.Events:
if event.Op&fsnotify.Write == fsnotify.Write {
reload() // 文件被写入,重新加载配置
}
case err := <-watcher.Errors:
log.Println("error:", err)
}
}
}

但这里有一个容易忽略的坑:kubelet 更新 ConfigMap 挂载文件时,不是直接 write() 到原文件,而是创建新文件 + 原子 rename。

kubelet 在 volume_manager.go 里的操作相当于:

# kubelet 的实际操作
echo "new content" > /tmp/.configmap-tmp-xxxx
mv /tmp/.configmap-tmp-xxxx /var/lib/kubelet/pods/<uid>/volumes/.../app.properties

mv(rename() 系统调用)是原子的——文件要么是旧内容,要么是新内容,不会读到半个文件。

但对 fsnotify 来说,rename 会先触发一个 RENAME 事件(旧文件消失了),然后新文件就位。大多数 fsnotify 的实现对这种情况的处理是重新 Add() 这个路径——因为旧 inode 已失效,watcher 需要重新注册新 inode:

case event.Op&fsnotify.Rename == fsnotify.Rename:
// 旧文件被 rename 走了,watcher 失效,重新 Add
watcher.Remove(event.Name)
watcher.Add(path)
reload()

如果你发现 ConfigMap reload 偶尔漏了一次更新,大概率是只处理了 Write 事件,没处理 RENAME。

另一种更简单的方案:用 os.Stat() 轮询 ModTime。不需要处理 inode 变化这些底层细节,代价是微小的 CPU 开销(每秒一次 stat 调用完全可忽略):

func watchModTime(path string, reload func()) {
var lastMod time.Time
for range time.Tick(1 * time.Second) {
info, _ := os.Stat(path)
if info.ModTime().After(lastMod) {
lastMod = info.ModTime()
reload()
}
}
}

简单粗暴但可靠。很多 Go 项目(包括一些 K8S controller)的配置热更新就这么干的。

下一章,看看怎么管理 Pod 的个数和版本——工作负载。