📎 配套脚本附件正文未内嵌 · 共 6 个 · 点开可在线查看
📂 查看全部 126 个脚本附件 →
⚠️ 脚本内含明文凭据(与本页正文一致),请勿在公网环境直接运行。
K8s · 探针与密钥

用 NVIDIA GPU Operator 暴露 H20:vLLM 探针、本地模型和 Vault 密钥

GPU Operator 让节点报出 nvidia.com/gpu,给 vLLM 加启动 / 存活 / 就绪三类探针,再用 HashiCorp Vault 1.21.2 统一保管环境变量并同步回 Kubernetes Secret。

用 NVIDIA GPU Operator 暴露 H20:vLLM 探针、本地模型和 Vault 密钥

目录

  1. 一、这些组件各自做什么
  2. 二、动手前现网是什么样
  3. 三、安装 NVIDIA GPU Operator
  4. 四、任务 A:给 vLLM 加探针
  5. 五、任务 B:模型文件留在宿主机
  6. 六、任务 C:用 Vault 管密钥
  7. 七、补上的 Xinference 模型
  8. 八、做完时的核对
  9. 九、文件

本文记录这一阶段在现网上的做法:安装 NVIDIA GPU Operator,让节点报出 nvidia.com/gpu;给 vLLM 加启动、存活和就绪探针;确认模型权重在宿主机目录里;用 HashiCorp Vault 1.21.2 保存各服务的环境变量,再同步回原来的 Kubernetes Secret。机器是 prod102,地址 10.0.1.47。集群、Dify、New-API 和监控是怎么装上的,写在《Ubuntu 单机从驱动装到 Kubernetes:H20 上跑 vLLM、Dify 和监控》和《Docker Compose 迁到单节点 Kubernetes:vLLM、Dify、Langfuse 逐个怎么搬》。本文只写这一阶段新增的原理和改造。KEDA、GitLab 和 Argo CD 写在目录 cicd。

scripts/ 里的程序和下面的代码块是同一份。01 到 03 发现现网已经装过就会停住,避免再初始化一次 Vault。Xinference 的启动脚本可以重复执行,已经在跑的模型会跳过。

登录口令沿用现网值。Vault 根令牌、Langfuse 的 NEXTAUTH_SECRET、SALT、ENCRYPTION_KEY、Redis 口令、MinIO 口令和 kubeadm 加入令牌不写进本文。根令牌在服务器 /usr/cluster/vault/init.json,权限 600,导航页卡片上有同一份,用来登录网页。

一、这些组件各自做什么

请求路径是:浏览器打开 Dify,对话从 Dify 到 New-API,再到 vLLM 的 qwen3.6-27b。知识库的向量和重排到 Xinference 的 bge-m3、bge-reranker-v2-m3。这四个业务加上 Langfuse、监控,原来的环境变量已经放在 Kubernetes Secret 里,这一阶段把同一份内容再存进 Vault。

1. vLLM

vLLM 是一个推理服务进程。它把 Hugging Face 格式的权重读进 GPU,再按 OpenAI 的 HTTP 形状提供 /v1/chat/completions、/v1/models 和 /health。本机的启动命令是 vllm serve /model,对外名字 qwen3.6-27b,上下文长度 65536,数据类型 bfloat16,显存占用比例 0.70。

权重和 KV 缓存都在显存里。普通的一次生成会把历史 Token 的 Key、Value 一直留着。vLLM 用分页去放这些缓存:显存被切成固定大小的块,一段对话占用若干块,块可以不连续。这样多条请求挤在同一张卡上时,不用为每条请求预留一整段连续显存。

连续批处理是另一件事。一个批次正在算的时候,新请求可以插进来,先算完的请求可以先离开,不用等这一批里最长的那条结束。--max-num-seqs 8 限制同时处理的序列数。--enable-prefix-caching 让相同前缀的 KV 可以复用,系统提示词重复出现时少算一遍。

/health 要等引擎把模型加载完才返回 200。加载的那几分钟里,端口往往还没打开,探针看到的是连接失败。这就是任务 A 要补的空窗。

gpu-memory-utilization 0.70 是进程启动时按整卡显存的 70% 给自己划一块,里面包括权重和 KV。它不是 Kubernetes 的 nvidia.com/gpu 申请。本机 Docker 的默认运行时已经是 nvidia,Pod 不写这个资源申请,照样能看见卡。节点上 GPU Operator 已经报出 nvidia.com/gpu: 1,这份申请一旦写上,整张卡的调度名额会被占住。vLLM 继续不写。

2. Xinference

Xinference 把「登记一个模型」和「真正把模型加载进进程」分开。xinference-local 在同一个进程里起一个 supervisor 和一个 worker。supervisor 接受「启动某个模型」的请求,worker 负责下载或读取权重、建虚拟环境、把模型放上 GPU。

每个运行中的模型有一个 model_uid。副本编号由程序自己加在后面,所以提交时不能写 bge-m3-rep0 这种以 -rep数字 结尾的名字,那个后缀是它留给副本的。本机对外的 id 是 bge-m3 和 bge-reranker-v2-m3。

权重从 ModelScope 目录读,环境变量 XINFERENCE_MODEL_SRC=modelscope,家目录 XINFERENCE_HOME=/data。宿主机目录 /usr/cluster/dify/xinference 挂在 /data。里面有三样东西:

路径内容
modelscope/models/Xorbits--bge-m3向量模型,约 4.3GB
modelscope/models/AI-ModelScope--bge-reranker-v2-m3重排模型,约 2.2GB
virtualenv/v4/...每个模型自己的 Python 虚拟环境
launch_history.db自启动记录
auth/auth.db登录用户和 API Key 的哈希

自启动存在 launch_history.db。进程重启后,supervisor 读这张表,再把 enabled 的模型拉起来。表是空的时候,进程本身可以是 Running,模型列表仍是空的。迁移进 Kubernetes 之后遇到的就是这件事。

sentence_transformers 引擎不使用镜像里那一份依赖,它会在 /data/virtualenv 里再装一套。镜像一换,虚拟环境路径里的 Python 小版本对不上,就要重装。这一步只发生在第一次启动。

3. New-API

New-API 是放在模型和调用方之间的网关。调用方拿一把令牌访问 http://10.0.1.47:3000/v1。网关查这把令牌还有没有额度、是否过期、允许哪些模型,再按渠道把请求转到上游。

本机渠道名是 vLLM-Qwen3.6-27B,上游地址 http://10.0.1.47:8000,模型名 qwen3.6-27b。额度在网关里记账,vLLM 自己不认识这些令牌。管理员登录是 admin / L4o4986784$。

