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 双向核对,才能确认基线真的落地,而不是只写在模板里。