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

Docker Compose 迁到单节点 Kubernetes:vLLM、Dify、Langfuse 逐个怎么搬

把 Docker Compose 上跑着的 vLLM、Dify、Langfuse、New-API、Prometheus、Grafana、DCGM逐个搬进单节点 Kubernetes,按依赖顺序逐章记录,看清一个运行中的容器怎么变成 Pod。

Docker Compose 迁到单节点 Kubernetes:vLLM、Dify、Langfuse 逐个怎么搬

目录

  1. 一、集群前提
  2. 二、按服务迁移
  3. 三、功能操作案例
  4. 写文档时核对到的数据

本文按服务记录这次迁移,用来看清一个正在运行的容器怎样变成 Pod。机器是 prod102,地址 10.0.1.47,系统 Ubuntu 22.04.5。迁移前,Dify、vLLM、Xinference、New-API、Langfuse、Prometheus、Grafana、DCGM 和导航页都在 Docker 里提供服务。本机上的 Kubernetes 当时不能提供服务:它只是一台 worker,控制面在 10.0.1.46:6443,那台机器连不上。10.0.1.48 没有动。

切换是一批完成的,后面七章按依赖顺序来读,每一章都会写明它在这一批里的位置。完整脚本仍在 scripts/,这里只引用和本服务有关的命令。不要为了对照本文在现网重跑安装或迁移。

一、集群前提

1. 当时要保住的服务

端口当时的容器数据留在
8000vllm-qwen3.6-27b/models/Qwen3.6-27B,符号链接到 /usr/cluster/models
9997xinference-bge/usr/cluster/dify/xinference
3000new-api/opt/new-api/data,库文件是 one-api.db
8088 / 18443dify-nginx-1/usr/cluster/dify/src/docker/volumes 下的 Postgres、Redis、pgvector 和上传文件
3100langfuse-langfuse-web-1Langfuse 的 Postgres、ClickHouse、MinIO、Redis 数据卷
3001 / 9090 / 9400grafana、prometheus、dcgm-exporter/usr/cluster/observability/metrics 和对应数据卷
8090llm-nav/usr/cluster/llm-nav
8777宿主机 systemd server-status/usr/cluster/server-status/app.py,不进集群
80宿主机 Nginx不进集群

模型权重和数据库目录留在磁盘上。新 Pod 用 hostPath 挂回这些目录,所以里面的渠道、知识库、追踪记录和监控历史还是原来那一份。

2. 清掉失效的旧集群

旧 kubelet 是 1.22.12,CRI 走 dockershim。Calico 期望的 BGP 邻居是 10.0.1.46 和 10.0.1.48。Pod 网段 10.243.212.128/26 当时是黑洞路由,机器上还有 tunl0 和 kube-ipvs0。

kubeadm reset 连不上 10.0.1.46,列不出 Pod。CNI、iptables 和 IPVS 是后来手工清的。清理时带上 cri-dockerd 的 socket,让 kubeadm 对着当时的 Docker 运行时操作:

bash
kubeadm reset --cri-socket unix:///run/cri-dockerd.sock

备份在 /usr/cluster/k8s-reset-backup-20261012/,里面有 iptables、ip6tables、ipvs、路由、地址和清理前的容器名。旧控制面和 10.0.1.48 没有重置。清完之后,原来的 Docker 健康检查仍然是 200,然后才装新集群。不执行 docker rm -v,不执行 docker system prune。

3. 三次安装里真正生效的做法

安装脚本是 scripts/01-部署单节点.sh。它装 cri-dockerd 0.4.8 和 Kubernetes 1.37.1。仓库里这份已经是改过的版本,末尾仍会去下载 Flannel。Flannel 没有成为最终网络。

第一次停在 systemctl mask kubelet。/etc/systemd/system/kubelet.service 已经是一个真实单元文件,mask 报 File exists,set -e 让脚本退出。当时 cri-dockerd 0.4.8 已经装上,kubelet 是停用的,还没有 admin.conf。后来改成:

bash
systemctl stop kubelet || true
systemctl disable kubelet || true
systemctl unmask kubelet || true

第二次 kubeadm init 停在 wait-control-plane。https://10.0.1.47:6443 拒绝连接。cri-dockerd 默认去拉 registry.k8s.io/pause:3.10,访问超时,静态 Pod 起不来。scripts/04-补齐控制面.sh 先从阿里云拉 pause,再打上 cri-dockerd 认识的名字,并写 drop-in:

bash
docker pull registry.aliyuncs.com/google_containers/pause:3.10.2
docker tag registry.aliyuncs.com/google_containers/pause:3.10.2 registry.k8s.io/pause:3.10
ini
[Service]
ExecStart=
ExecStart=/usr/local/bin/cri-dockerd --container-runtime-endpoint fd:// --pod-infra-container-image=registry.aliyuncs.com/google_containers/pause:3.10.2

这个 drop-in 挂在正在使用的 cri-docker.service 上。同名的 /etc/systemd/system/cri-dockerd.service 是坏的,已经 mask。socket 听在 %t/cri-dockerd.sock。CoreDNS 和 kube-proxy 在这一步装上。脚本后半段下载 Flannel 时,GitHub 返回了 HTTP/2 帧错误。

第三次,scripts/05-安装Flannel.sh 拿到了 v0.27.4 的清单,命名空间、RBAC 和 DaemonSet 都创建了。ghcr.io/flannel-io/flannel:v0.27.4 的层下载停住。scripts/06-等节点就绪.sh 里的 docker pull 被后面的网桥脚本杀掉,退出码 143。

节点 Ready 用的是 scripts/07-单节点网桥.sh。它停掉 Flannel 拉取,删除 DaemonSet kube-flannel-ds,写入 /etc/cni/net.d/10-bridge.conflist,再重启 kubelet。网桥名 cni0,子网 10.244.0.0/24,落在 kubeadm 所写的 10.244.0.0/16 里面。插件是机器上已有的 bridge、host-local 和 portmap。

