分类 Kubernetes 下的文章

HPA(HorizontalPodAutoscaler)配上了,但流量压上来 Pod 死活不扩,是 K8s 运维的高频坑。本文列我见过的 6 个典型原因。

起手命令

kubectl describe hpa <name> -n <ns>
kubectl get hpa <name> -n <ns> -w
kubectl logs -n kube-system deploy/metrics-server | tail

describe hpa 底部的 ConditionsEvents 是关键。

陷阱 1:metrics-server 没装/挂了

最常见。kubectl top pods 报错就是这个:

error: Metrics API not available

修复:

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

自签证书集群还得加 --kubelet-insecure-tls

陷阱 2:Pod 没设 resources.requests

HPA 算的是 当前用量 / requests没写 requests,HPA 啥也算不出来

kubectl get hpa <name> -o yaml | grep -A5 currentMetrics
# 看到 <unknown> 就是这个原因

修:deployment 里给容器加 resources.requests.cpu: 100m

陷阱 3:阈值算法和你想的不一样

HPA 公式:

desiredReplicas = ceil(currentReplicas × (currentMetric / desiredMetric))

举例:当前 2 个 Pod,CPU 平均使用率 60%,目标 50%:

desired = ceil(2 × (60/50)) = ceil(2.4) = 3

但有 10% 容忍区间horizontal-pod-autoscaler-tolerance 默认 0.1),60/50=1.2 超过 1.1 才扩。如果 currentMetric 在 45%~55% 之间,永远不会扩缩

陷阱 4:扩容/缩容窗口(v2 behavior)

HPA v2 引入 behavior,默认缩容窗口 5 分钟。流量降了但 Pod 不缩,可能正常:

behavior:
  scaleDown:
    stabilizationWindowSeconds: 300
  scaleUp:
    stabilizationWindowSeconds: 0

如果想更快响应,调小这两个值。

陷阱 5:自定义指标接不上

用 Prometheus Adapter / KEDA 的场景:

kubectl get apiservice | grep custom.metrics
# v1beta1.custom.metrics.k8s.io 必须是 Available

不 Available 就是 adapter 挂了或者配置没生效。Adapter 改完配置必须重启 Pod,热加载没用。

陷阱 6:目标 deployment 的 replicas 被其他东西管着

Argo CD / Flux 同步覆盖、PodDisruptionBudget 限制、StatefulSet 的 ordinal 限制都可能让 HPA 改了 replicas 立刻被改回去。

kubectl get events -n <ns> | grep -i hpa
kubectl get deployment <name> -o yaml | grep -B2 -A2 replicas

GitOps 场景的标准做法:让 HPA 管 replicas,Argo CD 忽略 replicas 字段:

# Application
spec:
  ignoreDifferences:
  - group: apps
    kind: Deployment
    jsonPointers:
    - /spec/replicas

几个调试 trick

  • 手动改 replicas 看 HPA 反应kubectl scale deploy/x --replicas=5,5 分钟后 HPA 应该把它拉回来
  • 临时打开 verbose 日志kubectl -n kube-system edit deployment metrics-server--v=4
  • 看是 metric 没采到还是算出来不扩describe hpacurrentMetrics 字段,<unknown> vs 有值 走不同分支

教训一句话:HPA 不扩 90% 是 metrics-server 或 requests 没设,剩下 10% 是 behavior 窗口或者算法容忍区间。

kubectl get pods 看到 Pending,新手最容易蒙圈。本文按出现频率排了 8 类原因,配定位命令,照着走一遍基本能搞定。

万能起手式

kubectl describe pod <pod> -n <ns> | tail -30
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -20

describe 底部的 Events 是最直接的线索,80% 的 Pending 看这里就够了

1. 资源不足(FailedScheduling: Insufficient cpu/memory)

0/10 nodes are available: 10 Insufficient cpu

检查:

kubectl top nodes
kubectl describe node <node> | grep -A5 "Allocated resources"

注意 Allocated 是 requests 之和,不是实际用量。Pod requests 写得太大也会卡:

kubectl get pod <pod> -o jsonpath='{.spec.containers[*].resources}'

2. 没有合适 node(NodeAffinity / NodeSelector 不匹配)