它和 vLLM 的关系是:New-API 决定「谁可以花多少钱调用」,vLLM 决定「这一次生成怎么在 GPU 上算完」。Dify 不直接拿着管理员账号去打 vLLM,它使用在 New-API 里发放的令牌。

4. Dify

Dify 是应用层。一个应用里可以有对话、知识库检索、工作流和工具调用。网页、API、异步 worker、定时任务、沙箱、插件进程和 Nginx 是分开的 Deployment,数据在 Postgres、pgvector 和 Redis 里。

对话模型配置成 OpenAI 兼容接口,地址指向 New-API。知识库把文档切成段,用 Xinference 的 bge-m3 做成向量,放进 pgvector。提问时先按向量找出候选段落,再用 bge-reranker-v2-m3 重新排序,然后把段落和问题一起交给 qwen3.6-27b。

所以 Dify 自己不加载 27B 模型,也不在自己的进程里跑 bge。它保存的是应用配置、文档和索引。模型进程挂了,Dify 的页面还在,只是对话和检索会失败。对外页面是 http://10.0.1.47:8088,登录 admin@example.com / Dify-rag-2026。

5. Vault 和 External Secrets

Vault 是密钥库。写入的值先被加密,再落到存储后端。本机后端是文件,目录 /usr/cluster/vault/data,在数据盘上。

加密用的主密钥默认不放在磁盘明文里。进程刚启动时处于封印状态,磁盘上的文件在,内存里没有主密钥,读不了内容。解封是把主密钥恢复进内存。本机按实验环境做成 1 份共享、阈值 1,解封密钥和根令牌写在 /usr/cluster/vault/init.json。Pod 一重启会重新封印,需要再执行 scripts/06-解封Vault.sh。业务 Pod 不直接读 Vault,所以封印期间已经跑着的 Dify 和 vLLM 不会掉环境变量。

KV 第 2 版把每次写入做成一个版本,路径形如 secret/data/apps/llm/vllm-env。命令行里写 vault kv get -mount=secret apps/llm/vllm-env,挂载点 secret 和数据路径是分开的。