json
{
  "cniVersion": "1.0.0",
  "name": "k8s-pod-network",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "cni0",
      "isGateway": true,
      "ipMasq": true,
      "promiscMode": true,
      "ipam": {
        "type": "host-local",
        "ranges": [[{"subnet": "10.244.0.0/24"}]],
        "routes": [{"dst": "0.0.0.0/0"}]
      }
    },
    {
      "type": "portmap",
      "capabilities": {"portMappings": true}
    }
  ]
}

重启后先打印 ready=False,随后 ready=True。kube-flannel 里的 RBAC 和 ConfigMap 可能还在。这份 conflist 还在时,不要再装 Flannel。

4. 真正切换的顺序

业务容器一直跑到这一步。scripts/03-迁入单节点.sh 在节点 Ready 之后按下面的顺序做。scripts/08-开始迁移.sh 只负责在后台启动服务器上的 /usr/cluster/k8s/migrate.sh。

  1. 起一个一次性 Pod gpu-check,镜像用 vLLM 那份,命令是 nvidia-smi -L,环境变量 NVIDIA_VISIBLE_DEVICES=all。成功后删掉。这一步确认 Pod 能看见 GPU,同时说明不需要在清单里写 nvidia.com/gpu。
  2. 容器还在跑的时候,从 dify-worker-1 把 langfuse_trace.py 拷到 /usr/cluster/k8s/patches/。新 Pod 的文件系统来自镜像,旧容器可写层里的文件不会自己过去。
  3. scripts/02-生成清单.py 对当时 docker ps 里每个业务容器做 docker inspect,写出 Deployment、Service 和 Secret。Secret 在服务器上是 0600,不放进这份仓库。
  4. 把 Prometheus 配置里的 host.docker.internal:8000 改成 10.0.1.47:8000。文件还在宿主机上,旧容器和新 Pod 挂的是同一份。
  5. 先 kubectl apply 命名空间、Secret 和 Service。这一步还不创建 Pod,所以还不会去抢宿主机端口。
  6. 给 CoreDNS 加上划线名到连字符名的 rewrite。Compose 的服务名可以有下划线,Kubernetes 的 Service 名不行。
  7. 把当时正在跑的业务容器名写入 /usr/cluster/k8s/stopped-containers.txt。除 vllm-qwen3.6-27b 以外执行 docker stop -t 25,vLLM 最后执行 docker stop -t 90。
  8. 容器停了、数据库文件还在磁盘上,这时把 New-API 渠道地址里的 172.17.0.1 换成 10.0.1.47。
  9. 再 kubectl apply 四个命名空间的 workloads.json,Pod 这才绑定 hostPort。

名单里有 28 个容器。已经退出的 dify-init_permissions-1 和若干 nvidia-smi 练习容器不在里面,因为生成器和停止列表都只看当时正在运行的容器。

CoreDNS 实际写入的规则:

text
agent_ssrf_proxy.dify.svc.cluster.local -> agent-ssrf-proxy.dify.svc.cluster.local
ssrf_proxy.dify.svc.cluster.local -> ssrf-proxy.dify.svc.cluster.local
api_websocket.dify.svc.cluster.local -> api-websocket.dify.svc.cluster.local
worker_beat.dify.svc.cluster.local -> worker-beat.dify.svc.cluster.local
agent_backend.dify.svc.cluster.local -> agent-backend.dify.svc.cluster.local
plugin_daemon.dify.svc.cluster.local -> plugin-daemon.dify.svc.cluster.local
local_sandbox.dify.svc.cluster.local -> local-sandbox.dify.svc.cluster.local
db_postgres.dify.svc.cluster.local -> db-postgres.dify.svc.cluster.local

生成器对每个容器使用同一套形状:strategy: Recreate,nodeName: prod102,enableServiceLinks: false,hostAliases 把 host.docker.internal 指到 10.0.1.47。环境变量放进名为 <工作负载>-env 的 Secret,清单里只留 envFrom。有宿主机端口映射的,写成 Pod 的 hostPort。挂载写成 hostPath,文件和目录用 inspect 时看到的类型。共享内存大于 64MiB 的,补一个 8Gi 的 emptyDir 到 /dev/shm。Service 只声明容器端口,外部仍从原来的宿主机端口进来。

Recreate 是因为 hostPort 和 GPU 都不能有两个副本同时占用。enableServiceLinks: false 是为了避免 Kubernetes 按 Service 名注入一组环境变量,盖住应用原来的配置。

5. 回滚,以及 Docker 重启后的端口

回滚函数只挂在 New-API 和 Dify 的等待失败上。它删除四个 workloads.json,再按 stopped-containers.txt 执行 docker start。vLLM 等待失败,以及最后的健康检查失败,都是直接退出,不调用这个函数。

bash
if [ "$na" != "1" ] || [ "$dy" != "1" ]; then
  rollback
  exit 1
fi
bash
if ! check_health final; then
  echo MIGRATE_PARTIAL
  exit 1
fi

这次最后一步是 Xinference 的 /v1/models 返回 401。脚本因此打印 MIGRATE_PARTIAL 并以退出码 1 结束。回滚没有执行,Pod 保持运行。

若干旧容器的重启策略是 always:Dify 各容器、Langfuse 各容器、vllm-qwen3.6-27b 和 new-api。它们现在是 Exited。Docker 守护进程如果重启,always 会把它们再拉起来,和 Pod 的 hostPort 抢 3000、8000、8088 这些端口。unless-stopped 的会保持停止:xinference-bge、llm-nav、prometheus、grafana、dcgm-exporter。迁移时没有改这些重启策略。

二、按服务迁移