0/10 nodes are available: 10 node(s) didn't match Pod's node affinity/selector
kubectl get pod <pod> -o yaml | grep -A20 "nodeSelector\|affinity"
kubectl get nodes --show-labels

经常是 nodeSelector 写了 node-role.kubernetes.io/worker: "true" 但节点上压根没这个 label。

3. 污点未容忍(Taints / Tolerations)

0/10 nodes are available: 10 node(s) had untolerated taint
kubectl describe node <node> | grep Taint

修复有两种:要么给 Pod 加 toleration,要么去掉 taint。生产建议加 toleration,taint 通常是有意打的。

4. PVC 还没 bound

pod has unbound immediate PersistentVolumeClaims
kubectl get pvc -n <ns>
kubectl describe pvc <pvc> -n <ns>

可能问题:

  • StorageClass 不存在或者拼错
  • provisioner 挂了(如 ebs-csi-controller)
  • PV 静态绑定时 selector 不匹配

5. 镜像拉不下来

Pod 状态可能是 ImagePullBackOffErrImagePull,但有时候表现为 ContainerCreating + Events 里报错:

Failed to pull image "xxx": rpc error: code = Unknown
  • 私有镜像没配 imagePullSecrets
  • 镜像 tag 写错(:latest vs :v1.2.3
  • 国内拉 docker.io 慢/不通 → 用 mirror

6. ResourceQuota / LimitRange 拦住了

exceeded quota: compute-resources
kubectl describe quota -n <ns>
kubectl describe limitrange -n <ns>

经常是 namespace 加了 LimitRange 强制要求 requests/limits,而 Pod 没写。

7. PodSecurityAdmission 拦截(1.25+)

violates PodSecurity "restricted:v1.28"
kubectl get ns <ns> -o jsonpath='{.metadata.labels}'

namespace 加了 pod-security.kubernetes.io/enforce=restricted,Pod 里有 privileged: true 或者 hostNetwork: true 都会被拦。

8. CNI 还没就绪 / Webhook 阻塞

罕见但很坑:

  • 节点 Ready 了但 CNI 插件还没 ready,Pod 调度上去后卡在 ContainerCreating
  • ValidatingWebhook 挂了,Pod 创建 API 都过不了,根本不会 schedule
kubectl get pods -A | grep -v Running | grep -v Completed
kubectl get validatingwebhookconfigurations

Debug 神器

如果 events 都没有,开个 ephemeral container 看节点视角:

kubectl debug node/<node> -it --image=busybox

或者直接看 kubelet 日志:

ssh <node> 'journalctl -u kubelet -f | grep <pod-name>'

教训一句话:Pending 看 describe 的 Events 永远是第一步,不要直接翻 controller-manager 日志。

Operator 是把运维知识写成代码的方式。本文用一个最小例子讲清楚怎么开发。

一个例子:自动备份 PVC

需求:定义一个 PVCBackup 资源,自动给 PVC 每小时打快照存到 S3。

1. 定义 CRD

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: pvcbackups.storage.example.com
spec:
  group: storage.example.com
  versions:
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              pvcName:
                type: string
              schedule:
                type: string
              s3Bucket:
                type: string
            required: [pvcName, schedule, s3Bucket]
          status:
            type: object
            properties:
              lastBackup:
                type: string
              nextBackup:
                type: string
  scope: Namespaced
  names:
    plural: pvcbackups
    singular: pvcbackup
    kind: PVCBackup

2. 用 kubebuilder 生成脚手架

kubebuilder init --domain example.com --repo pvcbackup-operator
kubebuilder create api --group storage --version v1 --kind PVCBackup

生成 controllers/pvcbackup_controller.go

3. 实现 Reconcile

func (r *PVCBackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)

    // 1. 拿到对象
    var backup storagev1.PVCBackup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // 2. 检查 PVC 是否存在
    var pvc corev1.PersistentVolumeClaim
    if err := r.Get(ctx, types.NamespacedName{Name: backup.Spec.PVCName, Namespace: req.Namespace}, &pvc); err != nil {
        log.Error(err, "PVC not found")
        return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
    }

    // 3. 判断是否到了下次备份时间
    if backup.Status.NextBackup != "" {
        nextTime, _ := time.Parse(time.RFC3339, backup.Status.NextBackup)
        if time.Now().Before(nextTime) {
            // 等到下次时间再 reconcile
            return ctrl.Result{RequeueAfter: time.Until(nextTime)}, nil
        }
    }

    // 4. 执行备份(创建 VolumeSnapshot + 上传 S3)
    if err := r.doBackup(ctx, &backup, &pvc); err != nil {
        return ctrl.Result{RequeueAfter: 5 * time.Minute}, err
    }

    // 5. 更新 status
    backup.Status.LastBackup = time.Now().Format(time.RFC3339)
    next, _ := cron.ParseStandard(backup.Spec.Schedule)
    backup.Status.NextBackup = next.Next(time.Now()).Format(time.RFC3339)
    return ctrl.Result{RequeueAfter: time.Until(next.Next(time.Now()))}, r.Status().Update(ctx, &backup)
}