Kubernetes 认证做的是:External Secrets 的 ServiceAccount 令牌交给 Vault,Vault 再请 API Server 核对这枚令牌。对上之后,角色 external-secrets 只拿到策略 read-apps,只能读 secret/data/apps/*,不能改 Vault 里的值。

External Secrets Operator 是同步程序。它按 ExternalSecret 去 Vault 取值,写进同名的 Kubernetes Secret。Deployment 继续写:

yaml
envFrom:
  - secretRef:
      name: vllm-env

容器只在启动时读环境变量。Secret 内容没变,就不滚动 Pod。以后要改口令,改 Vault 里的版本,一个小时内 Operator 会把 Kubernetes Secret 刷新成新值,下一次 Pod 启动用新值。

网页不直接开在 vault-0 的 hostPort 上。改 StatefulSet 的端口会重建 Pod,Vault 会封印。旁边加了一个 Nginx,把节点的 8200 转到 Service vault-ui。

6. NVIDIA GPU Operator

GPU Operator 是一套控制器。它看节点上有没有 NVIDIA 设备,再按一份 ClusterPolicy 决定要不要把驱动、容器工具、设备插件、特性发现、监控导出和校验程序拉起来。

节点特征发现(Node Feature Discovery)负责贴标签,例如 PCI 厂商 10de、nvidia.com/gpu.present=true。设备插件向 kubelet 注册扩展资源 nvidia.com/gpu。调度器看到 Pod 写了 resources.limits.nvidia.com/gpu: 1,才把这张卡的名额分给它,并让设备插件把可见设备写进容器环境。GPU Feature Discovery 再补更细的标签,例如型号。校验 Pod 用来确认驱动、工具包、CUDA 和设备插件这条链是通的。

驱动容器和 Toolkit 容器是另外两条链。驱动容器会在节点上安装内核模块。Toolkit 容器会改 Docker 的 daemon.json 并重启 Docker。这台机器上驱动 550.120 和 nvidia-container-toolkit 1.17.8 已经在宿主机里,Docker 的默认运行时已经是 nvidia。这两项如果再交给 Operator,会动到正在跑 vLLM 的那张卡和 Docker。DCGM Exporter 也已经有一份在 9400,再装一份会抢同一台 hostengine。MIG 管理器会改 GPU 的分区,保密计算开关也会动卡。这几项在本机都关掉。

Operator 默认给 GPU 相关 Pod 写 runtimeClassName: nvidia。本机的 cri-dockerd 0.4.8 只接受空的运行时处理器,或者名字就是 docker 的处理器。nvidia 会在创建沙箱时报 RuntimeHandler "nvidia" not supported,设备插件起不来,节点也就不会出现 nvidia.com/gpu。把 ClusterPolicy 里的 operator.runtimeClass 设成 docker 之后,沙箱能建出来。容器仍走 Docker 的默认运行时,而默认运行时就是 nvidia,所以容器里看得到卡。

nvidia.com/gpu 是调度名额,不是第二块物理卡。整机还是一张 H20。vLLM 不写这个申请,它靠默认运行时占用显存。谁再申请 1,调度器就认为这张卡的名额用完了。验收时跑过一次 nvidia-smi -L,看完就删掉了。

二、动手前现网是什么样

项目现网
Kubernetes1.37.1,运行时 cri-dockerd 0.4.8,Docker 28.0.1
节点prod102,10.0.1.47
vLLMDeployment llm/vllm,hostPort 8000,当时没有探针
模型目录/models 指向 /usr/cluster/models,权重在 /models/Qwen3.6-27B
XinferenceDeployment 在跑,模型没加载
环境变量四个命名空间的容器都是 envFrom.secretRef,Deployment 模板里没有明文 value
GPU Operator这一阶段才装。装之前集群里没有 nvidia.com/gpu。装完后的参数和验收写在第三节

四个命名空间:

命名空间工作负载
llmvLLM、Xinference、New-API、导航
difyDify 全套
langfuseLangfuse、Postgres、ClickHouse、MinIO、Redis
observabilityPrometheus、Grafana、DCGM Exporter

宿主机上 Supervisor 管的三个 Python 接口、server-status 和 80 端口的 Nginx 都不进这次改造。

三、安装 NVIDIA GPU Operator

1. 安装时关掉的组件

Chart 是 nvidia/gpu-operator,版本 v26.7.1,命名空间和 Helm release 都叫 gpu-operator。仓库地址 https://helm.ngc.nvidia.com/nvidia。

开关值原因
driver.enabledfalse宿主机已有 nvidia-driver-550 550.120
toolkit.enabledfalse已有 nvidia-container-toolkit 1.17.8。打开后 operand 会改 Docker 配置并重启 Docker
cdi.enabledfalseCDI 面向 containerd、CRI-O。本机是 cri-dockerd
dcgmExporter.enabledfalseobservability 里已经有 DCGM Exporter,hostPort 9400
migManager.enabledfalse不在 vLLM 运行时重划 H20 的 MIG
ccManager.enabledfalse不切换保密计算。chart 默认模式可能是打开的
operator.runtimeClassdockercri-dockerd 0.4.8 只接受这个处理器名字
node-feature-discovery.image.repositorym.daocloud.io/registry.k8s.io/nfd/node-feature-discoveryregistry.k8s.io 拉取超时

子 chart 的名字是 node-feature-discovery,版本 0.19.0。写 nfd.image.repository 不会改到它的镜像。第一次升级用错过这个键,值留在 Helm 的 user values 里,但渲染出来的镜像不变。生效的键是 node-feature-discovery.image.repository。镜像标签用 chart 的 v0.19.0。

config/gpu-operator-values.yaml:

yaml
driver:
  enabled: false
toolkit:
  enabled: false
cdi:
  enabled: false
dcgmExporter:
  enabled: false
migManager:
  enabled: false
ccManager:
  enabled: false
operator:
  runtimeClass: docker
node-feature-discovery:
  image:
    repository: m.daocloud.io/registry.k8s.io/nfd/node-feature-discovery

2. 实际安装和两处失败

能直接用的安装命令:

bash
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update nvidia
helm upgrade --install gpu-operator nvidia/gpu-operator \
  --version v26.7.1 \
  -n gpu-operator --create-namespace \
  -f config/gpu-operator-values.yaml

scripts/08-安装GPUOperator.sh 就是这段。现网 release 已经是 deployed、revision 4 时,脚本会退出。

第一次安装只关了驱动、Toolkit、CDI、DCGM Exporter、MIG 和 CC,没有改运行时类,也没有改 NFD 镜像。随后发生两件事。

NFD 的默认镜像是 registry.k8s.io/nfd/node-feature-discovery:v0.19.0。kubelet 去拉的时候,上游登记地址超时。docker.m.daocloud.io 返回 502。m.daocloud.io/registry.k8s.io/nfd/node-feature-discovery:v0.19.0 能拉下来。只在本机 docker tag 成 registry.k8s.io/... 不够,kubelet 仍会向原仓库发请求。后来把 worker DaemonSet、master 和 gc 的镜像改成 daocloud 这一个名字,Pod 才起来。Helm 升级时用上面的正确键,避免下一次升级又变回 registry.k8s.io。

设备插件、GPU Feature Discovery 和 validator 当时是 Pending。事件是 Failed to create pod sandbox: RuntimeHandler "nvidia" not supported。cri-dockerd 0.4.8 的沙箱代码大致是:处理器名字非空,并且不等于运行时名 docker,就直接拒绝。没有参数可以再登记一个 nvidia 处理器。把 operator.runtimeClass 设成空值也不行,Helm 的 --set operator.runtimeClass=null 把整个 spec.operator 抹掉,API 返回 spec.operator: Required value,那一次升级失败,revision 停在 failed。ClusterPolicy 里的字段当时还是 nvidia。

改成 docker 的升级成功,release revision 为 4,状态 deployed。ClusterPolicy cluster-policy 上现在是 runtimeClass=docker,上面那六个开关都是 false。设备插件镜像是 nvcr.io/nvidia/k8s-device-plugin:v0.20.1,runtimeClassName 为 docker。

3. 装完后哪些 Pod 在跑

Pod状态
gpu-operator-... 控制器1/1 Running
NFD master、gc、worker1/1 Running,镜像为 daocloud 上的 v0.19.0
nvidia-device-plugin-daemonset1/1 Running
gpu-feature-discovery1/1 Running
nvidia-operator-validator1/1 Running
nvidia-cuda-validatorCompleted

没有驱动 DaemonSet,没有 Operator 自己的 DCGM Exporter,也没有 MIG Manager。节点 prod102 的可分配资源里 nvidia.com/gpu 为 1,标签 nvidia.com/gpu.present=true。

验收 Pod 用的是本机已经有的 vLLM 镜像,只跑 nvidia-smi -L,申请 nvidia.com/gpu: 1。输出是 GPU 0: NVIDIA H20,UUID GPU-0a9cfb9a-ef39-5b22-54ec-22474c4cfd76。看完就删了,避免名额一直被占着。清单在 config/gpu-smi-pod.yaml。vLLM 的 /health 在这次前后都是 200,宿主机三个 Python 服务没有重启。

yaml
apiVersion: v1
kind: Pod
metadata:
  name: gpu-operator-smi
  namespace: gpu-operator
spec:
  restartPolicy: Never
  nodeName: prod102
  containers:
    - name: smi
      image: docker.m.daocloud.io/vllm/vllm-openai:latest
      imagePullPolicy: IfNotPresent
      command: ["nvidia-smi", "-L"]
      resources:
        limits:
          nvidia.com/gpu: "1"

现网 vLLM、Xinference 仍然不写 nvidia.com/gpu。它们继续用 Docker 默认的 nvidia 运行时共享这一张卡。谁要单独占调度名额,再给那个 Pod 加上面的 limits。加上之后,在没有时间分片的情况下,第二个这样的 Pod 会 Pending。

再看安装结果:

bash
helm list -n gpu-operator
kubectl -n gpu-operator get pods
kubectl get node prod102 -o jsonpath='{.status.allocatable.nvidia\.com/gpu}{"\n"}'

四、任务 A:给 vLLM 加探针

1. 要解决的问题

模型加载要几分钟。没有就绪探针时,容器一进 Running,Service 的 Endpoints 里就有这个 Pod,kube-proxy 会把流量送进来。加载期间请求进得去、没有正常响应。

Kubernetes 有三种 HTTP 探针,本机都打在 GET /health:8000:

探针失败了会怎样本机参数
startupProbe连续失败达到阈值,重启容器每 10 秒一次,允许 36 次,约 6 分钟
livenessProbe启动探针通过之后才开始。失败达到阈值就重启容器延迟 120 秒,每 10 秒,失败 5 次
readinessProbe不重启。没通过时 Pod 是 0/1,不进 Endpoints延迟 60 秒,每 5 秒,失败 3 次

只按任务卡片上的存活探针来算:第一次检查在 120 秒,之后每 10 秒一次,第 5 次失败大约在 160 秒。这次实测模型加载用了 181 秒。160 秒时 /health 还没有 200,kubelet 会把容器杀掉,进入重启循环,页面上就看不到稳定的 0/1 再变成 1/1。

启动探针先跑。它失败只会计数,在 6 分钟用完之前不触发存活探针。181 秒时 /health 返回 200,启动探针通过。这时 120 秒的延迟已经过去,存活和就绪探针马上各查一次,就绪通过,Pod 变成 1/1。重启次数保持 0。

2. 改的是哪一段

完整清单在 config/vllm.yaml,也抄在本节末尾。探针只加在容器 vllm 上,镜像、参数、hostPath 和 envFrom 没动。现网用的是对 Deployment 的 strategic merge patch,效果和这份清单里的探针段一致。

yaml
startupProbe:
  httpGet:
    path: /health
    port: 8000
  periodSeconds: 10
  failureThreshold: 36
livenessProbe:
  httpGet:
    path: /health
    port: 8000
  initialDelaySeconds: 120
  periodSeconds: 10
  failureThreshold: 5
readinessProbe:
  httpGet:
    path: /health
    port: 8000
  initialDelaySeconds: 60
  periodSeconds: 5
  failureThreshold: 3

提交这段会改 Pod 模板,Deployment 的策略是 Recreate,旧 Pod 会被换掉,模型重新加载一次。这是任务要求的验收,不是平时改配置都要做的事。

3. 当时看到的过程

新 Pod 名叫 vllm-67b8d8c9f6-vvfsq。

时刻状态
替换后约 5 秒Running,ready=False,也就是 0/1
181 秒ready=True,1/1
189 秒仍是 1/1,重启次数 0
之后http://127.0.0.1:8000/health 为 200

Service vllm.llm 在这 181 秒里没有这个 Pod 的 Endpoints。New-API 渠道写的是 http://10.0.1.47:8000,这是 Pod 的 hostPort。hostPort 在容器起来时就在节点上听着,不看 Endpoints。加载的那三分钟里,打到节点 8000 端口的请求仍会进到还没就绪的进程。要让这条路径也等探针,渠道要改成 Service 的地址。这次没有改渠道。

4. 完整的 vLLM 清单

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm
  namespace: llm
  labels:
    app: vllm
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: vllm
  template:
    metadata:
      labels:
        app: vllm
      annotations:
        llm.local/compose-service: "vllm"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: vllm
          image: docker.m.daocloud.io/vllm/vllm-openai:latest
          imagePullPolicy: IfNotPresent
          command: ["vllm", "serve"]
          args:
            - "/model"
            - "--trust-remote-code"
            - "--dtype"
            - "bfloat16"
            - "--tensor-parallel-size"
            - "1"
            - "--enable-auto-tool-choice"
            - "--tool-call-parser"
            - "qwen3_xml"
            - "--reasoning-parser"
            - "qwen3"
            - "--max-model-len"
            - "65536"
            - "--gpu-memory-utilization"
            - "0.70"
            - "--max-num-seqs"
            - "8"
            - "--served-model-name"
            - "qwen3.6-27b"
            - "--enable-prefix-caching"
            - "--host"
            - "0.0.0.0"
            - "--port"
            - "8000"
          envFrom:
            - secretRef:
                name: vllm-env
          ports:
            - containerPort: 8000
              hostPort: 8000
              protocol: TCP
          startupProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 10
            failureThreshold: 36
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120
            periodSeconds: 10
            failureThreshold: 5
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 60
            periodSeconds: 5
            failureThreshold: 3
          volumeMounts:
            - name: vol0
              mountPath: "/model"
            - name: dshm
              mountPath: "/dev/shm"
      volumes:
        - name: vol0
          hostPath:
            path: "/models/Qwen3.6-27B"
            type: Directory
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 8Gi
---
apiVersion: v1
kind: Service
metadata:
  name: vllm
  namespace: llm
spec:
  selector:
    app: vllm
  ports:
    - name: p8000
      port: 8000
      targetPort: 8000
      protocol: TCP

文件:config/vllm.yaml。

五、任务 B:模型文件留在宿主机

1. 任务卡片上的路径和这台机器的路径

卡片示例是把宿主机的 /data/ai-models/huggingface 挂到容器的 /root/.cache/huggingface。那是 Hugging Face 客户端的下载缓存。程序如果写 from_pretrained("Qwen/某个仓库名"),会先看这个缓存,没有再去网上拉。

本机不是这条路径。vllm serve 的第一个参数是本地目录 /model。权重已经在磁盘上,进程直接读这个目录,不经过 Hugging Face 缓存。对应关系是:

位置路径
宿主机看到的路径/models/Qwen3.6-27B
/models 实际指向/usr/cluster/models
容器内/model
卷类型hostPath,type: Directory

Directory 要求目录事先存在,不会建一个空目录。DirectoryOrCreate 会在没有目录时建一个空的,然后 vLLM 会去下载。这里用 Directory。

目录大约 52GB,15 个 model-*-of-00015.safetensors,外加 config.json。df 显示这块盘是 /dev/nvme1n1p1,挂在 /usr/cluster,当时还剩约 533GB。文件不在容器可写层里。换镜像或重建 Pod,挂的还是同一份。

/dev/shm 是另一个卷:emptyDir,介质 Memory,上限 8Gi。PyTorch 和 vLLM 会用共享内存。它跟模型权重无关,Pod 删掉就没了。

这次没有新建 /data/ai-models/huggingface,也没有把 serve 参数改成一个仓库名。那样做会让进程去找缓存,和已经放好的 52GB 目录脱节。

2. 怎么确认挂载还在

bash
kubectl -n llm get deploy vllm -o jsonpath='{range .spec.template.spec.volumes[*]}{.name}{" "}{.hostPath.path}{"\n"}{end}'
kubectl -n llm exec deploy/vllm -- ls /model/config.json

第一行应有 vol0 /models/Qwen3.6-27B。容器里能看到 config.json,就说明读的是宿主机那份。

六、任务 C:用 Vault 管密钥

1. 动手时 Deployment 里已经没有明文

四个命名空间里每个容器都是 envFrom 引用一个 *-env Secret,env.value 是空的。kubectl get deploy -o yaml 看不到口令。kubectl get secret -o yaml 仍能看到 Base64。Base64 不是加密,拿到 Secret 读权限的人能解开。Vault 这一层把同一份材料加密后放在数据盘上,并用策略限制谁能读。

observability/prometheus-env 的 data 是空的,没有写入 Vault,也没有 ExternalSecret。

写入 Vault 的 27 个名字:

命名空间Secret
llmnav-env、new-api-env、vllm-env、xinference-env
difyagent-backend-env、agent-ssrf-proxy-env、api-env、api-websocket-env、db-postgres-env、local-sandbox-env、nginx-env、pgvector-env、plugin-daemon-env、redis-env、sandbox-env、ssrf-proxy-env、web-env、worker-beat-env、worker-env
langfuseclickhouse-env、langfuse-web-env、langfuse-worker-env、minio-env、postgres-env、redis-env
observabilitydcgm-exporter-env、grafana-env

导入时按键集合对过一遍,同步前再按全部键值的摘要对过一遍,27 个都一致,然后才提交 ExternalSecret。

2. 安装 Vault

Helm chart hashicorp/vault 0.32.0,程序版本 1.21.2。官方镜像名 hashicorp/vault:1.21.2 在 kubelet 里拉取被取消。先用本机 Docker 从 docker.m.daocloud.io/hashicorp/vault:1.21.2 拉下来,再把 chart 的镜像仓库改成这个名字。

文件后端、界面打开、关闭 mlock。容器用户是 uid 100、gid 1000,数据目录要先交给这个用户,否则起不来:

bash
mkdir -p /usr/cluster/vault/data
chown 100:1000 /usr/cluster/vault/data
chmod 770 /usr/cluster/vault/data
chmod 700 /usr/cluster/vault

injector.enabled=false,csi.enabled=false。注入器是一个会改 Pod 的 webhook,这次不让它碰已经在跑的业务。认证委托打开,后面 Kubernetes 认证要用这台 Vault 自己的 ServiceAccount 去做 TokenReview。

config/vault-values.yaml:

yaml
server:
  image:
    repository: docker.m.daocloud.io/hashicorp/vault
    tag: "1.21.2"
    pullPolicy: IfNotPresent
  authDelegator:
    enabled: true
  dataStorage:
    enabled: false
  standalone:
    enabled: true
    config: |
      ui = true
      disable_mlock = true
      listener "tcp" {
        tls_disable = 1
        address = "[::]:8200"
        cluster_address = "[::]:8201"
      }
      storage "file" {
        path = "/vault/data"
      }
  volumes:
    - name: vault-data
      hostPath:
        path: /usr/cluster/vault/data
        type: DirectoryOrCreate
  volumeMounts:
    - name: vault-data
      mountPath: /vault/data
  service:
    type: ClusterIP
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      memory: 512Mi
injector:
  enabled: false
csi:
  enabled: false
ui:
  enabled: true
  serviceType: ClusterIP

安装:

bash
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update hashicorp
cp config/vault-values.yaml /usr/cluster/vault/values.yaml
helm upgrade --install vault hashicorp/vault \
  --version 0.32.0 \
  -n vault --create-namespace \
  -f /usr/cluster/vault/values.yaml

scripts/01-部署Vault.sh 是这段的脚本形式。现网已经有 vault-0 时它会直接退出。

Pod 进 Running 之后还是封印的,就绪探针要等解封。初始化只用一次:

bash
kubectl -n vault exec vault-0 -- vault operator init \
  -key-shares=1 -key-threshold=1 -format=json > /usr/cluster/vault/init.json
chmod 600 /usr/cluster/vault/init.json

这条命令的标准输出就是根令牌和解封密钥,只能进那个文件,不要再复制到仓库。解封用 scripts/06-解封Vault.sh,它从文件里读密钥,终端只打印 sealed False。

然后启用 KV 第 2 版,挂载点就叫 secret。scripts/02-初始化并导入密钥.py 把上面 27 个 Secret 解码后写入 apps/<命名空间>/<Secret名>。打印出来的只有名字和键的个数。

核对:

bash
kubectl -n vault exec vault-0 -- vault status

现网结果是版本 1.21.2,sealed 为 false,Pod vault-0 为 1/1。

3. 安装 External Secrets,并接回原来的 Secret 名

Operator 的 chart 包放在 /usr/cluster/k8s/charts/external-secrets-2.12.0.tgz。GitHub 直接下这个包会中断,现网这份是经镜像站拿到的。程序镜像是 ghcr.dockerproxy.net/external-secrets/external-secrets:v2.12.0。控制器、webhook、证书控制器三个 Deployment 用同一份镜像。

bash
helm upgrade --install external-secrets /usr/cluster/k8s/charts/external-secrets-2.12.0.tgz \
  -n external-secrets --create-namespace \
  --set installCRDs=true \
  --set image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set image.tag=v2.12.0 \
  --set webhook.image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set webhook.image.tag=v2.12.0 \
  --set certController.image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set certController.image.tag=v2.12.0

三个 Deployment 都 Ready 之后,在 Vault 里启用 Kubernetes 认证,写入只读策略,角色绑到 external-secrets 这个 ServiceAccount:

hcl
path "secret/data/apps/*" {
  capabilities = ["read"]
}
path "secret/metadata/apps/*" {
  capabilities = ["read", "list"]
}
bash
vault auth enable kubernetes
vault write auth/kubernetes/config \
  kubernetes_host=https://kubernetes.default.svc.cluster.local:443 \
  kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
  token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token
vault policy write read-apps policy.hcl
vault write auth/kubernetes/role/external-secrets \
  bound_service_account_names=external-secrets \
  bound_service_account_namespaces=external-secrets \
  policies=read-apps \
  ttl=1h

config/cluster-secret-store.yaml:

yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: vault
spec:
  provider:
    vault:
      server: http://vault.vault.svc.cluster.local:8200
      path: secret
      version: v2
      auth:
        kubernetes:
          mountPath: kubernetes
          role: external-secrets
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

每个业务 Secret 一份 ExternalSecret。target.name 用原来的名字,creationPolicy: Merge 把 Vault 的键写进已经存在的 Secret,不把这个 Secret 换成另一个名字,Deployment 就不用改。deletionPolicy: Retain 保证删掉 ExternalSecret 时,Kubernetes Secret 还留着。刷新间隔 1 小时。

vllm-env 的例子在 config/externalsecret-vllm.yaml。其余 26 个只是命名空间和名字不同。scripts/04-生成ExternalSecret.py 会按集群里现有的非空 Secret 把它们全部提交。

现网提交之后,kubectl get clustersecretstore vault 是 Valid、Ready=True。27 个 ExternalSecret 的状态都是 SecretSynced。llm/vllm、Dify、New-API 的 Deployment 年龄没有变,说明这次同步没有触发滚动更新。

4. 网页入口

Service vault-ui 仍是 ClusterIP 10.110.22.44:8200,只能在集群网络里访问。节点上另外部署 vault-ui-proxy,hostPort 8200,反代到这个 Service。清单是 config/vault-ui-proxy.yaml,脚本是 scripts/05-打开Vault网页.sh。

浏览器打开 http://10.0.1.47:8200/ui/。登录方式选 Token。令牌不在本文,在导航页 Vault 卡片上,也在 /usr/cluster/vault/init.json 的 root_token 字段。

进去之后的点击顺序:

  1. Secrets engines
  2. secret
  3. apps
  4. 命名空间目录:llm、dify、langfuse、observability
  5. 具体名字,例如 vllm-env

这一页就是该服务容器启动时拿到的环境变量。改某一条时在网页里写新版本,等刷新周期,或删掉对应 ExternalSecret 再由脚本提交一次,让它马上同步。正在跑的进程要等重建 Pod 才读到新值。重建 vLLM 会再次加载约三分钟。

导航页 /usr/cluster/llm-nav/index.html 的第一张卡片是 Vault,地址同样是 http://10.0.1.47:8200/ui/。导航由 llm/nav 挂这个目录,改完文件刷新浏览器即可,不用重启 Pod。

5. Vault 的程序

scripts/02-初始化并导入密钥.py:

python
#!/usr/bin/env python3
"""初始化 Vault、解封,并把四个命名空间里已有的 Opaque Secret 抄进 KV v2。

现网已经完成。再次执行时如果 init.json 已存在,只做解封和补齐,不会重新 init。
脚本不打印口令、令牌和 Secret 的值。
"""
import base64
import json
import os
import subprocess

KUBECTL = ["kubectl", "-n", "vault"]
INIT_PATH = "/usr/cluster/vault/init.json"
NAMESPACES = ["llm", "dify", "langfuse", "observability"]


def run(args, input_bytes=None, check=True):
    result = subprocess.run(args, input=input_bytes, capture_output=True)
    if check and result.returncode != 0:
        raise SystemExit(result.stderr.decode("utf-8", "replace")[-800:])
    return result


def status():
    result = run(KUBECTL + ["exec", "vault-0", "--", "vault", "status", "-format=json"], check=False)
    return json.loads(result.stdout.decode())


def ensure_init():
    current = status()
    if current.get("initialized"):
        if not os.path.exists(INIT_PATH):
            raise SystemExit("Vault 已初始化,但本机没有 " + INIT_PATH)
        print("already initialized")
        return
    result = run(
        KUBECTL
        + ["exec", "vault-0", "--", "vault", "operator", "init", "-key-shares=1", "-key-threshold=1", "-format=json"]
    )
    with open(INIT_PATH, "w", encoding="utf-8") as handle:
        handle.write(result.stdout.decode())
    os.chmod(INIT_PATH, 0o600)
    print("init file written")


def unseal():
    if not status().get("sealed"):
        print("already unsealed")
        return
    key = json.load(open(INIT_PATH, encoding="utf-8"))["unseal_keys_b64"][0]
    run(KUBECTL + ["exec", "vault-0", "--", "vault", "operator", "unseal", key])
    if status().get("sealed"):
        raise SystemExit("unseal failed")
    print("unsealed")


def vault_cmd(*args):
    script = "export VAULT_ADDR=http://127.0.0.1:8200; export VAULT_TOKEN=$(cat /tmp/vt); vault " + " ".join(
        json.dumps(arg) for arg in args
    )
    return run(KUBECTL + ["exec", "vault-0", "--", "sh", "-c", script])


def with_token():
    token = json.load(open(INIT_PATH, encoding="utf-8"))["root_token"]
    run(
        KUBECTL + ["exec", "-i", "vault-0", "--", "sh", "-c", "cat > /tmp/vt && chmod 600 /tmp/vt"],
        input_bytes=token.encode(),
    )


def enable_kv():
    listed = json.loads(vault_cmd("secrets", "list", "-format=json").stdout.decode())
    if "secret/" not in listed:
        vault_cmd("secrets", "enable", "-path=secret", "kv-v2")
        print("kv v2 enabled")
    else:
        print("kv v2 present")


def import_secrets():
    copied = 0
    for ns in NAMESPACES:
        raw = json.loads(run(["kubectl", "-n", ns, "get", "secret", "-o", "json"]).stdout)
        for item in raw["items"]:
            name = item["metadata"]["name"]
            if item.get("type") != "Opaque" or name.startswith("sh.helm.release"):
                continue
            data = item.get("data") or {}
            if not data:
                print(f"SKIP {ns}/{name} empty")
                continue
            payload = {}
            for key, value in data.items():
                blob = base64.b64decode(value)
                try:
                    payload[key] = blob.decode("utf-8")
                except UnicodeDecodeError:
                    payload[key] = blob.decode("latin-1")
            local = f"/tmp/vault-payload-{ns}-{name}.json"
            with open(local, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False)
            os.chmod(local, 0o600)
            run(KUBECTL + ["cp", local, "vault-0:/tmp/payload.json"])
            os.remove(local)
            vault_cmd("kv", "put", "-mount=secret", f"apps/{ns}/{name}", "@/tmp/payload.json")
            run(KUBECTL + ["exec", "vault-0", "--", "rm", "-f", "/tmp/payload.json"])
            print(f"PUT {ns}/{name} keys={len(payload)}")
            copied += 1
    print(f"COPIED {copied}")


def main():
    ensure_init()
    unseal()
    with_token()
    try:
        enable_kv()
        import_secrets()
    finally:
        run(KUBECTL + ["exec", "vault-0", "--", "rm", "-f", "/tmp/vt"], check=False)


if __name__ == "__main__":
    main()

scripts/06-解封Vault.sh:

bash
#!/bin/bash
# 从 /usr/cluster/vault/init.json 读取解封密钥。不要把密钥打到终端。
set -eu
export KUBECONFIG=/root/.kube/config
key=$(python3 -c 'import json; print(json.load(open("/usr/cluster/vault/init.json"))["unseal_keys_b64"][0], end="")')
kubectl -n vault exec vault-0 -- vault operator unseal "$key" >/tmp/vault-unseal.out
rm -f /tmp/vault-unseal.out
kubectl -n vault exec vault-0 -- vault status -format=json \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print("initialized", d.get("initialized"), "sealed", d.get("sealed"))'

七、补上的 Xinference 模型

迁进 Kubernetes 之后,llm/xinference 一直是 1/1 Running,日志里有 Uvicorn running on http://0.0.0.0:9997。不带令牌访问 /v1/models 返回 401。带管理员令牌访问时,列表是空的。

日志写着 Connected to supervisor as a fresh worker。launch_history.db 的行数是 0。旧的运行记录还在 worker_recovery/*/models.json 里,内容是 bge-m3 和 bge-reranker-v2-m3,引擎 sentence_transformers,gpu_idx 为 [0],下载源 modelscope。新 worker 的地址和上次不一样,又没有自启动记录,所以不会把这两个模型再加载出来。权重文件一直在 /usr/cluster/dify/xinference/modelscope/models,缺的是一次启动。