阅读顺序是 vLLM、Xinference、New-API、导航页、Dify、Langfuse、Prometheus / Grafana / DCGM。每一章的「切换」都是第 4 节那一批里的一段,容器是一起停的,Pod 是一起拉起的。

1. vLLM

它在停止名单的最后,超时 90 秒,用来等模型进程退出并把 8000 端口让出来。GPU 检查在停容器之前已经做过。

迁移前

容器名 vllm-qwen3.6-27b,镜像 docker.m.daocloud.io/vllm/vllm-openai:latest,重启策略 always。它不在 Compose 项目里,生成器按名字把它放进命名空间 llm,工作负载名 vllm。

宿主机 8000 映射到容器 8000。权重目录 /models/Qwen3.6-27B 挂到 /model。共享内存是 8GiB。入口是 vllm serve /model,参数保持 bfloat16、上下文 65536、gpu-memory-utilization 0.70、max-num-seqs 8、服务名 qwen3.6-27b。New-API 的渠道和 Dify 的对话都依赖这个端口。

清单里变成什么

Pod 继续使用同一条命令和同一个 hostPath,所以加载的还是磁盘上的权重,服务名仍是 qwen3.6-27b。HF_HUB_OFFLINE 和 NVIDIA_VISIBLE_DEVICES 在 Secret vllm-env 里。/dev/shm 用内存型 emptyDir,上限 8Gi,对应原来的 ShmSize。没有申请 nvidia.com/gpu,GPU 靠节点上的 NVIDIA 运行时和这个环境变量。

文件:config/vllm.yaml。

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
          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

Service 的 8000 是集群内端口。宿主机上的 8000 来自 Pod 的 hostPort,所以原来访问 10.0.1.47:8000 的调用方式不变。

切换时做了什么

vLLM 留到其他容器都发出停止之后:

bash
# 其余正在运行的业务容器一次 docker stop -t 25
docker stop -t 90 vllm-qwen3.6-27b

端口空出来之后,和其他工作负载一起提交:

bash
kubectl apply -f /usr/cluster/k8s/manifests/llm/workloads.json

这个文件里同时有 vLLM、Xinference、New-API 和导航页。脚本随后单独等 :8000/health 变成 200,最多约 15 分钟。这一步失败会打印 VLLM_NOT_READY 并退出,不回滚。

怎么确认

写本文时,http://127.0.0.1:8000/health 和 http://127.0.0.1:8000/v1/models 都是 200。Pod 挂的仍是 /models/Qwen3.6-27B。Prometheus 能从 10.244.0.1 刮到 /metrics。

2. Xinference

它和其余容器一起 docker stop -t 25。最终健康检查把未带令牌的 401 当成失败,整批结果记成 MIGRATE_PARTIAL。

迁移前

容器名 xinference-bge,镜像 xprobe/xinference:latest,重启策略 unless-stopped。命令是 xinference-local -H 0.0.0.0 --log-level info。宿主机 9997 映射到容器 9997。/usr/cluster/dify/xinference 挂到 /data,里面是已经下载的 bge-m3 和 bge-reranker-v2-m3。Dify 的向量和重排走这个服务。它也不是 Compose 容器,生成器把它放进 llm,名字 xinference。

清单里变成什么

数据目录整份挂回去,所以模型文件还在 /data。镜像外临时装进旧容器可写层的包不会出现在新 Pod 里;要留住这种包,得像 Dify 的 Langfuse 补丁那样,在停止前拷出来再挂回去。XINFERENCE_HOME 和 NVIDIA_VISIBLE_DEVICES 在 Secret xinference-env 里。

文件:config/xinference.yaml。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: xinference
  namespace: llm
  labels:
    app: xinference
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: xinference
  template:
    metadata:
      labels:
        app: xinference
      annotations:
        llm.local/compose-service: "xinference"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: xinference
          image: xprobe/xinference:latest
          imagePullPolicy: IfNotPresent
          command:
            - "xinference-local"
            - "-H"
            - "0.0.0.0"
            - "--log-level"
            - "info"
          envFrom:
            - secretRef:
                name: xinference-env
          ports:
            - containerPort: 9997
              hostPort: 9997
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/data"
      volumes:
        - name: vol0
          hostPath:
            path: "/usr/cluster/dify/xinference"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: xinference
  namespace: llm
spec:
  selector:
    app: xinference
  ports:
    - name: p9997
      port: 9997
      targetPort: 9997
      protocol: TCP

切换时做了什么

它在「其余容器」那一组里,超时 25 秒,然后随 llm/workloads.json 拉起。没有单独的等待循环。最后的 check_health 请求 http://127.0.0.1:9997/v1/models,只把 200、301、302、307、308 当成成功。

怎么确认

写本文时,不带令牌访问 /v1/models 仍是 401。401 表示进程在听 9997,这个接口要求管理员令牌。脚本因此没有打印 MIGRATE_OK。Pod 没有被删掉。

3. New-API

它在 25 秒那一组里被停止。渠道地址是在数据库文件已经脱离容器、Pod 还没起来的空隙里改的。

迁移前

容器名 new-api,镜像 calciumion/new-api:latest,重启策略 always。Compose 项目名是 new-api,生成器把它放进命名空间 llm。入口是 /new-api --log-dir /app/logs。宿主机 3000 映射到容器 3000。/opt/new-api/data 挂到 /data,/opt/new-api/logs 挂到 /app/logs。渠道、令牌和额度都在 /data/one-api.db。

迁移前,渠道要去访问宿主机上的 vLLM,地址写的是 Docker 桥上的 172.17.0.1。Pod 的网络已经不是那座桥。

清单里变成什么

SQLite 文件还在原来的 hostPath 上,所以用户、渠道和调用日志没有换一份新库。命令和日志目录也保持原样。

