背景
我一直想把 DSH(DeepSeek Harness)运行在我的 homelab2 k8s集群里(此前一直运行在本机的 ~/.dsh 里。),这样我就可以 7*24 随时随地使用了。
之前是我自己被 AI Sandbox 洗脑了,想着等 AI Sandbox 开源生态成熟了,再搞。
但是今天突然想明白了,AI Sandbox 说白了是给企业/云厂商/模型厂大规模使用准备的,普通人其实用不到这么复杂。
想明白后,那就基于成熟的 k8s 原语:statefulset, secret, cm, PVC, Ingress来做,再用 Helm Chart包装起来。这次把它搬进 homelab2 的 k3s,并接入既有的 Helm、ArgoCD 与 CalVer 发布流程。
以下是具体实践过程:
交付形态:vendored 扁平 Chart
我先是搜了下 DSH 相关开源 repo, 找到了一个上游runzhliu/deepseek-harness-docker。它已经有了镜像和 Helm Chart 的相关积累,这次在它的肩膀上继续。
选择了 runzhliu/deepseek-harness-docker 的当时最新 commit 04371405,Chart 版本 0.1.29。
上游没有 Helm 仓库,只能从 Git 目录取。所以做了 vendored 扁平 Chart:
version:0.1.29-homelab.1appVersion:0.2.2-rc.2- 镜像固定
0.2.2-rc.2-r1-market.1
上游的 values 命名和安全上下文原样保留,新增的部分是四块 PVC、一个 ClusterIP Service、两条 Ingress、NetworkPolicy 放行规则和 resources.limits。
主线取舍:不用 initContainer
这是整个方案里最有争议的一条。
我的规划如下:
- DSH 的配置需要从我电脑同步过来并持久化
- Skills 也要从我电脑同步过来并持久化
- uv 工具集需要安装并持久化
- mise 工具集需要安装并持久化
关于工具集,我现在工具集基本都使用 uv 和 mise 管理:
- uv 管理 python 相关(如:pre-commit)
- mise 管理 非python的其他(如:kubectl)
工具安装要装 uv、mise,再加 4 个 uv 工具和 9 个 mise 工具。
uv mise 的 global 工具清单如下(很多 repo 都用得上):
shell
$ uv tool list
pre-commit v4.7.0
- pre-commit
pyrefly v1.4.1
- pyrefly
specify-cli v1.4.0
- specify
yamllint v1.38.0
- yamllint
$ mise list
Tool Version Source Requested
aqua:atuinsh/atuin 18.24.6 ~/.config/mise/config.toml latest
argocd 3.6.1 ~/.config/mise/config.toml latest
gh 2.103.3 ~/.config/mise/config.toml latest
github:Dicklesworthstone/destructive_command_guard 0.16.0 ~/.config/mise/config.toml latest
github:harehare/mq 0.10.1 ~/.config/mise/config.toml latest
github:mikefarah/yq 4.54.3 ~/.config/mise/config.toml latest
github:yusukebe/ax 0.1.25 ~/.config/mise/config.toml latest
jq 1.8.2 ~/.config/mise/config.toml latest
kubectl 1.37.2 ~/.config/mise/config.toml latest
npm:@colbymchenry/codegraph 1.6.2 ~/.config/mise/config.toml latest
npm:clawhub 0.25.1 ~/.config/mise/config.toml latest
关于 DSH 配置,配置同步要取一份配置,然后进容器跑 pnpm install。skills 同步同理。
这些如果塞进 initContainer,Pod 每次启动都要重跑一遍,或者得额外做一套缓存判断。装挂了 Pod 起不来,日志还埋在 init 阶段。而且额外引入了很多的复杂性。
所以最终放弃 initContainer方案,做简化: Pod 直接启动 DSH。(🧠可能后续我又会纠结,然后考虑 sidecar 等方案🤣)
最终敲定方案:工具安装、配置同步、skills 同步改成维护者(我)在 Pod Ready 之后通过 kubectl cp / kubectl exec 执行的一次性手动任务,只要结果落在 PVC 上持久化不丢就行。
失败可以重跑,不阻塞 Pod 启动。
代价也很直接:首次部署是「先上线一个空 DSH,再手动补齐工具与配置」。漏跑的话 DSH 照样能启动,但工具和插件不完整。
这个代价可以接受。至少在这一步,可诊断性比自动化程度值钱。
还有个代价就是:不够标准化,不够声明式配置,后续想重建估计又得想破脑袋回忆当初是怎么操作的来着...
四块 PVC
全部 RWO:
| PVC | 大小 | 承载 |
|---|---|---|
dsh-home |
10Gi | DSH_HOME |
workspace |
20Gi | /workspace |
tools |
20Gi | 工具与 XDG 目录 |
skills |
1Gi | skills |
保留策略分两种写法:
dsh-home走上游的volumeClaimTemplates+persistentVolumeClaimRetentionPolicy- 另外三块是独立 PVC,带
helm.sh/resource-policy: keep和 ArgoCD 的Prune=false,Delete=false。目标只有一个:Pod 重建、Helm 卸载之后数据都还在。
📝Notes :
四块 PVC 后续都用 volsync 做了自动备份策略。RWO 卷是由集群上的 rook-ceph 提供。
配置同步
简单粗暴:从电脑本机拉,本地改写后,tar 传输进容器。
完整流程:
bash
# 1. 本机取配置
scripts/sync-dsh-config --source ~/.dsh
# 2. 本地改写(这里是我自己的有些dsh hardcode了本地路径,规划了后续优化)
./transform-config.sh
# 3. tar + kubectl cp 到 Pod 的 /tmp
kubectl cp ./dsh-config.tar <ns>/<pod>:/tmp/dsh-config.tar
# 4. 容器内解包
kubectl exec -it -n <ns> <pod> -- \
sh -c 'cd $DSH_HOME && tar xf /tmp/dsh-config.tar'
# 5. 容器内装依赖
kubectl exec -it -n <ns> <pod> -- \
sh -c 'cd $DSH_HOME && CI=true pnpm install --no-frozen-lockfile'
# 6. 重启生效
kubectl rollout restart statefulset/deepseek-harness -n <ns>
复制走白名单,明确不带的:sessions/(后续再看有没有现成的会话同步方案。DSH SuperAgent 后续是不是会做个数据库来存 sessions?)、storages/、.credentials.yaml。
容器化改写清单
从本机 ~/.dsh 到容器,路径、认证、包管理器语义都有差异。
- 绝对路径:
/home/casey/.dsh→/home/node/.dsh codegraph/atuin/dcg指向 mise shims,电脑本机和容器的 mise shims 路径不同- 删掉 profile 里重复的
dshmarket。镜像内置1.66.9,重复会出问题 atuin(shell 历史记录同步工具) 改由 mise(aqua)安装
另外发现镜像默认把 XDG_CACHE_HOME / XDG_CONFIG_HOME / XDG_DATA_HOME 指向 /tmp。只挂 PVC 不足以持久化工具。
覆盖成:
yaml
env:
- name: XDG_CACHE_HOME
value: /workspace/.local/cache
- name: XDG_DATA_HOME
value: /workspace/.local/share
- name: XDG_STATE_HOME
value: /workspace/.local/state
- name: XDG_CONFIG_HOME
value: /workspace/.config
PATH 里把 /workspace/.local/bin 和 mise 的 shims 前置。如下:
shell
$ echo $PATH
/workspace/.local/bin:/workspace/.local/share/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
不覆盖这一条,Pod 重启一次工具就没了。
K8s 网络
Tailscale L7 Ingress 设置
上游 Service 是 clusterIP: None 的 Headless Service。
我的期望是通过 Tailscale Ingress 暴露,这样我就可以通过手机/电脑/平板等直接访问。
Tailscale Operator 的 L7 Ingress 在 addIngressBackend 里要求后端有 ClusterIP。Headless 直接不行。
所以新增 deepseek-harness-tailscale,ClusterIP,两个端口:http 3080、desktop 6080。Headless 只留给 StatefulSet 的 serviceName。
两条 Ingress:
dsh.west-beta.ts.net→3080dsh-desktop.west-beta.ts.net→6080用于打开上游镜像里带的noVNC的浏览器界面。
Web 那条同时把 /novnc- 前缀路由到 6080,并把 DSH_DESKTOP_PUBLIC_PORT 设成 443,UI 里的桌面入口才点得开。
Cilium 设置
上游的 helm chart 设置了 NetworkPolicy。我需要放行 tailscale ingress 的流量,NetworkPolicy 按 tailscale 或 ingress-proxies 标签放行,也试了 ipBlock。结果都不生效。
原因是 Cilium 配了 ipv4NativeRoutingCIDR=192.168.3.0/24。跨节点 Pod 流量被 SNAT 成节点 IP,源身份变成 world。策略 match none,连接直接超时。
最后退化成只按端口放行 3080 和 6080,其余入站继续拒绝:
yaml
ingress:
- ports:
- port: 3080
protocol: TCP
- port: 6080
protocol: TCP
安全边界
- 非 root,UID/GID
1000 - 只读根文件系统
- drop ALL
noVNC 没有认证。这是显式接受的设计,不是遗漏。只有 tailnet Ingress 能到,Tailscale ACL 是唯一网络边界。反正这个 Tailnet 是我自己一个人用的。
Secret 里配置了两个 key:
DEEPSEEK_API_KEYGITHUB_API_TOKEN: 给 mise 用,绕过 GitHub 下载二进制文件的限流
总结
🎉🎉🎉
最终效果如下:
📔Notes:
哦,差点忘了说,记得 DSH 启动时需要加这 2 个 flags:
--host <your-domain> --trusted-host <your-domain>,这样才能通过域名访问。
这次落地真正花时间的地方,是把能自动化的和不该自动化的分开:
- PVC、Service、Ingress、NetworkPolicy 归 Helm 和 ArgoCD。工具安装与配置同步留在 Pod Ready 之后手动做。
- 平台约束那部分没得选。Tailscale 要 ClusterIP 就给 ClusterIP,Cilium SNAT 让源选择器失效就退到端口级。
先用起来,再根据真实需求逐步迭代优化。
以上。