Helm Chart 安全加固实战:K8s 部署基线

Helm Chart 安全加固常被忽略的一点是:Chart 本身就是一段会渲染出 Kubernetes 资源的模板代码,谁来写 values、模板函数怎么用,直接决定集群里跑出的是不是一台「root 权限 + 可写根文件系统 + 无资源限制」的容器。本文从模板注入、securityContext 基线、密钥管理、CI 门禁四个维度给出可复制执行的配置与校验命令。

适用场景

  • 团队用 Helm 交付自研服务,Chart 由开发提交、CI 自动打包发布;
  • 多个业务共用一套基础 Chart,values 由不同项目组自行覆盖;
  • 集群做安全基线检查时,发现大量 Pod 以 root 运行、未限制 capabilities;
  • 需要通过等保、ISO 27001 或内部审计对「部署产物可控」的核查。

不适用场景:仅用 kubectl apply 管理裸 YAML、不涉及 Helm 的环境;此类环境应改用 Kyverno 或 Gatekeeper 做准入校验,本文第五节的策略部分仍可复用。

前置条件

  • Helm 3.12 以上,helm version 可执行;
  • Kubernetes 1.25 以上,已启用 PodSecurity Admission(PSA);
  • CI 环境可执行 shell 脚本,具备拉取镜像与访问集群的权限;
  • 已安装 kubeconform、kubesec、kyverno CLI 中的至少两个,用于门禁校验;
  • 具备对目标命名空间的 get/list 权限,用于部署后核验。

原理说明

Helm 的安全问题集中在四个位置,理解它们才能判断哪些配置真正有效:

  • 模板求值:{{ .Values.image }} 只是文本替换,但 {{ tpl .Values.cmd . }} 会把 values 里的字符串当作模板再次求值。若 values 可被低权限用户修改,等于把模板执行权交了出去;
  • 渲染产物:Chart 渲染出的 YAML 才是集群实际执行的对象。模板里没写 securityContext,Pod 就以镜像默认用户(通常是 root)运行;
  • values 文件:values.yaml 常被提交进 Git,若其中直接写数据库口令、API Key,等同于把密钥写进版本历史;
  • 依赖 Chart:charts/ 目录下的 subchart 有自己的默认值,父 Chart 的 values 覆盖不到的部分会按 subchart 默认值渲染,容易漏配。

因此加固顺序是:先约束模板能力,再固定渲染产物的安全字段,再切断密钥的明文来源,最后用 CI 门禁把前三步变成不可绕过的流程。

操作步骤

步骤一:移除模板注入面,用 schema 约束 values

禁止在模板中对用户可控字符串使用 tpl。若确实需要动态命令,改用白名单枚举:

{{- /* 不安全:values 内容被当作模板求值 */ -}}
{{- /* args: {{ tpl .Values.extraArgs . }} */ -}}

{{- /* 安全:只接受预定义枚举 */ -}}
{{- $allowed := dict "serve" "/app/server --port=8080" "migrate" "/app/migrate up" -}}
{{- $cmd := required "cmd 必填" .Values.cmd -}}
{{- if not (hasKey $allowed $cmd) -}}
{{- fail (printf "不支持的 cmd: %s" $cmd) -}}
{{- end -}}
command: ["/bin/sh", "-c"]
args: [{{ get $allowed $cmd | quote }}]

再用 values.schema.json 把类型、枚举、长度固定下来,非法 values 在 helm lint 阶段即被拒绝:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "cmd"],
  "properties": {
    "image": {
      "type": "object",
      "required": ["repository", "digest"],
      "properties": {
        "repository": { "type": "string", "pattern": "^[a-z0-9./-]+$" },
        "digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }
      },
      "additionalProperties": false
    },
    "cmd": { "type": "string", "enum": ["serve", "migrate"] },
    "replicaCount": { "type": "integer", "minimum": 1, "maximum": 20 }
  }
}

注意 digest 用 pattern 强制 sha256,从源头阻断 latest 标签;additionalProperties: false 防止拼写错误的键被静默忽略。

步骤二:固定 securityContext 基线

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: {{ .Chart.Name }}
      image: "{{ .Values.image.repository }}@{{ .Values.image.digest }}"
      imagePullPolicy: IfNotPresent
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        privileged: false
        capabilities:
          drop: ["ALL"]
      resources:
        requests: { cpu: "100m", memory: "128Mi" }
        limits:   { cpu: "500m", memory: "512Mi" }
      volumeMounts:
        - { name: tmp, mountPath: /tmp }
        - { name: cache, mountPath: /app/cache }
  volumes:
    - name: tmp
      emptyDir: { medium: Memory, sizeLimit: 64Mi }
    - name: cache
      emptyDir: { sizeLimit: 256Mi }

参数要点:readOnlyRootFilesystem 开启后必须为需要写入的路径挂 emptyDir,否则应用启动即报只读错误;drop: ALL 之后再按需 add 单项 capability,比默认保留 NET_RAW 等权限安全得多;emptyDir 加 sizeLimit 防止容器写满节点磁盘。

步骤三:用 NetworkPolicy 收口流量

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ .Release.Name }}-default-deny
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]

---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ .Release.Name }}-allow-web
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: {{ .Chart.Name }}
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
      ports: [{ protocol: TCP, port: 8080 }]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
      ports: [{ protocol: UDP, port: 53 }]
    - to:
        - podSelector:
            matchLabels: { app.kubernetes.io/name: postgres }
      ports: [{ protocol: TCP, port: 5432 }]

先 default-deny 再逐条放行,是唯一可靠的做法。只写 allow 规则而不写 default-deny,未匹配的流量仍按放行处理(取决于 CNI 实现),等同于没配。