文件:config/new-api.yaml。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: new-api
  namespace: llm
  labels:
    app: new-api
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: new-api
  template:
    metadata:
      labels:
        app: new-api
      annotations:
        llm.local/compose-service: "new-api"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: new-api
          image: calciumion/new-api:latest
          imagePullPolicy: IfNotPresent
          command:
            - "/new-api"
          args:
            - "--log-dir"
            - "/app/logs"
          envFrom:
            - secretRef:
                name: new-api-env
          ports:
            - containerPort: 3000
              hostPort: 3000
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/data"
            - name: vol1
              mountPath: "/app/logs"
      volumes:
        - name: vol0
          hostPath:
            path: "/opt/new-api/data"
            type: Directory
        - name: vol1
          hostPath:
            path: "/opt/new-api/logs"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: new-api
  namespace: llm
spec:
  selector:
    app: new-api
  ports:
    - name: p3000
      port: 3000
      targetPort: 3000
      protocol: TCP

切换时做了什么

docker stop 之后、kubectl apply 工作负载之前,脚本打开磁盘上的库,只改 base_url 里的地址:

bash
python3 - << 'PY'
import sqlite3
con = sqlite3.connect("/opt/new-api/data/one-api.db")
cur = con.cursor()
cur.execute("select id, base_url from channels")
changed = 0
for cid, url in cur.fetchall():
    if url and "172.17.0.1" in url:
        new = url.replace("172.17.0.1", "10.0.1.47")
        cur.execute("update channels set base_url=? where id=?", (new, cid))
        print("channel", cid, new)
        changed += 1
con.commit()
print("channels_updated", changed)
PY

然后它随 llm/workloads.json 拉起。等待循环把 :3000/api/status 的 200 和 Dify 首页的 307 绑在一起,最多约 8 分钟。这两个有一个等不到,才会回滚。

怎么确认

写本文时,:3000/api/status 是 200。渠道 vLLM-Qwen3.6-27B 的 base_url 是 http://10.0.1.47:8000,模型 qwen3.6-27b,状态为启用。令牌仍然在原来的 one-api.db 里,本文不列出密钥。

4. 导航页

它在 25 秒那一组里被停止,随 llm/workloads.json 拉起。页面文件在宿主机目录里,迁移没有改页面内容。

迁移前

容器名 llm-nav,镜像 nginx:latest,重启策略 unless-stopped。入口是 /docker-entrypoint.sh nginx -g daemon off;。宿主机 8090 映射到容器 80。/usr/cluster/llm-nav 挂到 /usr/share/nginx/html,而且是只读。链接指向本机其他服务的页面。

清单里变成什么

hostPort 写在容器端口 80 上,对外仍是 8090。只读挂载保留,Pod 改不了页面文件。

文件:config/nav.yaml。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nav
  namespace: llm
  labels:
    app: nav
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: nav
  template:
    metadata:
      labels:
        app: nav
      annotations:
        llm.local/compose-service: "nav"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: nav
          image: nginx:latest
          imagePullPolicy: IfNotPresent
          command:
            - "/docker-entrypoint.sh"
          args:
            - "nginx"
            - "-g"
            - "daemon off;"
          envFrom:
            - secretRef:
                name: nav-env
          ports:
            - containerPort: 80
              hostPort: 8090
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/usr/share/nginx/html"
              readOnly: true
      volumes:
        - name: vol0
          hostPath:
            path: "/usr/cluster/llm-nav"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: nav
  namespace: llm
spec:
  selector:
    app: nav
  ports:
    - name: p80
      port: 80
      targetPort: 80
      protocol: TCP

切换时做了什么

bash
docker stop -t 25 llm-nav
kubectl apply -f /usr/cluster/k8s/manifests/llm/workloads.json

停止命令和同组其他容器是一次执行的。这里只标出这一个名字。

怎么确认

写本文时,http://127.0.0.1:8090/ 是 200。页面目录仍是 /usr/cluster/llm-nav。

5. Dify

Dify 是一套容器。下面用 Nginx 的完整清单代表「一个容器变成一个 Deployment」;其余容器用对照表。Nginx 是迁完之后唯一又改过配置的成员。

迁移前

这些容器都属于 Compose 项目 dify,重启策略是 always,镜像和数据如下。没有宿主机端口的,只在 Docker 网络里被别的容器用服务名访问。

原容器工作负载镜像宿主机端口容器端口数据目录依赖
dify-nginx-1nginxnginx:latest8088→80,18443→44380、443nginx/ 配置、证书和 certbot 目录api、web、api-websocket、plugin-daemon
dify-web-1weblanggenius/dify-web:1.17.1无3000无api
dify-api-1apilanggenius/dify-api:1.17.1无5001volumes/app/storagePostgres、Redis、pgvector、sandbox、插件
dify-api_websocket-1api-websocket同上无5001volumes/app/storage同上
dify-worker-1worker同上无5001volumes/app/storage同上
dify-worker_beat-1worker-beat同上无5001无业务目录同上
dify-plugin_daemon-1plugin-daemonlanggenius/dify-plugin-daemon:0.6.10-local50035003volumes/plugin_daemonPostgres
dify-agent_backend-1agent-backendlanggenius/dify-agent-backend:1.17.1无5050无api、sandbox
dify-local_sandbox-1local-sandboxlanggenius/dify-agent-local-sandbox:1.17.1无5004两个 Docker 数据卷,工作区和 homeagent-ssrf-proxy
dify-sandbox-1sandboxlanggenius/dify-sandbox:0.2.15无8194volumes/sandboxssrf-proxy
dify-db_postgres-1db-postgrespostgres:15-alpine无5432volumes/db/data无
dify-pgvector-1pgvectorpgvector/pgvector:pg16无5432volumes/pgvector/data无
dify-redis-1redisredis:6-alpine无6379volumes/redis/data无
dify-ssrf_proxy-1ssrf-proxyubuntu/squid:latest无3128squid 配置和两个数据卷无
dify-agent_ssrf_proxy-1agent-ssrf-proxyubuntu/squid:latest无3128agent 用的 squid 配置和数据卷无