登录接口是 POST /token,JSON 正文 {"username","password"},不是表单。管理员用户在 auth/auth.db,口令 Xinf-bge-2026。启动体:

json
{
  "model_uid": "bge-m3",
  "model_name": "bge-m3",
  "model_type": "embedding",
  "model_engine": "sentence_transformers",
  "n_gpu": 1,
  "gpu_idx": [0],
  "download_hub": "modelscope",
  "replica": 1
}

重排模型把 model_uid、model_name 换成 bge-reranker-v2-m3,model_type 换成 rerank,其余相同。

第一次启动 bge-m3 时,worker 在 /data/virtualenv/v4/bge-m3/sentence_transformers/3.12.14 里用 uv 安装了 sentence-transformers==6.1.0、scikit-learn==1.9.1、joblib、narwhals、threadpoolctl。准备这 5 个包用了约 9 分 56 秒。调用方 600 秒超时断开时,服务端还在装,大约 624 秒后日志出现 ModelActor(bge-m3-rep0) loaded。显存里能看到进程 Model: bge-m3-rep0,大约 2504 MiB。

接着把重排模型也拉起来,并把两个都写入自启动:

json
{
  "enabled": true,
  "launch": { "model_uid": "bge-m3", "model_name": "bge-m3" }
}

GET /v1/autostart/models 返回的 models 长度为 2。以后这个 Pod 重建,supervisor 会按这张表再加载,不用再手工点一次。第二次启动重排模型时包已经在缓存里,几十秒就完成。

