用 NVIDIA GPU Operator 暴露 H20:vLLM 探针、本地模型和 Vault 密钥
目录
- 一、这些组件各自做什么
- 二、动手前现网是什么样
- 三、安装 NVIDIA GPU Operator
- 四、任务 A:给 vLLM 加探针
- 五、任务 B:模型文件留在宿主机
- 六、任务 C:用 Vault 管密钥
- 七、补上的 Xinference 模型
- 八、做完时的核对
- 九、文件
本文记录这一阶段在现网上的做法:安装 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 继续写:
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,看完就删掉了。
二、动手前现网是什么样
| 项目 | 现网 |
|---|---|
| Kubernetes | 1.37.1,运行时 cri-dockerd 0.4.8,Docker 28.0.1 |
| 节点 | prod102,10.0.1.47 |
| vLLM | Deployment llm/vllm,hostPort 8000,当时没有探针 |
| 模型目录 | /models 指向 /usr/cluster/models,权重在 /models/Qwen3.6-27B |
| Xinference | Deployment 在跑,模型没加载 |
| 环境变量 | 四个命名空间的容器都是 envFrom.secretRef,Deployment 模板里没有明文 value |
| GPU Operator | 这一阶段才装。装之前集群里没有 nvidia.com/gpu。装完后的参数和验收写在第三节 |
四个命名空间:
| 命名空间 | 工作负载 |
|---|---|
llm | vLLM、Xinference、New-API、导航 |
dify | Dify 全套 |
langfuse | Langfuse、Postgres、ClickHouse、MinIO、Redis |
observability | Prometheus、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.enabled | false | 宿主机已有 nvidia-driver-550 550.120 |
toolkit.enabled | false | 已有 nvidia-container-toolkit 1.17.8。打开后 operand 会改 Docker 配置并重启 Docker |
cdi.enabled | false | CDI 面向 containerd、CRI-O。本机是 cri-dockerd |
dcgmExporter.enabled | false | observability 里已经有 DCGM Exporter,hostPort 9400 |
migManager.enabled | false | 不在 vLLM 运行时重划 H20 的 MIG |
ccManager.enabled | false | 不切换保密计算。chart 默认模式可能是打开的 |
operator.runtimeClass | docker | cri-dockerd 0.4.8 只接受这个处理器名字 |
node-feature-discovery.image.repository | m.daocloud.io/registry.k8s.io/nfd/node-feature-discovery | registry.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:
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-discovery2. 实际安装和两处失败
能直接用的安装命令:
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.yamlscripts/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、worker | 1/1 Running,镜像为 daocloud 上的 v0.19.0 |
nvidia-device-plugin-daemonset | 1/1 Running |
gpu-feature-discovery | 1/1 Running |
nvidia-operator-validator | 1/1 Running |
nvidia-cuda-validator | Completed |
没有驱动 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 服务没有重启。
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。
再看安装结果:
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,效果和这份清单里的探针段一致。
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 清单
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. 怎么确认挂载还在
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 |
|---|---|
llm | nav-env、new-api-env、vllm-env、xinference-env |
dify | agent-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 |
langfuse | clickhouse-env、langfuse-web-env、langfuse-worker-env、minio-env、postgres-env、redis-env |
observability | dcgm-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,数据目录要先交给这个用户,否则起不来:
mkdir -p /usr/cluster/vault/data
chown 100:1000 /usr/cluster/vault/data
chmod 770 /usr/cluster/vault/data
chmod 700 /usr/cluster/vaultinjector.enabled=false,csi.enabled=false。注入器是一个会改 Pod 的 webhook,这次不让它碰已经在跑的业务。认证委托打开,后面 Kubernetes 认证要用这台 Vault 自己的 ServiceAccount 去做 TokenReview。
config/vault-values.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安装:
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.yamlscripts/01-部署Vault.sh 是这段的脚本形式。现网已经有 vault-0 时它会直接退出。
Pod 进 Running 之后还是封印的,就绪探针要等解封。初始化只用一次:
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名>。打印出来的只有名字和键的个数。
核对:
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 用同一份镜像。
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:
path "secret/data/apps/*" {
capabilities = ["read"]
}
path "secret/metadata/apps/*" {
capabilities = ["read", "list"]
}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=1hconfig/cluster-secret-store.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 字段。
进去之后的点击顺序:
- Secrets engines
- secret
- apps
- 命名空间目录:
llm、dify、langfuse、observability - 具体名字,例如
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:
#!/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:
#!/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。启动体:
{
"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。
接着把重排模型也拉起来,并把两个都写入自启动:
{
"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 只补自启动,不重复加载。
#!/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 |
| Vault | vault-0 1/1,版本 1.21.2,未封印 |
| ExternalSecret | 27 个 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 Operator | chart v26.7.1,revision 4,deployed。nvidia.com/gpu 可分配数量为 1 |
| 设备插件 | nvidia-device-plugin-daemonset、GFD、validator 为 Running,CUDA validator 为 Completed |
再看一眼:
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.yaml | GPU 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.yaml | Helm 安装 Vault 1.21.2 的值 |
config/vault-ui-proxy.yaml | 把节点 8200 转到 Vault 界面 |
config/cluster-secret-store.yaml | External Secrets 连接 Vault 的集群级 Store |
config/externalsecret-vllm.yaml | vllm-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.sh | Pod 重启后解封 |
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 如下。其余清单和脚本也按文件里的内容贴在后面。
#!/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:
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-proxyconfig/externalsecret-vllm.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-envscripts/01-部署Vault.sh:
#!/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:
#!/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:
#!/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:
#!/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/