dify-init_permissions-1 在迁移前已经退出,不在停止名单里,也没有对应的 Deployment。存储目录上的初始化标记还在 volumes/app/storage。

api、worker、api-websocket、worker-beat 都额外挂了 Langfuse 补丁。宿主机文件是 /usr/cluster/k8s/patches/langfuse_trace.py,容器路径是 /app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py。worker-beat 只有这一份补丁,没有 storage 目录。

数据库仍是原来的 Postgres 数据目录,用户 postgres,库名 dify。Redis 的启动参数里带有 requirepass,值留在服务器上的 Secret 里,这里不写。

对外地址是 http://10.0.1.47:8088。合同 Agent 的访问路径是 /agent/gQPVqCdcyKoW82iZ。

清单里变成什么

每个容器一个 Deployment 加一个 Service,命名空间 dify。工作负载名把下划线改成了连字符,原来的环境变量仍使用带下划线的主机名,所以才需要第 4 节的 CoreDNS rewrite。

Nginx 把配置目录挂回去,Pod 读到的就是后来改过的那份 default.conf。代表清单如下,文件是 config/dify-nginx.yaml。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: dify
  labels:
    app: nginx
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
      annotations:
        llm.local/compose-service: "nginx"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: nginx
          image: nginx:latest
          imagePullPolicy: IfNotPresent
          command:
            - "sh"
            - "-c"
            - "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh"
          envFrom:
            - secretRef:
                name: nginx-env
          ports:
            - containerPort: 443
              hostPort: 18443
              protocol: TCP
            - containerPort: 80
              hostPort: 8088
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/etc/nginx/proxy.conf.template"
            - name: vol1
              mountPath: "/var/www/html"
            - name: vol2
              mountPath: "/etc/nginx/https.conf.template"
            - name: vol3
              mountPath: "/docker-entrypoint-mount.sh"
            - name: vol4
              mountPath: "/etc/letsencrypt/live"
            - name: vol5
              mountPath: "/etc/nginx/nginx.conf.template"
            - name: vol6
              mountPath: "/etc/nginx/conf.d"
            - name: vol7
              mountPath: "/etc/letsencrypt"
            - name: vol8
              mountPath: "/etc/ssl"
      volumes:
        - name: vol0
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/proxy.conf.template"
            type: File
        - name: vol1
          hostPath:
            path: "/usr/cluster/dify/src/docker/volumes/certbot/www"
            type: Directory
        - name: vol2
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/https.conf.template"
            type: File
        - name: vol3
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/docker-entrypoint.sh"
            type: File
        - name: vol4
          hostPath:
            path: "/usr/cluster/dify/src/docker/volumes/certbot/conf/live"
            type: Directory
        - name: vol5
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/nginx.conf.template"
            type: File
        - name: vol6
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/conf.d"
            type: Directory
        - name: vol7
          hostPath:
            path: "/usr/cluster/dify/src/docker/volumes/certbot/conf"
            type: Directory
        - name: vol8
          hostPath:
            path: "/usr/cluster/dify/src/docker/nginx/ssl"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: dify
spec:
  selector:
    app: nginx
  ports:
    - name: p443
      port: 443
      targetPort: 443
      protocol: TCP
    - name: p80
      port: 80
      targetPort: 80
      protocol: TCP

plugin-daemon 的 Deployment 只有 hostPort 5003。迁完之后给它的 Service 补了 5002,因为 Nginx 反代插件市场走 5002。现在这个 Service 同时有 p5003 和 p5002。

web 没有 volume。新 Pod 的前端来自镜像 langgenius/dify-web:1.17.1。

切换时做了什么

停止前先做了两件只和 Dify 有关的事:从 dify-worker-1 拷出 Langfuse 补丁;生成清单时把这份文件写进 api、worker、api-websocket、worker-beat 的 volume。

然后这 15 个容器进入 25 秒停止组,再随其他命名空间一起提交:

bash
kubectl apply -f /usr/cluster/k8s/manifests/dify/workloads.json

Pod 起来之后,访问 8088 会挂住,Nginx 日志是 499。配置里的解析器还是 Docker 内嵌 DNS 127.0.0.11,Pod 里没有这个地址。改成 CoreDNS 10.96.0.10 之后出现 502,上游被解析成 http://127.0.53.53:3000/。Nginx 的 resolver 不使用 resolv.conf 的 search 域,短名 web 落到了 nss-myhostname。

CoreDNS 那组 rewrite 也帮不上这个忙:rewrite 改的是 web 这种短名不会命中的全域名,而且 Nginx 当时写的就是短名。最后改的是宿主机上的两份文件,default.conf 和 default.conf.template:

text
resolver 127.0.0.11 -> resolver 10.96.0.10
api:5001 -> api.dify.svc.cluster.local:5001
web:3000 -> web.dify.svc.cluster.local:3000
plugin_daemon:5002 -> plugin-daemon.dify.svc.cluster.local:5002

Secret nginx-env 的 NGINX_SOCKET_IO_UPSTREAM 改为 api-websocket.dify.svc.cluster.local:5001。然后:

bash
kubectl -n dify rollout restart deploy/nginx

这些替换记在 scripts/09-修正Dify域名.sh。现网文件已经改完。脚本发现解析器已经是 10.96.0.10 且上游已经是 api.dify.svc.cluster.local:5001 时,打印 NGINX_ALREADY_OK 并退出,不再写文件。

怎么确认

写本文时,http://127.0.0.1:8088/ 是 307。这是首页跳转,说明 Nginx 已经能把请求交给 web。知识库、应用和上传文件在原来的 Postgres 与 volumes/app/storage 里。