当时整卡已用显存从 78770 MiB 升到 83781 MiB,总量 97871 MiB。vLLM 进程仍在,/health 为 200。宿主机那三个 Python 服务的进程号没有变。

程序是 scripts/07-启动Xinference模型.py。它先看列表,已经有的 uid 只补自启动,不重复加载。

python
#!/usr/bin/env python3
"""把 bge-m3 和 bge-reranker-v2-m3 拉起来,并写入 Xinference 自启动。

已经在跑的模型不会重复启动。登录口令是现网管理员口令。
"""
import json
import sqlite3
import urllib.request

BASE = "http://127.0.0.1:9997"
PASSWORD = "Xinf-bge-2026"
SPECS = [
    {
        "model_uid": "bge-m3",
        "model_name": "bge-m3",
        "model_type": "embedding",
        "model_engine": "sentence_transformers",
        "n_gpu": 1,
        "gpu_idx": [0],
        "download_hub": "modelscope",
        "replica": 1,
    },
    {
        "model_uid": "bge-reranker-v2-m3",
        "model_name": "bge-reranker-v2-m3",
        "model_type": "rerank",
        "model_engine": "sentence_transformers",
        "n_gpu": 1,
        "gpu_idx": [0],
        "download_hub": "modelscope",
        "replica": 1,
    },
]


