// IAI

从「部署一下」到一个能打开的网址:我做了个内部一键部署平台

从「部署一下」到一个能打开的网址

一、起因:一个 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

第 4 步很关键:源码包可能几十 MB,让它穿过 API 进程纯属给自己找瓶颈。直接走对象存储的预签名上传,control-plane 只负责发号施令,不当数据管道。

Control-plane(调度大脑)

一个 Go 进程,HTTP API + 一个后台 reconciler goroutine。reconciler 跑两个 loop

Loop周期作用
tick2s推进pushing / deploying 状态:写 K8s manifest、监 pod、ready 后转 running
runHealthLoop30s重新探测所有running 项目的 pod,DB 状态和真实不一致就纠正

第二个 loop 是后来加的,是被现实教育的结果。总有人手动 kubectl scale --replicas=0,总有节点驱逐、pod OOM 之后没拉起来。如果 control-plane 只在部署那一刻看一眼,DB 就会永远显示 running,而实际上应用早挂了。

一条经验:声明式系统的状态,不能只在写入那一刻才对。必须有一个持续校准的循环,把「DB 里记的」和「集群里真实的」对齐。

Build-service

从预签名 URL 把 tarball 拉下来,跑 nixpacks——它会自动识别语言、生成构建 plan、产出一个标准化镜像,用户全程不需要写 Dockerfile。构建完 docker push 到本地 registry,然后把 deployment 推进到 pushing 状态,交给 reconciler。

这里有个细节我挺喜欢:registry 监听 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 链:

一个未登录请求的命运


  • L1
    IP allow-list(IPAllowList CRD)—— 不在白名单的源 IP 在这一层就被拒了,根本打不到后面

  • L2
    errors-redirect —— 把下游的 401 转成 302,跳到 /oauth2/start

  • L3
    forward-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 要多一层握手、要自己定协议,对一个单向日志流来说是过度设计。

别因为 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 policyIAM 层:列别人的 bucket 直接 403

我认真考虑过「每个项目一套独立 PG / Redis pod」,然后否掉了:

每项目独立实例50 个项目 = 50 个 PG 实例,每个 200MB 内存起步,加上 PVC 管理——对内部工具平台是严重 over-engineering。
共享底座 + 原生隔离SQL / ACL / IAM 三种原生机制已经能拿到真隔离,性价比高得多。真要专属实例,自带 RDS 手填 DATABASE_URL 即可。
SQLite 是其中最有意思的一个。很多小工具就想用 SQLite,但容器无状态,pod 一重启数据就没了。解法是 Litestream:给 pod 加 sidecar,把 /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 原子热替换,零停机生效

敏感字段(client secret)在 GET 接口返回 ********,PATCH 时收到 ******** 就当作「保持原值」,避免把密文覆盖成星号。这种小处理省了不少误操作。

五、有意识地「没做」的事

平台的克制比平台的功能更需要纪律。几个明确选择不做的:

① 不用 Helm / ArgoCD
用户群里有工程师,也有 PM、ops。对他们来说写 YAML 是负向体验。我们的目标是压低门槛,不是暴露 K8s 能力。Helm 适合 ops 给 ops 用,不适合「我有个 demo 想给团队看看」。
② build 暂时用 docker daemon,而不是 Kaniko
起步阶段 docker daemon 免费、零配置;Kaniko 要 PVC + cache 管理。等扩容到多 build 实例再迁,这不是 day-1 决策。
③ 不直接用 Vercel / Railway
内部 AI 项目可能碰客户数据,不能跑外部 SaaS;很多项目流量小但 7×24 在线,外部按量计费不划算;而且应用经常要直连内网服务和公司 OIDC。

把「现在不做什么」写清楚,比堆一堆 feature 重要——它定义了这个平台是什么、不是什么。

六、关于扩容,留了后路

当前是单平台节点 all-in-one,能撑到团队大约 20 人、50 个项目。但好在每个组件之间从一开始就是网络解耦的:PG 走 URL,MinIO 走 S3 endpoint,registry 走 host:port。所以扩容基本不需要改代码,是纯运维动作:

扩容路线图(按收益/工作量排序)


  • 阶段 0
    单平台节点 all-in-one(现在)

  • 阶段 1
    state 外置(收益最大):PG→RDS、MinIO→OSS、Registry→ACR,各改一组 env。做完平台节点变无状态

  • 阶段 2
    build-service 多实例 + 并发构建

  • 阶段 3
    control-plane 多副本 + SLB + K3s 三节点 etcd 集群

阶段 1 是关键一步:做完之后平台节点变无状态——磁盘炸了重装一台 ECS、git pull + docker compose up -d + 把 .envtls/ 拷回来,就完全恢复。

单节点起步、但每一步都为下一步留好接口——这是我觉得做内部基础设施最该有的姿势。不要一上来就上 HA,但也别把自己焊死在单机上。

顺手记一下:平台 ↔ worker 要开的端口

平台 ↔ worker(私有子网内):

端口协议用途
6443tcpK3s API
10250tcpkubelet
8472udpflannel VXLAN
5001tcpimage registry(workers 拉镜像用)

对外(仅平台节点):

端口协议用途
80tcpTraefik HTTP(自动 302 跳 443)
443tcpTraefik HTTPS

七、写在最后

回头看,这个平台真正解决的不是「部署」这个技术动作,而是「从想法到能给别人看」之间那段被流程吃掉的时间。以前按周计,现在按分钟计。

最让我有成就感的,反而不是某个具体技术点,而是当组里同学第一次说完「部署一下」、盯着终端里一行行滚出来的构建日志、最后看到那个 https://... 链接时——那种「居然真的就这么简单」的表情。

技术选型上我尽量克制:能用简单方案就不用复杂的,能单机起步就不提前上分布式,能复用底座就不每项目开实例。这些克制单看每一个都不起眼,但加在一起,就是「一句话拿到一个网址」和「折腾一周还没跑起来」的区别。

如果你也在给团队做内部工具平台,希望这些取舍能帮你在自己的岔路口少纠结一会儿。
:star:GitHub 仓库 :book:使用手册(业务向) :building_construction:技术架构(工程向)

还没有评论

欢迎留下你的观点,保持交流的清晰和友好。

写下评论