plugin-daemon 启动过程中重启过 2 次,之后是 1/1 Running。dify-web 没有挂回「知识检索」的前端补丁,那个改动当时只在旧容器的可写层里。API 侧的 Langfuse 补丁还在,文件里有标记 # AGENT_TOOL_SPANS_V2。

Dify 里 getServerStatus 这个工具的 URL,迁移脚本没有改。库里仍可能是 http://172.17.0.1:8777。SSRF 放行包含 10.0.0.0/8,从 Pod 访问宿主机时应使用 http://10.0.1.47:8777。

6. Langfuse

Langfuse 也是一套容器。Web 没有自己的数据目录,追踪数据在 Postgres、ClickHouse 和 MinIO 里。这里贴 Web 的完整清单,其余用对照表。

迁移前

Compose 项目 langfuse,重启策略 always。

原容器工作负载镜像宿主机端口容器端口数据目录依赖
langfuse-langfuse-web-1langfuse-webdocker.langfuse.com/langfuse/langfuse:43100→30003000无Postgres、ClickHouse、MinIO
langfuse-langfuse-worker-1langfuse-workerdocker.langfuse.com/langfuse/langfuse-worker:4127.0.0.1:30303030无同上
langfuse-postgres-1postgresdocker.io/postgres:17无5432langfuse_langfuse_postgres_data无
langfuse-clickhouse-1clickhousedocker.io/clickhouse/clickhouse-server:25.12无8123、9000、9009数据和日志两个卷无
langfuse-minio-1miniodocker.io/minio/minio:latest9092→90009000langfuse_langfuse_minio_data无
langfuse-redis-1redisdocker.io/redis:7无6379langfuse_langfuse_redis_data无

这些数据卷都在 /usr/cluster/docker/volumes/<卷名>/_data。Worker 的 3030 只绑在 127.0.0.1,生成器把 hostIP: 127.0.0.1 留在了 Pod 上。

ClickHouse 原来没有发布到宿主机。生成器看到 PortBindings 是空的,就到容器里看正在听的端口,写成 Service 的 8123、9000、9009,不设 hostPort。集群里的 Web 和 Worker 通过 Service 名访问它。

Redis 启动参数里带有 requirepass。MinIO 和 Langfuse 的加密项在各自的 Secret 里。这些值不写入本文。

清单里变成什么

Web 可以换成一个新容器,因为会话和追踪不在它的可写层里。入口仍是 dumb-init -- ./web/entrypoint.sh,参数仍按有没有 NEXT_PUBLIC_LANGFUSE_CLOUD_REGION 选择要不要加载 dd-trace。

文件:config/langfuse-web.yaml。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: langfuse-web
  namespace: langfuse
  labels:
    app: langfuse-web
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: langfuse-web
  template:
    metadata:
      labels:
        app: langfuse-web
      annotations:
        llm.local/compose-service: "langfuse-web"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: langfuse-web
          image: docker.langfuse.com/langfuse/langfuse:4
          imagePullPolicy: IfNotPresent
          command:
            - "dumb-init"
            - "--"
            - "./web/entrypoint.sh"
          args:
            - "/bin/sh"
            - "-c"
            - "if [ -n \"$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION\" ]; then     node --import dd-trace/initialize.mjs ./web/server.js --keepAliveTimeout 110000;     else     node ./web/server.js --keepAliveTimeout 110000;     fi"
          envFrom:
            - secretRef:
                name: langfuse-web-env
          ports:
            - containerPort: 3000
              hostPort: 3100
              protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
  name: langfuse-web
  namespace: langfuse
spec:
  selector:
    app: langfuse-web
  ports:
    - name: p3000
      port: 3000
      targetPort: 3000
      protocol: TCP

Postgres、ClickHouse、MinIO、Redis 各自把原来的卷挂回原来的容器路径。Web 和 Worker 不挂这些卷,它们通过环境变量里的主机名访问 Service。

切换时做了什么

六个容器都在 25 秒停止组里。Secret 和 Service 在停止前已经提交,工作负载在停止后提交:

bash
kubectl apply -f /usr/cluster/k8s/manifests/langfuse/workloads.json

没有单独改 Langfuse 的配置文件。Dify 能继续把追踪送到 10.0.1.47:3100,是因为 hostPort 没变,而且 Web 背后的数据库卷没换。

怎么确认

写本文时,http://127.0.0.1:3100/api/public/health 是 200。Postgres 数据卷仍是 langfuse_langfuse_postgres_data,ClickHouse 和 MinIO 的卷也还是迁移前那几个目录。

7. Prometheus、Grafana、DCGM

这三个容器在 25 秒停止组里,命名空间是 observability。真正改过内容的只有 Prometheus 的抓取目标。

迁移前

原容器工作负载镜像宿主机端口容器端口数据目录依赖
prometheusprometheusprom/prometheus:v3.2.190909090配置文件和 observability_prometheus_data要抓 vLLM、DCGM
grafanagrafanagrafana/grafana:11.5.23001→30003000provisioning、仪表盘目录和 observability_grafana_dataPrometheus
dcgm-exporterdcgm-exporternvidia/dcgm-exporter:3.3.8-3.6.0-ubuntu22.0494009400无节点上的 GPU

三者的重启策略都是 unless-stopped。Prometheus 的参数是 --config.file=/etc/prometheus/prometheus.yml、--storage.tsdb.path=/prometheus、保留 15 天。Grafana 的入口是 /run.sh。DCGM 的入口是 /usr/local/dcgm/dcgm-exporter-entrypoint.sh,没有宿主机目录。

迁移前,Prometheus 用 host.docker.internal:8000 找 vLLM。那个名字在 Docker 里由宿主机映射提供。Pod 里如果只靠这个名字,要另外写 hostAliases。脚本改的是配置文件里的地址,让抓取目标直接写成节点 IP。