def call(method, path, token=None, payload=None, timeout=900):
    headers = {}
    data = None
    if payload is not None:
        data = json.dumps(payload).encode()
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = "Bearer " + token
    req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        body = resp.read().decode()
        return json.loads(body) if body else {}


def main():
    user = sqlite3.connect("/usr/cluster/dify/xinference/auth/auth.db").execute(
        "select username from users"
    ).fetchone()[0]
    token = call("POST", "/token", payload={"username": user, "password": PASSWORD})["access_token"]
    running = call("GET", "/v1/models", token=token).get("data") or []
    ids = {item.get("id") for item in running}
    print("before", sorted(ids), flush=True)
    for spec in SPECS:
        uid = spec["model_uid"]
        if uid not in ids:
            print("launch", uid, flush=True)
            print("launched", call("POST", "/v1/models?wait_ready=true", token=token, payload=spec), flush=True)
        else:
            print("already", uid, flush=True)
        call("POST", "/v1/autostart/models", token=token, payload={"enabled": True, "launch": spec})
        print("autostart", uid, flush=True)
    after = call("GET", "/v1/models", token=token).get("data") or []
    for item in after:
        print("model", item.get("id"), item.get("model_type") or item.get("type"), flush=True)


if __name__ == "__main__":
    main()

Xinference 界面上的 Token Router、Tokenizer 资产目录是这版程序自带的页面。里面那条 deepseek-v4 Flash、来源 builtin 的记录没有绑定 Agent,也没有路由策略。bge-m3 和重排模型不走那一页。