4. Reconcile 的几条铁律

  1. 幂等:同一个对象 Reconcile 10 次结果应一样
  2. 不要在 Reconcile 里 sleep:用 RequeueAfter 让 controller-runtime 自己调度
  3. status 单独 update:spec 和 status 是不同的 subresource
  4. 错误处理分类:临时错误 RequeueAfter,永久错误记到 status 别 retry
  5. finalizer 处理删除:要清理外部资源(如 S3 上的备份)就加 finalizer

5. 监控 Operator

Operator 也是普通 Pod,加 Prometheus metrics:

import "sigs.k8s.io/controller-runtime/pkg/metrics"

var (
    reconcileCounter = prometheus.NewCounter(...)
)
func init() {
    metrics.Registry.MustRegister(reconcileCounter)
}

关键指标:
- controller_runtime_reconcile_total:reconcile 次数
- controller_runtime_reconcile_errors_total:错误次数
- workqueue_depth:队列深度(大了说明 reconcile 跟不上)

6. 部署

make docker-build docker-push IMG=registry/pvcbackup:v1
make deploy IMG=registry/pvcbackup:v1

一些反模式

  • ❌ 在 Reconcile 里写状态机:用 phase 字段记当前状态
  • ❌ 跨对象事务:K8s 没事务,要设计成幂等的
  • ❌ 调用外部 API 不超时:必加 context timeout

教训:Operator 难的不是写代码,是把"运维知识"翻译成"幂等的 Reconcile 逻辑"。

K8s 给多团队 / 多业务用,怎么隔离?方案从弱到强排列。

L1:Namespace 隔离(最弱)

每个团队一个 namespace + RBAC:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: team-a-admin
  namespace: team-a
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: admin
subjects:
- kind: Group
  name: team-a

隔离的:API 权限。
不隔离的:网络(所有 Pod 互通)、节点(混合调度)、资源(无限抢)。

适合:内部团队、互相信任的场景。

L2:加 NetworkPolicy + ResourceQuota

# 资源配额
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-quota
  namespace: team-a
spec:
  hard:
    requests.cpu: "100"
    requests.memory: 200Gi
    limits.cpu: "200"
    persistentvolumeclaims: "20"

---
# 默认拒绝所有外部流量
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: team-a
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]

加 NetworkPolicy 后跨 namespace 默认不通,加 ResourceQuota 后无法抢资源。

适合:公司内部多业务、生产级隔离。

L3:节点池隔离

不同租户跑在不同节点上:

# 节点打 taint
kubectl taint nodes worker-team-a tenant=team-a:NoSchedule

# 业务 Pod 加 toleration + nodeSelector
spec:
  nodeSelector:
    tenant: team-a
  tolerations:
  - key: tenant
    operator: Equal
    value: team-a
    effect: NoSchedule

物理隔离,避免 noisy neighbor。但成本上升(每个团队都要预留节点)。

适合:金融、政企等强合规场景。

L4:vCluster(虚拟集群)

每个租户一个虚拟 K8s 集群,跑在 host 集群的 namespace 里:

helm install vcluster-team-a vcluster --namespace team-a

每个租户看到的是独立 API server、独立 etcd(轻量),但底层共享 host 节点。

适合:SaaS 平台、PaaS 服务。