清单里变成什么

三份清单分别是 config/prometheus.yaml、config/grafana.yaml、config/dcgm-exporter.yaml。抓取配置本身仍是 config/prometheus.yml,和服务器上 /usr/cluster/observability/metrics/prometheus.yml 是同一份内容。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: prometheus
  namespace: observability
  labels:
    app: prometheus
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: prometheus
  template:
    metadata:
      labels:
        app: prometheus
      annotations:
        llm.local/compose-service: "prometheus"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: prometheus
          image: prom/prometheus:v3.2.1
          imagePullPolicy: IfNotPresent
          command:
            - "/bin/prometheus"
          args:
            - "--config.file=/etc/prometheus/prometheus.yml"
            - "--storage.tsdb.path=/prometheus"
            - "--storage.tsdb.retention.time=15d"
          envFrom:
            - secretRef:
                name: prometheus-env
          ports:
            - containerPort: 9090
              hostPort: 9090
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/etc/prometheus/prometheus.yml"
              readOnly: true
            - name: vol1
              mountPath: "/prometheus"
      volumes:
        - name: vol0
          hostPath:
            path: "/usr/cluster/observability/metrics/prometheus.yml"
            type: File
        - name: vol1
          hostPath:
            path: "/usr/cluster/docker/volumes/observability_prometheus_data/_data"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: prometheus
  namespace: observability
spec:
  selector:
    app: prometheus
  ports:
    - name: p9090
      port: 9090
      targetPort: 9090
      protocol: TCP
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: grafana
  namespace: observability
  labels:
    app: grafana
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: grafana
  template:
    metadata:
      labels:
        app: grafana
      annotations:
        llm.local/compose-service: "grafana"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: grafana
          image: grafana/grafana:11.5.2
          imagePullPolicy: IfNotPresent
          command:
            - "/run.sh"
          envFrom:
            - secretRef:
                name: grafana-env
          ports:
            - containerPort: 3000
              hostPort: 3001
              protocol: TCP
          volumeMounts:
            - name: vol0
              mountPath: "/etc/grafana/provisioning/dashboards"
              readOnly: true
            - name: vol1
              mountPath: "/var/lib/grafana/dashboards"
              readOnly: true
            - name: vol2
              mountPath: "/var/lib/grafana"
            - name: vol3
              mountPath: "/etc/grafana/provisioning/datasources"
              readOnly: true
      volumes:
        - name: vol0
          hostPath:
            path: "/usr/cluster/observability/metrics/grafana/provisioning/dashboards"
            type: Directory
        - name: vol1
          hostPath:
            path: "/usr/cluster/observability/metrics/grafana/dashboards"
            type: Directory
        - name: vol2
          hostPath:
            path: "/usr/cluster/docker/volumes/observability_grafana_data/_data"
            type: Directory
        - name: vol3
          hostPath:
            path: "/usr/cluster/observability/metrics/grafana/provisioning/datasources"
            type: Directory
---
apiVersion: v1
kind: Service
metadata:
  name: grafana
  namespace: observability
spec:
  selector:
    app: grafana
  ports:
    - name: p3000
      port: 3000
      targetPort: 3000
      protocol: TCP
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dcgm-exporter
  namespace: observability
  labels:
    app: dcgm-exporter
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: dcgm-exporter
  template:
    metadata:
      labels:
        app: dcgm-exporter
      annotations:
        llm.local/compose-service: "dcgm-exporter"
    spec:
      nodeName: prod102
      enableServiceLinks: false
      hostAliases:
        - ip: 10.0.1.47
          hostnames: ["host.docker.internal"]
      containers:
        - name: dcgm-exporter
          image: nvidia/dcgm-exporter:3.3.8-3.6.0-ubuntu22.04
          imagePullPolicy: IfNotPresent
          command:
            - "/usr/local/dcgm/dcgm-exporter-entrypoint.sh"
          envFrom:
            - secretRef:
                name: dcgm-exporter-env
          ports:
            - containerPort: 9400
              hostPort: 9400
              protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
  name: dcgm-exporter
  namespace: observability
spec:
  selector:
    app: dcgm-exporter
  ports:
    - name: p9400
      port: 9400
      targetPort: 9400
      protocol: TCP

Grafana 的 provisioning 和仪表盘目录是只读挂载,数据目录 observability_grafana_data 可写,所以账号和已保存的仪表盘还在。管理员口令在 Secret grafana-env 里。DCGM 不挂目录,GPU 指标靠镜像里的导出器和 Secret dcgm-exporter-env 中的 NVIDIA_VISIBLE_DEVICES。

Prometheus 现在的目标:

yaml
scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["localhost:9090"]
  - job_name: dcgm
    static_configs:
      - targets: ["dcgm-exporter:9400"]
  - job_name: vllm
    metrics_path: /metrics
    static_configs:
      - targets: ["10.0.1.47:8000"]

dcgm-exporter:9400 能解析,是因为 Grafana 和 Prometheus 在同一个命名空间,短名会落到 observability.svc.cluster.local。Dify 的 Nginx 不能靠这个机制,它的 resolver 不读 search 域,所以上一章才改成全域名。

切换时做了什么

配置文件在容器还活着的时候就改好了。随后这三个容器进入 25 秒停止组,再提交:

bash
kubectl apply -f /usr/cluster/k8s/manifests/observability/workloads.json

新 Prometheus 一启动就读到已经改过的 yaml,时序数据仍写在原来的数据卷里。

怎么确认

写本文时,:9090/-/ready 和 :3001/api/health 都是 200。vLLM 的 /metrics 返回 200。DCGM 的 Service 端口是 9400。

三、功能操作案例

下面三件事跨着多个服务。产品章里已经写过各自的结果,这里把它们串成可以对照着做的步骤。现网已经是迁移之后的状态。

