写在前面
用 K8s 好几年了,从最开始的”照着文档搭集群”,到现在管理几十个节点的生产集群,踩过的坑已经够写一本书了。
官方文档当然很重要,但文档告诉你的是”怎么用”,不会告诉你 "用了之后会出什么问题"。很多坑,只有你真正在生产上跑过、出过事之后才知道。
这篇文章整理了我在生产环境遇到过的 7 个坑,每一个都是真金白银的教训。有些坑让我半夜爬起来处理,有些坑让我在复盘会上被问得哑口无言。
希望你看完之后,能少走一些我走过的弯路。
坑一:etcd 磁盘满了,整个集群变成只读
发生了什么
某天上午 10 点,开发同学跑来说:”K8s 集群不能创建新 Pod 了。”
我上去一看:
$ kubectl run test --image=nginx
Error from server (Forbidden): error when creating "test": pods "test" is forbidden:
etcdserver: request timed out
不只是创建 Pod,kubectl get nodes 都开始超时了。赶紧 ssh 到 master 节点:
$ journalctl -u etcd --no-pager -n 50
etcdserver: mvcc: database space exceeded
etcd 磁盘空间超限了。
etcd 有一个默认的存储配额(2GB),当数据量超过配额时,etcd 会变成只读模式,拒绝所有写操作。而 K8s 的几乎所有操作(创建 Pod、更新 Deployment、甚至心跳上报)都依赖 etcd 写入,所以整个集群就”瘫痪”了。
为什么会满?
查了一下,罪魁祸首是 事件(Event)记录。
K8s 会为每个 Pod 的每次事件(调度、拉取镜像、启动、重启、OOM 等)创建一个 Event 对象,存在 etcd 里。我们有个服务因为配置问题一直在 CrashLoopBackOff,每次重启都会产生一条 Event。跑了三天,产生了 100 多万条 Event,把 etcd 撑爆了。
# 查看事件数量
$ kubectl get events --all-namespaces | wc -l
1234567
怎么解决
紧急止血:
# 临时扩大 etcd 配额(从 2GB 扩到 8GB)
$ etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
put quota/bytes -- '8589934592'
扩容后 etcd 恢复写入,集群恢复正常。
清理事件:
# 删除所有 namespace 下的旧事件(保留最近 1 小时的)
$ kubectl get events --all-namespaces --field-selector eventTimestamp<2026-04-27T09:00:00Z \
| awk '{print $1}' | sort -u | xargs -I {} kubectl delete events --field-selector eventTimestamp<2026-04-27T09:00:00Z -n {}
永久预防:
部署 eventrouter 或使用 kube-controller-manager 自带的事件清理功能:
# 在 kube-controller-manager 配置中设置事件 TTL
$ vi /etc/kubernetes/manifests/kube-controller-manager.yaml
spec:
containers:
- command:
- kube-controller-manager
- --terminated-pod-gc-threshold=1000
- --event-ttl=1h # 事件保留 1 小时后自动清理
教训:etcd 磁盘空间是 K8s 集群的”生命线”,一旦满了整个集群就废了。一定要设置事件 TTL,并且监控 etcd 的存储使用量。建议在监控里加一条规则:etcd 存储使用率超过 70% 就告警。
坑二:CoreDNS 的 ndots:5 让 DNS 解析慢了 5 倍
发生了什么
上线了一个新服务,调用方反馈”第一次请求特别慢,要 2-3 秒,之后就正常了”。
第一反应是”连接池冷启动”,但查了应用日志,连接池是预热过的。用 dig 在 Pod 里测试 DNS 解析:
$ kubectl exec -it dev-app-pod -- dig dev-service.default.svc.cluster.local
;; Query time: 12 msec
12ms,看起来正常。但测试一个不存在的域名:
$ kubectl exec -it dev-app-pod -- dig dev-service.default.svc.cluster.local.xxx
;; Query time: 3125 msec
3 秒! 这就是第一次请求慢的原因。
为什么会这样?
K8s Pod 里的 DNS 配置默认是这样的:
$ kubectl exec -it dev-app-pod -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
关键在 ndots:5。DNS 解析的规则是:如果域名中”.“的数量小于 ndots(这里是 5),会先按照 search 列表中的后缀依次拼接尝试解析,全部失败后才用原始域名解析。
比如解析 dev-service(1 个点,小于 5),DNS 会按这个顺序尝试:
1. dev-service.default.svc.cluster.local → NXDOMAIN
2. dev-service.svc.cluster.local → NXDOMAIN
3. dev-service.cluster.local → NXDOMAIN
4. dev-service(原始域名) → 成功
每次 NXDOMAIN 都要等超时(默认 5 秒),所以最坏情况下要等 15 秒以上。
我们的应用在连接数据库时用的是短域名 mysql,每次解析都要走完整个 search 列表,所以第一次连接特别慢。
怎么解决
方案一:用完整域名(最简单)
代码里把 mysql 改成 mysql.default.svc.cluster.local,域名中的点数超过 ndots,直接解析,不走 search。
方案二:降低 ndots(推荐)
在 CoreDNS 的 ConfigMap 中把 ndots 改成 2:
$ kubectl edit configmap coredns -n kube-system
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
然后在 Pod 的 DNS 配置中覆盖 ndots:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
或者通过 Deployment 的 dnsPolicy 设置:
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
方案三:给内部服务加 headless Service 的 A 记录
如果服务名是 mysql,可以创建一个同名的 headless Service,让 DNS 直接解析到对应的 IP,不需要走 search 列表。
教训:如果你的应用里用了短域名连接其他服务,一定要关注 DNS 解析耗时。建议把 ndots 从 5 降到 2,对绝大多数场景都够用,而且能显著减少 DNS 查询次数。
坑三:Secret 更新了,Pod 里还是旧的
发生了什么
我们有个服务连接第三方 API,用的密钥存在 K8s Secret 里。密钥到期了,运维同学更新了 Secret:
$ kubectl create secret generic api-secret \
--from-literal=api-key=new-key-12345 \
--dry-run=client -o yaml | kubectl apply -f -
更新完之后,通知开发同学”密钥已更新”。开发同学说”好的”,然后继续用。过了半小时,第三方 API 那边反馈我们的请求还是用的旧密钥。开发同学查了应用日志,确认应用读到的确实是旧密钥。“Secret 不是已经更新了吗?”
为什么会这样?
K8s Secret 有两种挂载方式:
方式一:环境变量
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: api-secret
key: api-key
方式二:Volume 挂载
volumes:
- name: secret-volume
secret:
secretName: api-secret
volumeMounts:
- name: secret-volume
mountPath: /etc/secrets
readOnly: true
两种方式的更新行为完全不同:
| 挂载方式 | Secret 更新后 | 需要重启 Pod 吗? |
|---|---|---|
| 环境变量 | 不会更新,Pod 里还是旧值 | 必须重启 |
| Volume 挂载 | 会更新(K8s 会定期同步,大约 1 分钟) | 不需要重启 |
我们的服务用的是环境变量方式挂载 Secret,所以更新 Secret 后,Pod 里读到的还是旧值。必须重启 Pod 才能生效。但运维同学不知道这个区别,以为更新了 Secret 就完事了。
怎么解决
短期:重启 Pod
$ kubectl rollout restart deployment app-service
长期:如果需要 Secret 热更新,改用 Volume 挂载方式。应用层做文件监听,检测到文件变化后重新加载密钥。
spec:
containers:
- name: dev-app
volumeMounts:
- name: secret-volume
mountPath: /etc/secrets
readOnly: true
env:
- name: SECRET_PATH
value: "/etc/secrets/api-key"
volumes:
- name: secret-volume
secret:
secretName: api-secret
应用代码里监听文件变化:
// Go 示例:监听 Secret 文件变化
watcher, _ := fsnotify.NewWatcher("/etc/secrets/api-key")
go func() {
for {
select {
case event := <-watcher.Events:
if event.Op&fsnotify.Write == fsnotify.Write {
newKey, _ := os.ReadFile("/etc/secrets/api-key")
reloadAPIKey(string(newKey))
}
}
}
}()
教训:K8s Secret 的更新机制是面试高频题,但在生产上踩坑的人真的不少。如果你不确定团队是否清楚这个区别,建议在运维文档里明确写上:更新 Secret 后,必须确认 Pod 的挂载方式,如果是环境变量则需要重启 Pod。
坑四:HPA 扩容了,但新 Pod 一直 Pending
发生了什么
晚上 8 点,流量高峰来了。HPA 正常触发了扩容:
$ kubectl get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
dev-service Deployment/dev-service/Scale 85%/80% 3 20 8
副本数从 3 扩到了 8。但业务同学说”还是扛不住,响应时间还是很长”。我上去一看,8 个 Pod 里只有 3 个是 Running,剩下 5 个全是 Pending:
$ kubectl get pods | grep dev-service
dev-service-xxx-abc12 1/1 Running 0 15m
dev-service-xxx-def34 1/1 Running 0 15m
dev-service-xxx-ghi56 1/1 Running 0 15m
dev-service-xxx-jkl78 0/1 Pending 0 5m
dev-service-xxx-mno90 0/1 Pending 0 5m
dev-service-xxx-pqr12 0/1 Pending 0 5m
dev-service-xxx-stu34 0/1 Pending 0 5m
dev-service-xxx-vwx56 0/1 Pending 0 5m
HPA 扩了,但新 Pod 调度不上去。等于 HPA 做了无用功。
为什么会这样?
$ kubectl describe pod dev-service-xxx-jkl78 | tail -10
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 5m default-scheduler 0/5 nodes are available: 3 Insufficient cpu, 2 Insufficient memory.
节点资源不够了。问题出在:我们的 Pod 的 resources.requests 设得太低了:
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "2"
memory: "4Gi"
request 设了 100m CPU,但实际运行时每个 Pod 要用 800m-1500m CPU。调度器按 request 算,觉得每个节点还能塞很多 Pod,就都调度上去了。结果 Pod 跑起来之后疯狂抢占 CPU,节点负载飙升,真正需要扩容时反而没资源了。
这就是经典的 request 和 limit 差距过大 的问题。
怎么解决
紧急:手动清理一些低优先级的 Pod,腾出资源
# 查看哪些 Pod 占用资源最多
$ kubectl top pods --all-namespaces | sort -k3 -rn | head -20
# 临时缩容非核心服务
$ kubectl scale deployment non-critical-service --replicas=0
长期:合理设置 request 值
request 应该反映 Pod 的实际资源使用量,而不是”最小可能值”。建议用以下方法确定:
# 查看 Pod 历史资源使用量(需要 metrics-server)
$ kubectl top pod dev-service-xxx-abc12 --no-headers
# 输出类似:523m 384Mi
# 建议取 P50 或 P70 的值作为 request
# 如果 P50 CPU = 500m,P70 CPU = 800m
# 那 request 设为 600m-800m 比较合理
resources:
requests:
cpu: "800m" # 接近实际使用量
memory: "512Mi"
limits:
cpu: "2000m"
memory: "2Gi"
教训:request 设太低不是”省资源”,而是”骗调度器”。调度器按 request 算,你告诉它每个 Pod 只要 100m,它就会往节点上塞很多 Pod,结果实际运行时资源不够,真正需要扩容时反而没地方放。request 应该设成实际使用量的 70%-80% ,给一些余量,但不要差太多。
坑五:kubectl drain 把集群搞崩了
发生了什么
有次要升级一批节点的内核版本,需要先把节点上的 Pod 驱逐走。我用了 kubectl drain:
$ kubectl drain worker-03 --ignore-daemonsets --delete-emptydir-data
node/worker-03 cordoned
evicting pod default/my-service-xxx-abc12
evicting pod kube-system/calico-node-xxx
...
看起来正常。然后我对 worker-04 执行了同样的操作。然后对 worker-05,然后监控告警炸了:多个服务不可用。
为什么会这样?
kubectl drain 会驱逐节点上的所有 Pod(除了 DaemonSet)。但驱逐是”优雅的”——它会先发 SIGTERM,等 Pod 完成清理工作后再删除。
问题出在:我们的 Pod 没有配置优雅终止。
# 很多 Pod 的 terminationGracePeriodSeconds 用的是默认值 30 秒
# 但应用没有监听 SIGTERM 信号,不会主动退出
Pod 收到 SIGTERM 后,应用不处理,30 秒后 kubelet 发 SIGKILL 强杀。但在这 30 秒内,Pod 还是 Running 状态,Service 的 endpoint 里还有它。
当我同时 drain 3 个节点时,3 个节点上的 Pod 都在”等死”状态。新 Pod 调度到其他节点需要时间,而旧 Pod 还没完全退出。Service 的 endpoint 里混着”正在退出的旧 Pod”和”刚启动的新 Pod”,流量分发混乱,部分请求打到了正在退出的 Pod 上,导致错误。
更糟糕的是,有个服务的 Pod 里有本地缓存(Redis 连接池),优雅终止时需要 30 秒来关闭连接。但 terminationGracePeriodSeconds 只有 30 秒,还没来得及关完就被杀了,导致 Redis 连接泄漏。
怎么解决
1. 配置优雅终止
spec:
terminationGracePeriodSeconds: 60 # 给足够的时间
containers:
- name: dev-app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"] # 预停止钩子,先从 Service 摘除
# 应用本身需要监听 SIGTERM 信号
2. 配置 PodDisruptionBudget
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: dev-service-pdb
spec:
minAvailable: "50%"
selector:
matchLabels:
app: dev-service
有了 PDB,kubectl drain 在驱逐 Pod 之前会检查:驱逐后是否还有足够的可用 Pod。如果不够,drain 会拒绝操作,避免服务不可用。
$ kubectl drain worker-03 --ignore-daemonsets
error when evicting pods/default/my-service-xxx:
Cannot evict pod as it would violate the pod's disruption budget.
3. 逐个节点操作,不要同时 drain 多个节点
# 正确做法:一个一个来
$ kubectl drain worker-03 --ignore-daemonsets --delete-emptydir-data
# 等所有 Pod 都迁移完成
$ kubectl get nodes worker-03
# 确认 Ready 后再操作下一个
$ kubectl drain worker-04 --ignore-daemonsets --delete-emptydir-data
教训:kubectl drain 看起来是个简单的命令,但它会触发一系列连锁反应。在执行之前,确保:① 配置了 PDB;② Pod 有优雅终止逻辑;③ 逐个节点操作,不要贪快。
坑六:日志采集把节点打挂了
发生了什么
某天凌晨 3 点,值班同事打电话给我:”好几台机器 CPU 100%,服务全挂了。”
我迷迷糊糊爬起来,打开电脑一看,确实是好几台 worker 节点 CPU 飙满。但不是我们的业务应用,而是:
$ top -bn1 | head -20
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5678 root 20 0 512.4m 128.6m 12.2m S 95.3 1.6 2345:12.78 fluentd
5679 root 20 0 498.2m 115.3m 10.8m S 92.1 1.4 2198:34.56 fluentd
Fluentd(日志采集组件)占了 90%+ 的 CPU。
为什么会这样?
查了一下 Fluentd 的日志,发现它在疯狂处理日志。原因是:有个服务的日志级别被开发同学调试时,临时改成了 DEBUG,每秒产生上百 MB 的日志。Fluentd 拼命采集、解析、转发,CPU 直接被打满了。
更惨的是,Fluentd 是以 DaemonSet 方式部署的,每个节点上都跑着一个。所以日志暴增的服务的 Pod 在哪个节点上,那个节点的 Fluentd 就被打满,进而影响节点上所有其他 Pod。
怎么解决
紧急:先给 Fluentd 加资源限制,防止它把节点打挂
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluentd
namespace: kube-system
spec:
template:
spec:
containers:
- name: fluentd
resources:
requests:
cpu: "100m"
memory: "200Mi"
limits:
cpu: "1000m" # 限制最大 CPU
memory: "1Gi"
然后:找到产生大量日志的 Pod,把日志级别改回正常
# 找到日志量最大的 Pod
$ kubectl top pods --all-namespaces | sort -k3 -rn | head -10
# 修改日志级别
$ kubectl set env deployment dev-service LOG_LEVEL=INFO
长期:
1、给所有日志采集组件设置资源限制(很多团队用 DaemonSet 部署 Fluentd/Filebeat 时不设 limits,这是定时炸弹)
2、配置日志采集的速率限制:
# Fluentd 配置
<source>
@type tail
path /var/log/containers/*.log
pos_file /var/log/fluentd-containers.log.pos
tag kubernetes.*
read_from_head true
<parse>
@type json
time_key time
time_format %Y-%m-%dT%H:%M:%S.%NZ
</parse>
</source>
# 限制采集速率
<filter kubernetes.**>
@type throttle
group_key log
group_bucket_limit 100 # 每秒最多采集 100 条
group_interval 10s
group_reset_rate 10/m
</filter>
3、在监控里加上日志采集组件的资源使用告警
教训:DaemonSet 部署的组件(日志采集、监控 Agent)一定要设资源限制。它们跑在每个节点上,一旦出问题影响面是全集群级别的。不要相信”日志采集组件很轻量”这种说法——它轻量是正常的,但不正常的时候能把你整个集群搞崩。
坑七:滚动更新导致的连接中断
发生了什么
某次发布新版本,用的是正常的滚动更新:
$ kubectl set image deployment/dev-service dev-app=evd-app:v2.0.0
deployment.apps/dev-service image updated
发布过程中,监控显示有大约 1-2% 的请求返回了 502。业务同学问:”不是滚动更新吗?为什么还有请求失败?”
为什么会这样?
滚动更新的流程是这样的:
- 创建新 Pod,等待新 Pod Ready
- 新 Pod Ready 后,删除旧 Pod
- 重复直到所有旧 Pod 被替换
问题出在第 1 步和第 2 步之间。
新 Pod 变成 Ready,意味着 readinessProbe 通过了。但 readinessProbe 通过 ≠ 应用真正能处理请求。
我们的应用启动流程是这样的:
JVM 启动 → Spring 初始化 → 连接数据库 → 加载缓存 → 开始监听端口
readinessProbe 配置的是 TCP 端口检查:
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
端口打开了,readinessProbe 就通过了。但这时候 Spring 可能还在初始化数据库连接池、加载缓存。如果这时候有请求打过来,应用能收到,但处理不了,就会返回 502。
而旧 Pod 被删除后,它的连接是直接断掉的,不会有优雅关闭的过程。
怎么解决
1. 改用 HTTP 探针,检查应用真正就绪
readinessProbe:
httpGet:
path: /health/ready # 应用提供一个真正的就绪检查接口
port: 8080
initialDelaySeconds: 15
periodSeconds: 5
failureThreshold: 3
应用的 /health/ready 接口应该检查:
@GetMapping("/health/ready")
public ResponseEntity<String> ready() {
// 检查数据库连接池是否就绪
if (!dataSource.isRunning()) {
return ResponseEntity.status(503).body("database not ready");
}
// 检查缓存是否加载完成
if (!cacheManager.isReady()) {
return ResponseEntity.status(503).body("cache not ready");
}
return ResponseEntity.ok("ok");
}
2. 配置 preStop 钩子,让旧 Pod 优雅退出
spec:
terminationGracePeriodSeconds: 60
containers:
- name: my-app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
preStop 的 sleep 10 看起来很蠢,但它有重要作用:让 kubelet 在发 SIGTERM 之前先等 10 秒。在这 10 秒内,endpoint controller 会把旧 Pod 从 Service 的 endpoint 列表中移除。这样就不会有新请求打到正在退出的旧 Pod 上了。
时序是这样的:
t=0s preStop sleep 10 开始执行
t=0s endpoint controller 从 endpoint 列表中移除旧 Pod
t=10s preStop 执行完毕,kubelet 发送 SIGTERM
t=10s 应用收到 SIGTERM,开始优雅关闭
t=40s 应用完成清理,主动退出
t=60s terminationGracePeriodSeconds 到期,强制 SIGKILL
3. 配置 maxSurge
spec:
strategy:
rollingUpdate:
maxSurge: 1 # 滚动更新时最多多创建 1 个 Pod
maxUnavailable: 0 # 滚动更新时不允许有 Pod 不可用
maxUnavailable: 0 确保更新过程中,可用 Pod 数量不会减少。maxSurge: 1 允许临时多一个 Pod 来承接流量。
教训:滚动更新”零中断”不是 K8s 默认就能做到的,需要三个条件配合:① HTTP 就绪探针(不是 TCP);② preStop 钩子 + 合理的 terminationGracePeriodSeconds;③ 合理的 maxSurge 和 maxUnavailable。缺一个都可能出问题。
最后整理一份清单
把上面 7 个坑的预防措施整理成一份上线前检查清单:
| # | 检查项 | 不做的后果 | 操作 |
|---|---|---|---|
| 1 | etcd 存储监控 + 事件 TTL | etcd 满了集群瘫痪 | 设置 --event-ttl=1h,监控 etcd 存储使用率 |
| 2 | DNS ndots 配置 | DNS 解析慢,首次请求延迟高 | ndots 改为 2,内部服务用完整域名 |
| 3 | Secret 挂载方式 | 更新 Secret 后应用不生效 | 需要热更新用 Volume 挂载,否则更新后要重启 Pod |
| 4 | resources.request 合理设置 | HPA 扩容时调度不上 | request 设为实际使用量的 70%-80% |
| 5 | PodDisruptionBudget + 优雅终止 | drain 节点时服务中断 | 配置 PDB + preStop + terminationGracePeriodSeconds |
| 6 | DaemonSet 资源限制 | 日志采集/监控组件打满节点 CPU | 所有 DaemonSet 必须设 requests + limits |
| 7 | 滚动更新零中断配置 | 发布时有少量请求失败 | HTTP 探针 + preStop + maxSurge/maxUnavailable |
如果你也在用 K8s 跑生产环境,并且踩过其他文档里没写的,欢迎在评论区分享。踩过的坑多了,路就平了。