L5:独立集群

最强隔离 = 一租户一集群。

适合:完全不能共享的场景(多家公司、跨地域)。

决策树

跨公司租户? → 独立集群
跨业务(同一公司),强合规? → 节点池隔离
内部团队,要看似独立的 K8s? → vCluster
内部团队,控制资源 + 网络就够? → Namespace + Quota + NetworkPolicy
完全信任,简单分组? → Namespace + RBAC

Quota 设置的坑

ResourceQuota 一旦设置,namespace 里所有 Pod 必须设 requests/limits,否则创建失败。配合 LimitRange 设默认值:

apiVersion: v1
kind: LimitRange
metadata:
  name: team-a-defaults
  namespace: team-a
spec:
  limits:
  - default:
      cpu: 500m
      memory: 512Mi
    defaultRequest:
      cpu: 100m
      memory: 128Mi
    type: Container

教训:多租户隔离不是越强越好——节点池隔离用户体验下降明显,能用 Quota 解决就别上节点池。

K8s 一个季度一个版本,升级是个常态。本文按 kubeadm 跨 3 个版本的实战记录。

跨版本规则

K8s 官方只支持 N→N+1 升级。要跨多版本就要"逐级跳":

1.27 → 1.28 → 1.29 → 1.30

每跳一次必须等集群稳定(一天以上),不能连续跳。

单步升级流程

以 1.27 → 1.28 为例。

1. 升 control plane(第一个 master)

# 1. 升 kubeadm
dnf install -y kubeadm-1.28.0-0 --disableexcludes=kubernetes

# 2. 看升级计划
kubeadm upgrade plan

# 3. 真正升级
kubeadm upgrade apply v1.28.0

# 4. drain 节点
kubectl drain master1 --ignore-daemonsets

# 5. 升 kubelet/kubectl
dnf install -y kubelet-1.28.0-0 kubectl-1.28.0-0
systemctl daemon-reload
systemctl restart kubelet

# 6. uncordon
kubectl uncordon master1

2. 升其他 master

# 在 master2、master3 上
kubeadm upgrade node    # 注意是 node 不是 apply
# 然后同上升 kubelet

3. 升 worker

逐个节点:

# 控制面执行
kubectl drain worker1 --ignore-daemonsets --delete-emptydir-data

# 节点上执行
dnf install -y kubeadm-1.28.0-0 kubelet-1.28.0-0
kubeadm upgrade node
systemctl restart kubelet

# 控制面执行
kubectl uncordon worker1

必看 release notes 的坑

跨版本最容易翻车的点:

1.28 砍了

  • --feature-gates 里的 GA feature 不再接受(会报 invalid feature gate)
  • in-tree GCE/AWS/Azure/vSphere 存储插件全部移到 CSI

1.29 砍了

  • Secret 默认开启 SecretImmutability
  • KubeProxyConfiguration v1alpha1 弃用
  • Pod 安全准入策略对照 v1.29 检查

1.30 砍了

  • app.kubernetes.io/component 一些保留值变化
  • CRI v1alpha2 完全删除,runtime 必须支持 v1

CNI 兼容性

CNI 插件也要升:

  • Calico:跨版本 K8s 升级前先升 Calico 到对应版本
  • Cilium:同上
  • 自研 CNI:每次升前测一遍
# 查当前 CNI 版本
kubectl get daemonset -n kube-system -l k8s-app=calico-node -o jsonpath='{.items[0].spec.template.spec.containers[0].image}'

回滚

K8s 不支持降级!只能:
- 备份 etcd
- 失败就用 etcd 备份恢复,然后从快照重启

我的升级 checklist

  1. ☐ 备份 etcd(强制)
  2. ☐ 看 release notes(关键)
  3. ☐ staging 演练过(必须)
  4. ☐ kubelet 配置文件检查兼容性
  5. ☐ CRI runtime 版本检查
  6. ☐ 准备 PodDisruptionBudget(drain 才不会断业务)
  7. ☐ 一台一台升,每台升完观察 10 分钟

教训:跨版本升级出问题最常见的是 webhook(如 cert-manager、ingress-nginx)和 CNI 不兼容,新版本一定先升 webhook 再升 K8s。