案例 1:一次看完端口、数据目录和健康检查

在服务器上执行:

bash
export KUBECONFIG=/root/.kube/config
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl get svc -A

节点应是 prod102、Ready、v1.37.1,运行时 docker://28.0.1。业务 Pod 在 llm、dify、langfuse、observability。docker ps 里不应再有第 1 节那些业务容器;docker ps -a 里它们是 Exited,名字与 /usr/cluster/k8s/stopped-containers.txt 一致。

抽查数据目录有没有被换成空卷:

bash
kubectl -n llm get deploy vllm -o jsonpath='{.spec.template.spec.volumes}'
kubectl -n llm get deploy new-api -o jsonpath='{.spec.template.spec.volumes}'
kubectl -n dify get deploy db-postgres -o jsonpath='{.spec.template.spec.volumes}'
kubectl -n langfuse get deploy postgres -o jsonpath='{.spec.template.spec.volumes}'

应当看到 /models/Qwen3.6-27B、/opt/new-api/data、/usr/cluster/dify/src/docker/volumes/db/data,以及 Langfuse 的 langfuse_langfuse_postgres_data。

然后看接口。期望写在每行命令旁边:

bash
curl -s -o /dev/null -w 'new-api %{http_code}\n' http://127.0.0.1:3000/api/status
curl -s -o /dev/null -w 'vllm %{http_code}\n' http://127.0.0.1:8000/health
curl -s -o /dev/null -w 'models %{http_code}\n' http://127.0.0.1:8000/v1/models
curl -s -o /dev/null -w 'dify %{http_code}\n' http://127.0.0.1:8088/
curl -s -o /dev/null -w 'langfuse %{http_code}\n' http://127.0.0.1:3100/api/public/health
curl -s -o /dev/null -w 'grafana %{http_code}\n' http://127.0.0.1:3001/api/health
curl -s -o /dev/null -w 'prometheus %{http_code}\n' http://127.0.0.1:9090/-/ready
curl -s -o /dev/null -w 'nav %{http_code}\n' http://127.0.0.1:8090/
curl -s -o /dev/null -w 'xinference %{http_code}\n' http://127.0.0.1:9997/v1/models
curl -s -o /dev/null -w 'host80 %{http_code}\n' http://127.0.0.1:80/

New-API、vLLM、Langfuse、Grafana、Prometheus、导航页期望 200。Dify 期望 307。Xinference 期望 401。宿主机 80 期望 200,它没有进集群。

server-status 仍由宿主机 systemd 提供。请求 http://127.0.0.1:8777/ 得到 404,因为根路径本来就没有处理函数。状态接口是 http://127.0.0.1:8777/server/status?server_name=gpu-server-01。

页面入口:Dify http://10.0.1.47:8088,合同 Agent http://10.0.1.47:8088/agent/gQPVqCdcyKoW82iZ,导航 http://10.0.1.47:8090,Langfuse http://10.0.1.47:3100,Grafana http://10.0.1.47:3001。

案例 2:把 Nginx 的两次失败当成排障步骤

这一段对应 Dify 那一章的切换。现网已经改完,scripts/09-修正Dify域名.sh 再执行会停在 NGINX_ALREADY_OK。

  1. 看 Nginx 日志。解析器若还是 127.0.0.11,请求会挂住,状态 499。Pod 里没有 Docker 的内嵌 DNS。
  2. 把 default.conf 和 default.conf.template 的解析器改为 resolver 10.96.0.10 valid=30s ipv6=off;,重启 Nginx。CoreDNS 的 ClusterIP 是 10.96.0.10。
  3. 若上游变成 127.0.53.53,说明短名没有走集群 DNS。把 api:5001、web:3000、plugin_daemon:5002 换成 *.dify.svc.cluster.local 的全域名。两份文件都要改,否则下次渲染模板会盖回去。
  4. 确认 plugin-daemon Service 同时有 5002 和 5003。WebSocket 上游是 api-websocket.dify.svc.cluster.local:5001,这个值在 Secret nginx-env 的 NGINX_SOCKET_IO_UPSTREAM。
  5. 再请求 http://127.0.0.1:8088/,期望 307。

CoreDNS 里的下划线 rewrite 解决的是应用环境变量里的主机名。Nginx 这处失败要改的是它自己的配置文件。两处都要在,Dify 里的服务名才能对上。

案例 3:回滚命令

只在确定要回到原来的容器时使用。先确认 /usr/cluster/k8s/stopped-containers.txt 里有原来的 28 个容器名。现网没有走过这次回滚。

bash
export KUBECONFIG=/root/.kube/config
MAN=/usr/cluster/k8s/manifests
kubectl delete -f "$MAN/llm/workloads.json" --ignore-not-found
kubectl delete -f "$MAN/dify/workloads.json" --ignore-not-found
kubectl delete -f "$MAN/langfuse/workloads.json" --ignore-not-found
kubectl delete -f "$MAN/observability/workloads.json" --ignore-not-found
while read -r name; do
  [ -n "$name" ] || continue
  docker start "$name" || true
done < /usr/cluster/k8s/stopped-containers.txt

删除工作负载之后,Pod 释放 hostPort,原来的容器重新占用 3000、8000、8088、3100、9090、3001、9400、8090、9997。Service 和 Secret 还在的话,不会把数据目录删掉;数据本来就在宿主机路径上。

迁移脚本里的自动回滚只在 New-API 没到 200 或 Dify 没到 307 时触发。这次没有触发。最后的 MIGRATE_PARTIAL 来自 Xinference 的 401,当时 Pod 保持运行。

若 Docker 守护进程先被重启,而重启策略为 always 的旧容器已经起来,它们会先占住 hostPort,新 Pod 会起不来。那时候要先停掉这些 Exited 之后又被拉起的容器,再处理 Pod。

写文档时核对到的数据