八、做完时的核对

检查结果
llm/vllm 探针启动 36 次 × 10 秒,就绪延迟 60 秒,存活延迟 120 秒
探针验收新 Pod 先 0/1,181 秒后 1/1,重启次数 0
模型目录/models/Qwen3.6-27B 挂到容器 /model,/models 指向 /usr/cluster/models
Vaultvault-0 1/1,版本 1.21.2,未封印
ExternalSecret27 个 SecretSynced,ClusterSecretStore/vault 为 Ready
业务 Deployment同步密钥时没有换 Pod
Xinference列表里有 bge-m3(embedding)和 bge-reranker-v2-m3(rerank),自启动 2 条
网页http://10.0.1.47:8200/ui/ 返回 200,导航页有「打开 Vault」
vLLM 健康http://127.0.0.1:8000/health 为 200
GPU Operatorchart v26.7.1,revision 4,deployed。nvidia.com/gpu 可分配数量为 1
设备插件nvidia-device-plugin-daemonset、GFD、validator 为 Running,CUDA validator 为 Completed

再看一眼:

bash
kubectl -n gpu-operator get pods
kubectl get node prod102 -o jsonpath='{.status.allocatable.nvidia\.com/gpu}{"\n"}'
kubectl -n llm get pod -l app=vllm
kubectl -n vault get pod
kubectl get externalsecret -A
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/health
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8200/ui/

九、文件

文件作用
config/gpu-operator-values.yamlGPU Operator 的 Helm 值。驱动、Toolkit、CDI、DCGM、MIG、CC 关闭,运行时类为 docker
config/gpu-smi-pod.yaml验收用的一次性 nvidia-smi Pod。看完要删除
scripts/08-安装GPUOperator.sh安装 chart。现网已有 release 时退出
config/vllm.yaml带探针和模型 hostPath 的 vLLM Deployment、Service
config/vault-values.yamlHelm 安装 Vault 1.21.2 的值
config/vault-ui-proxy.yaml把节点 8200 转到 Vault 界面
config/cluster-secret-store.yamlExternal Secrets 连接 Vault 的集群级 Store
config/externalsecret-vllm.yamlvllm-env 的同步对象,其余名字按这个形状生成
scripts/01-部署Vault.sh安装 chart。现网已有 Pod 时退出
scripts/02-初始化并导入密钥.py初始化、解封、写入 KV。不打印值
scripts/03-安装ExternalSecrets.sh安装 Operator 并提交 Store
scripts/04-生成ExternalSecret.py为非空 Secret 提交 ExternalSecret
scripts/05-打开Vault网页.sh部署 8200 反代并检查 HTTP 状态
scripts/06-解封Vault.shPod 重启后解封
scripts/07-启动Xinference模型.py启动两个 bge 并打开自启动