步骤四:切断密钥明文来源

禁止在 values.yaml 中出现口令字段。推荐用 External Secrets 从外部密钥库同步,或至少用 SOPS 加密后提交:

# 反例:不要这样写
# database:
#   password: "Pr0d_P@ssw0rd"

# 正例:声明式引用,值由 ExternalSecret 注入
env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: {{ .Release.Name }}-db
        key: password

---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-db
spec:
  refreshInterval: 1h
  secretStoreRef: { name: vault-backend, kind: ClusterSecretStore }
  target: { name: {{ .Release.Name }}-db }
  data:
    - secretKey: password
      remoteRef: { key: apps/{{ .Release.Name }}/db, property: password }

若暂无密钥库,可用 SOPS + age 加密 values:sops -e -i values-prod.yaml,Helm 侧配合 helm-secrets 插件解密,明文只存在于内存。

步骤五:CI 门禁串联四项校验

set -euo pipefail
CHART=./charts/app

# 1. 模板语法与 values 合规(含 schema 校验)
helm lint "$CHART" -f values-prod.yaml

# 2. 渲染产物结构校验(对 K8s API schema)
helm template rel "$CHART" -f values-prod.yaml --namespace prod \
  | kubeconform -strict -summary -kubernetes-version 1.28.0

# 3. 安全上下文评分,低于 5 分直接失败
helm template rel "$CHART" -f values-prod.yaml \
  | kubesec scan - | jq -e '.[0].score >= 5' > /dev/null

# 4. 策略准入校验(Kyverno CLI 离线跑)
helm template rel "$CHART" -f values-prod.yaml \
  | kyverno apply policies/ --resource - --policy-report

# 5. 依赖 Chart 也一并检查
helm dependency list "$CHART"

四道校验的定位不同:lint 管 values 形状,kubeconform 管字段是否存在与类型是否正确,kubesec 管安全字段是否齐全,Kyverno 管业务策略(如必须打标签、禁止 hostPath)。set -euo pipefail 保证任一环节失败即中断流水线。

配置验证

# 渲染结果中不应出现 root、latest、privileged
helm template rel ./charts/app -f values-prod.yaml \
  | grep -nE "runAsUser: 0|image:.*:latest|privileged: true" || echo "OK: 无高危字段"

# 部署后核对实际 Pod 的安全上下文
kubectl get pod -n prod -l app.kubernetes.io/name=app \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.securityContext.runAsNonRoot}{"\t"}{.spec.containers[0].securityContext.readOnlyRootFilesystem}{"\n"}{end}'

# 确认 capabilities 已清空
kubectl get pod -n prod -l app.kubernetes.io/name=app \
  -o jsonpath='{.items[0].spec.containers[0].securityContext.capabilities}'

# 验证 NetworkPolicy 是否生效(应从非允许命名空间超时)
kubectl run nettest --rm -it --image=busybox -n default -- \
  wget -T 3 -qO- http://app.prod.svc:8080/healthz

# 验证镜像以 digest 拉取
kubectl get pod -n prod -l app.kubernetes.io/name=app \
  -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'

判定标准:grep 无高危字段输出;runAsNonRoot 为 true、readOnlyRootFilesystem 为 true、capabilities 为空对象;跨命名空间访问超时;imageID 为 sha256 摘要而非标签。

常见问题

FAQ 1:helm template 与 helm install --dry-run 的渲染结果不一致,以哪个为准?

两者执行的是同一套模板引擎,差异来自集群上下文。helm install --dry-run 会连接集群,能拿到 Capabilities(K8s 版本、可用 API)、已存在的 Secret/ConfigMap 查询结果,以及 .Release.IsInstall 等状态;helm template 默认使用本地假定的 Capabilities,版本信息需手动指定 --kube-version。做安全门禁时以 helm template 为主,因为它可离线、可重复;但必须在命令中固定 --kube-version 与 --api-versions,否则模板里的版本判断会走错分支,渲染出与生产不同的对象。

FAQ 2:开启了 readOnlyRootFilesystem,Java 应用启动报写入失败怎么办?

先定位写入路径,而不是直接关掉该选项。常见位置有三处:临时目录 /tmp、日志目录、以及 JVM 的 java.io.tmpdir。依次挂载 emptyDir 到这些路径,并用 -Djava.io.tmpdir=/tmp 显式指定;若应用使用 Tomcat 或 Spring Boot 的可执行 jar,还需为工作目录挂载可写卷。挂载后仍失败,用 kubectl exec 进入容器执行 touch /path/test 定位具体目录,不要凭猜测逐个试。

FAQ 3:subchart 的默认值覆盖不到,导致依赖组件以 root 运行,如何统一约束?

两种做法。一是在父 Chart 的 values.yaml 中显式写出 subchart 的完整路径覆盖,例如 postgresql.securityContext.runAsUser,并在 CI 中渲染后整体 grep 高危字段,防止漏配。二是在集群侧用 Kyverno 的 mutate 规则为所有 Pod 注入基线 securityContext,作为兜底。前者治本但需随 subchart 版本升级维护,后者治标但能保证「无论谁写的 Chart,落地的 Pod 都符合基线」,生产环境建议两者并用。

总结

Helm Chart 安全加固的核心是把「模板能力、渲染产物、密钥来源、交付流程」四件事分别收口:模板层去掉 tpl 求值并用 schema 约束输入,产物层固定 securityContext 与资源限制,密钥层用 External Secrets 或 SOPS 切断明文,流程层用 lint、kubeconform、kubesec、Kyverno 四道门禁把前三层变成不可跳过的一步。配置完成后,用渲染结果 grep 与实际 Pod 的 jsonpath 双向核对,才能确认基线真的落地,而不是只写在模板里。