从「部署一下」到一个能打开的网址
一、起因:一个 demo 卡在「只能在我电脑跑」
事情的起点很普通。
组里有同学写了个小工具——一个前端 demo,或者一个 AI agent,跑在自己 localhost:3000 上,想让团队其他人也点开看一眼。然后他卡住了。
要让别人能访问,标准流程是这样的:
想给别人看一眼,传统得走这六步
- 01申请一台 ECS
- 02写 Dockerfile
- 03配一堆 K8s YAML(Deployment / Service / Ingress / TLS)
- 04配 CI/CD
- 05申请域名、配 DNS
- 06接公司 SSO,别让外人随便打开
对一个只是想「快速验证一下想法」的人来说,这六步太重了。从「我有个东西想给你看」到「这是网址你点开」,经常按周计。大部分 demo 还没走到这一步,就死在了「太麻烦」上。
我想要的体验其实很简单:
cd <project> # 任意项目目录
claude # 在 Claude Code 里说一句话
> 部署一下
# 1-3 分钟后:
# :rocket: https://my-app.example.com一句话,1 到 3 分钟,拿到一个带 SSO + HTTPS 的网址。不写 Dockerfile,不碰 K8s,不配 DNS。这就是这个平台——我们内部叫它 iai——要解决的全部问题。
二、先看它最后长什么样
在讲怎么实现之前,先把全貌摆出来,后面讲链路才有坐标。
开发者本机 平台节点 Worker 节点
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Claude │ ─API─▶ │ control- │──┤ PG │ ┌────────────┐
│ Code + │ │ plane │ │ MinIO │ │ K3s agent │
│ Skill │ ├──────────┤ │ Registry │ │ │
└──────────┘ │ build- │ │ Redis │ │ user pods │
│ service │ │ user-PG │ ← 自动开 │ (proj-xxx) │
└──────────┘ └──────────┘ 给项目 └────────────┘三块东西:
开发者本机
Claude Code + 一个 Skill(本质是几个 bash 脚本),负责扫描项目、打包、调 API、流式打印日志。
平台节点
一台 ECS,跑 docker-compose 全家桶——control-plane、build-service、PostgreSQL、MinIO、镜像 registry、Redis,外加 K3s server 和 Traefik。
Worker 节点
纯粹的 K3s agent,只干一件事——跑用户的应用 pod。
技术栈没什么花哨的:Go 1.22 chi pgxpool 做后端,React 18 Vite Tailwind 做管理后台,K3s 做编排,Keycloak 做 OIDC。选型的克制本身就是一个决策,后面会展开。
三、说「部署一下」之后,到底发生了什么
这是整篇文章里我最想讲清楚的一段。一次部署,从一句话到一个网址,链路是这样的。
Skill 端(你的电脑上)
你在 Claude Code 里说「部署一下」,Skill 的 push.sh 开始干活:
一次 push 的七个动作
- 1扫描:递归识别项目类型(Node / Python / Go / Rust),生成 manifest。优先级:
.vibedeploy.toml显式声明 > 框架特征推断 > 兜底默认值 - 2打包:git 仓库走
git ls-files,天然尊重.gitignore;非 git 仓库手工 exclude。zstd 压成source.tar.zst - 3创建部署:调
POST /v1/.../deployments,服务端回 deployment ID、一个预签名 MinIO PUT URL、一个 SSE 流地址 - 4上传:
curl -X PUT直接把 tarball 怼到预签名地址——不经过 control-plane - 5跟踪:订阅 SSE,把
[phase] message一行行打到终端,直到event: end
Control-plane(调度大脑)
一个 Go 进程,HTTP API + 一个后台 reconciler goroutine。reconciler 跑两个 loop:
| Loop | 周期 | 作用 |
|---|---|---|
tick | 2s | 推进pushing / deploying 状态:写 K8s manifest、监 pod、ready 后转 running |
runHealthLoop | 30s | 重新探测所有running 项目的 pod,DB 状态和真实不一致就纠正 |
第二个 loop 是后来加的,是被现实教育的结果。总有人手动 kubectl scale --replicas=0,总有节点驱逐、pod OOM 之后没拉起来。如果 control-plane 只在部署那一刻看一眼,DB 就会永远显示 running,而实际上应用早挂了。
Build-service
从预签名 URL 把 tarball 拉下来,跑 nixpacks——它会自动识别语言、生成构建 plan、产出一个标准化镜像,用户全程不需要写 Dockerfile。构建完 docker push 到本地 registry,然后把 deployment 推进到 pushing 状态,交给 reconciler。
127.0.0.1:5001,docker daemon 默认信任 loopback,所以 build-service 推镜像零配置。但 worker 节点拉镜像不能用 loopback,于是 apply 前有一步 rewriteImageHost,把 tag 里的 127.0.0.1:5001 改写成 <平台IP>:5001——push 用环回、pull 用内网地址,两边各取所需。K8s driver + Traefik 入口
deployer.go 是 client-go 的一层薄壳:给每个项目建独立 namespace proj-<slug>,写 Deployment / Service / Ingress,默认子域 <slug>.example.com。
流量入口是 Traefik,按 DaemonSet 部署、hostNetwork: true 直接绑每个节点的 :80 / :443。非 public 的项目自动挂上一条 middleware 链:
一个未登录请求的命运
- L1IP allow-list(IPAllowList CRD)—— 不在白名单的源 IP 在这一层就被拒了,根本打不到后面
- L2errors-redirect —— 把下游的 401 转成 302,跳到
/oauth2/start - L3forward-auth —— 调 oauth2-proxy 的
/oauth2/auth,200 放行,401 触发上面的跳转
于是「未登录的人访问 → 自动跳 SSO 登录 → 登录完回到应用」整条体验就闭环了,应用本身一行认证代码都不用写。
四、几个我觉得值得讲的设计取舍
做平台的乐趣不在于「用了什么技术」,而在于「在每个岔路口为什么走了这条」。挑几个说。
为什么是 K3s,而不是托管 K8s
我们要在自己的 ECS 上跑。托管 K8s(EKS / ACK)不一定能方便地拉私有 registry、走内网,而且控制面是个黑盒。K3s 是单 binary、配置简单、控制面占用 < 1GB,单节点就能起步,扩容也平滑。对一个内部平台来说,「能完全掌控、能单机起步」比「云厂商托管」重要得多。
为什么 Traefik 用 DaemonSet + hostNetwork
云上的 ServiceLB 想拿公网 IP,得用 LoadBalancer 类型的 Service,公有云要另外计费。与其如此,不如让每个节点的 :80/:443 都能直接服务流量,DNS 轮询就是最朴素的负载均衡。hostNetwork: true 就是在说「我就要这台机器的 80 端口」,再配个 NET_BIND_SERVICE capability 让非 root 进程也能绑低端口。
hostNetwork 默认走宿主机的 /etc/resolv.conf,云上那个指向云厂商 DNS,解析不到 *.svc.cluster.local。结果 Traefik 转发到集群内的 oauth2-proxy 直接解析失败。解法是加一行 dnsPolicy: ClusterFirstWithHostNet,让它走 CoreDNS。一行配置,定位了挺久。为什么部署日志用 SSE,而不是 WebSocket
部署日志是单向的(server → client),SSE 完全够用。它走标准 HTTP,浏览器、curl、各种中间件兼容性都好。WebSocket 要多一层握手、要自己定协议,对一个单向日志流来说是过度设计。
为什么 OIDC 的 state 用 HMAC 签名,而不是存 cookie
OIDC 标准里 state 参数通常存 cookie 防 CSRF。但我们的 Web UI 和 Skill 是跨端口、跨子域的,cookie 的 scope 特别容易踩坑——这个子域写的 cookie,那个子域读不到。
所以这里把 state 做成 HMAC 签名的自包含字符串:所有需要的信息都编码进 state 本身,再用密钥签名校验,完全不依赖 cookie。副作用是它天然 stateless,对将来做 HA 也友好——任意一个 control-plane 副本都能校验,不需要共享 session。
用户应用的数据:共享底座,但每个项目「真隔离」
这是我最满意的一块。
用户在 manifest 里声明一句 postgres = true / redis = true / s3 = true,平台就自动开通对应资源、把连接串加密注入到 pod 里。业务方不用申请、不用配、不用自己想密码——但拿到的是真隔离的切片,不是一把共享钥匙。
| 服务 | 共享底座 | 每项目派生 | 隔离在哪一层 |
|---|---|---|---|
| PostgreSQL | 一个独立的user-postgres 容器 | 独立 databaseproj_<slug> + 独立 role + 随机密码 | SQL 层:GRANT 只到自家库,跨项目 \c 都不行 |
| Redis | 共享一个 Redis 6 容器 | ACL 用户 + 限定 key 前缀~proj-<slug>:* + 禁危险命令 | ACL 层:写前缀外的 key 直接NOPERM |
| MinIO / S3 | 共享一个 MinIO 容器 | 独立 bucket + 独立 IAM 用户 + bucket-only policy | IAM 层:列别人的 bucket 直接 403 |
我认真考虑过「每个项目一套独立 PG / Redis pod」,然后否掉了:
DATABASE_URL 即可。/data/app.db 的 WAL 实时增量同步到项目自己的 S3 bucket;再加 init container,pod 启动时先 litestream restore 从 S3 恢复。于是 pod 重启、节点漂移,数据都在。声明 sqlite = true 时平台会隐式把 s3 = true 也打开——因为 Litestream 没 S3 没法工作,但用户不需要知道这层依赖。所有自动派生的环境变量都用 system=true 标记,后台不允许人工编辑——避免哪天 admin 手滑覆盖掉,把应用搞挂。值在数据库里用一个根密钥(KEK)加密落地,部署时解密注入到 K8s Secret,源码里不留任何明文。
运行时配置热加载
早期改 Keycloak 配置要改 .env + 重启容器,体验很差。现在所有可变配置都存在 system_config 表里,管理员在设置页保存 → PATCH 接口写库 → Runtime.Reload 刷新内存视图 → JWT 验签器通过 atomic.Pointer 原子热替换,零停机生效。
********,PATCH 时收到 ******** 就当作「保持原值」,避免把密文覆盖成星号。这种小处理省了不少误操作。五、有意识地「没做」的事
平台的克制比平台的功能更需要纪律。几个明确选择不做的:
① 不用 Helm / ArgoCD
② build 暂时用 docker daemon,而不是 Kaniko
③ 不直接用 Vercel / Railway
把「现在不做什么」写清楚,比堆一堆 feature 重要——它定义了这个平台是什么、不是什么。
六、关于扩容,留了后路
当前是单平台节点 all-in-one,能撑到团队大约 20 人、50 个项目。但好在每个组件之间从一开始就是网络解耦的:PG 走 URL,MinIO 走 S3 endpoint,registry 走 host:port。所以扩容基本不需要改代码,是纯运维动作:
扩容路线图(按收益/工作量排序)
- 阶段 0单平台节点 all-in-one(现在)
- 阶段 1state 外置(收益最大):PG→RDS、MinIO→OSS、Registry→ACR,各改一组 env。做完平台节点变无状态
- 阶段 2build-service 多实例 + 并发构建
- 阶段 3control-plane 多副本 + SLB + K3s 三节点 etcd 集群
git pull + docker compose up -d + 把 .env 和 tls/ 拷回来,就完全恢复。单节点起步、但每一步都为下一步留好接口——这是我觉得做内部基础设施最该有的姿势。不要一上来就上 HA,但也别把自己焊死在单机上。
对外(仅平台节点):顺手记一下:平台 ↔ worker 要开的端口
平台 ↔ worker(私有子网内):端口 协议 用途 6443 tcp K3s API 10250 tcp kubelet 8472 udp flannel VXLAN 5001 tcp image registry(workers 拉镜像用) 端口 协议 用途 80 tcp Traefik HTTP(自动 302 跳 443) 443 tcp Traefik HTTPS
七、写在最后
回头看,这个平台真正解决的不是「部署」这个技术动作,而是「从想法到能给别人看」之间那段被流程吃掉的时间。以前按周计,现在按分钟计。
最让我有成就感的,反而不是某个具体技术点,而是当组里同学第一次说完「部署一下」、盯着终端里一行行滚出来的构建日志、最后看到那个 https://... 链接时——那种「居然真的就这么简单」的表情。
技术选型上我尽量克制:能用简单方案就不用复杂的,能单机起步就不提前上分布式,能复用底座就不每项目开实例。这些克制单看每一个都不起眼,但加在一起,就是「一句话拿到一个网址」和「折腾一周还没跑起来」的区别。
还没有评论
欢迎留下你的观点,保持交流的清晰和友好。
写下评论