上面已经全文贴出 config/vllm.yaml、config/vault-values.yaml、config/cluster-secret-store.yaml、scripts/02-初始化并导入密钥.py、scripts/06-解封Vault.sh、scripts/07-启动Xinference模型.py。GPU Operator 的值文件和验收 Pod 在第三节。scripts/08-安装GPUOperator.sh 如下。其余清单和脚本也按文件里的内容贴在后面。

bash
#!/bin/bash
# 对照记录。prod102 上 gpu-operator 已经是 deployed,再执行会停在下面的判断。
# 不要打开 driver、toolkit、dcgmExporter、migManager、ccManager。
set -eu
export KUBECONFIG=/root/.kube/config

if [ "$(hostname)" = "prod102" ] && helm status gpu-operator -n gpu-operator >/dev/null 2>&1; then
  echo "prod102 上 GPU Operator 已经在跑。这份脚本是对照记录,停在这里。"
  exit 1
fi

helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update nvidia
helm upgrade --install gpu-operator nvidia/gpu-operator \
  --version v26.7.1 \
  -n gpu-operator --create-namespace \
  -f "$(dirname "$0")/../config/gpu-operator-values.yaml"

config/vault-ui-proxy.yaml:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: vault-ui-proxy
  namespace: vault
data:
  default.conf: |
    server {
      listen 8200;
      location / {
        proxy_pass http://vault-ui.vault.svc.cluster.local:8200;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto http;
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vault-ui-proxy
  namespace: vault
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vault-ui-proxy
  template:
    metadata:
      labels:
        app: vault-ui-proxy
    spec:
      nodeName: prod102
      containers:
        - name: proxy
          image: nginx:latest
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8200
              hostPort: 8200
          volumeMounts:
            - name: conf
              mountPath: /etc/nginx/conf.d
      volumes:
        - name: conf
          configMap:
            name: vault-ui-proxy

config/externalsecret-vllm.yaml:

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: vllm-env
  namespace: llm
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: vllm-env
    creationPolicy: Merge
    deletionPolicy: Retain
  dataFrom:
    - extract:
        key: apps/llm/vllm-env

scripts/01-部署Vault.sh:

bash
#!/bin/bash
# 对照记录。prod102 上 Vault 1.21.2 已经在跑,再执行会停在下面的判断。
set -eu
export KUBECONFIG=/root/.kube/config

if [ "$(hostname)" = "prod102" ] && kubectl -n vault get pod vault-0 >/dev/null 2>&1; then
  echo "prod102 上 Vault 已经在跑。这份脚本是对照记录,停在这里。"
  exit 1
fi

mkdir -p /usr/cluster/vault/data
chown 100:1000 /usr/cluster/vault/data
chmod 770 /usr/cluster/vault/data
chmod 700 /usr/cluster/vault

helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update hashicorp
helm upgrade --install vault hashicorp/vault \
  --version 0.32.0 \
  -n vault --create-namespace \
  -f /usr/cluster/vault/values.yaml

echo "等 vault-0 进入 Running 之后,再执行 02-初始化并导入密钥.py"

scripts/03-安装ExternalSecrets.sh:

bash
#!/bin/bash
# 安装 External Secrets Operator,并让它按 Vault 里的 KV 刷新现有 Secret。
# prod102 上已经装过。再执行前先看命名空间 external-secrets 是否已有 Pod。
set -eu
export KUBECONFIG=/root/.kube/config
CHART=/usr/cluster/k8s/charts/external-secrets-2.12.0.tgz

if [ ! -f "$CHART" ]; then
  echo "缺少 $CHART"
  exit 1
fi

helm upgrade --install external-secrets "$CHART" \
  -n external-secrets --create-namespace \
  --set installCRDs=true \
  --set image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set image.tag=v2.12.0 \
  --set webhook.image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set webhook.image.tag=v2.12.0 \
  --set certController.image.repository=ghcr.dockerproxy.net/external-secrets/external-secrets \
  --set certController.image.tag=v2.12.0 \
  --set image.pullPolicy=IfNotPresent \
  --set webhook.image.pullPolicy=IfNotPresent \
  --set certController.image.pullPolicy=IfNotPresent

kubectl -n external-secrets rollout status deploy/external-secrets --timeout=180s
kubectl apply -f "$(dirname "$0")/../config/cluster-secret-store.yaml"
echo "ClusterSecretStore 已提交。各 Secret 的 ExternalSecret 由 04-生成ExternalSecret.py 写出。"

scripts/04-生成ExternalSecret.py:

python
#!/usr/bin/env python3
"""为四个命名空间里非空的 Opaque Secret 生成 ExternalSecret。

目标 Secret 名保持原样,Deployment 的 envFrom 不用改,Pod 也不会因此滚动。
"""
import json
import subprocess

NAMESPACES = ["llm", "dify", "langfuse", "observability"]
HEADER = """apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {name}
  namespace: {ns}
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: {name}
    creationPolicy: Merge
    deletionPolicy: Retain
  dataFrom:
    - extract:
        key: apps/{ns}/{name}
"""


def main():
    docs = []
    for ns in NAMESPACES:
        raw = subprocess.check_output(["kubectl", "-n", ns, "get", "secret", "-o", "json"])
        for item in json.loads(raw)["items"]:
            name = item["metadata"]["name"]
            if item.get("type") != "Opaque" or name.startswith("sh.helm.release"):
                continue
            if not item.get("data"):
                print(f"SKIP {ns}/{name} empty")
                continue
            docs.append(HEADER.format(ns=ns, name=name).strip())
            print(f"ADD {ns}/{name}")
    path = "/tmp/external-secrets-all.yaml"
    with open(path, "w", encoding="utf-8") as handle:
        handle.write("\n---\n".join(docs) + "\n")
    subprocess.check_call(["kubectl", "apply", "-f", path])
    print(f"APPLIED {len(docs)}")


if __name__ == "__main__":
    main()

scripts/05-打开Vault网页.sh:

bash
#!/bin/bash
set -eu
export KUBECONFIG=/root/.kube/config
kubectl apply -f "$(dirname "$0")/../config/vault-ui-proxy.yaml"
kubectl -n vault rollout status deploy/vault-ui-proxy --timeout=120s
curl -s -o /dev/null -m 10 -w "vault_ui %{http_code}\n" http://127.0.0.1:8200/ui/