Knative Serving 解决的不是「再写一套调度器」,而是把 HTTP 工作负载在 Kubernetes 上收成一个对象。一次
Serviceapply,换来 Revision、Route、自动伸缩和 scale-to-zero。底层仍是 Deployment 与 Endpoints,控制面替你转。
Knative 由 Google 发起,2025 年 10 月从 CNCF Incubating 毕业。整套东西分三层,这篇写 Serving;Eventing 的事件格式见 CloudEvents,Functions 只是上面的 FaaS 糖衣,不改变下面任何不变量。
| 层 | 管什么 |
|---|---|
| Serving | HTTP 运行时、Revision、流量、扩缩容到零 |
| Eventing | CloudEvents 的 Source / Broker / Trigger |
| Functions | Serving + Eventing 上的函数式入口 |
传统 Kubernetes 要拼 Deployment、Service、Ingress、HPA、ConfigMap。Serving 把它们收成 serving.knative.dev/v1 Service。适合 HTTP API、按请求唤醒的推理服务、要灰度的微服务。官方也写了不适用的边界:要亚 100ms 冷启动,或进程必须常驻、带本地状态——别用 scale-to-zero 赌。
冷启动走完整的 Kubernetes 路径:网关 → Activator 拦截 → Autoscaler 改 Deployment → 调度 → 拉镜像 → Queue-Proxy + 业务容器就绪 → 再转发。调度器、镜像、应用 init、控制面往返,每一环都加延迟。Lambda / Cloud Run 不走这套,所以冷启动更快;Serving 换来的是 Kubernetes 可移植,不是更快的沙箱。
资源模型
用户只写 Service。Controller 拆出 Configuration 与 Route;Configuration 每次 template 变更钉一个不可变 Revision;Revision 再落下 Deployment、PodAutoscaler(PA)、ServerlessService(SKS),以及 public / private 两套 Kubernetes Service。
Service
├─ Configuration ──► Revision ──► Deployment
│ └─ PodAutoscaler ──► ServerlessService
│ ├─ Service/public
│ └─ Service/private
└─ Route ──► KIngress ──► net-istio / kourier / contour
└─ VirtualService / HTTPProxy / Ingress
网络层可换。Istio 场景里,knative-serving 的 net-istio-controller 听 KIngress,写出 VirtualService;Istio 自己再把抽象路由编成数据面配置。Route 决定「这个 Host 的多少流量、哪个 tag 打到哪个 Revision」;SKS 决定「此刻有没有 Ready Pod,没有就指到 Activator」。

创建后真正要盯的,通常是这几个:
ksvc/configuration/revision/route:意图与版本pa/sks:扩缩容与公私 Service 的切换- 对应的 Kubernetes
deployment、endpoints:副本是否真的起来
控制面与数据面

| 组件 | 平面 | 不变量 |
|---|---|---|
| Webhook | 控制面入口 | 资源入库前校验、注入默认值。非法配置到不了 Controller |
| Serving Controller | 控制面 | 把 Service / Route / Revision 收成 Kubernetes 对象 |
| Autoscaler | 控制面 | 拉 Queue-Proxy 指标、收 Activator 推送的排队数,约 2s 写一次 DesiredScale 到 Decider |
| KPA Controller | 控制面 | 听 Decider,改 Deployment 的 scale 子资源 |
| Activator | 数据面缓冲 | 从零唤醒时接住请求;突发时也可留在路径上排队,再 poke Autoscaler |
| Queue-Proxy | 热路径数据面 | 每个业务 Pod 的 sidecar。唯一对外入口:并发硬限制、本地排队、指标、优雅退出 |
指标方向不能写反。Autoscaler 每秒 HTTP 拉 Queue-Proxy :9090 的 protobuf;Activator 走 WebSocket 立刻推 排队数 / poke。从零唤醒比稳态扩容更急,就是因为这一条推送,不必等下一轮 pull。
关系再收三句:
- Webhook 先于 Serving Controller。提交时就拦住坏配置。
- 零副本时,SKS 把 public Service 指到 Activator。有副本时默认可以直打 Queue-Proxy;
target-burst-capacity决定 Activator 还在不在路上。 - Autoscaler 算出 DesiredScale,写入 Decider;真正改副本的是 KPA Controller。没有这一跳,scale-to-zero 只是把服务关了。
sequenceDiagram
participant C as Client
participant IG as Ingress Gateway
participant Act as Activator
participant AS as Autoscaler
participant KPA as KPA Controller
participant QP as Queue-Proxy
participant App as User Container
C->>IG: Host: echo-server.default.example.com
alt replicas = 0
IG->>Act: 冷路径
Act->>AS: WebSocket poke / 排队数
Act->>Act: goroutine 阻塞等待容量
AS->>KPA: Decider.DesiredScale
KPA->>KPA: Deployment.scale
Note over Act: 看 Endpoints,不是等 Autoscaler 回信
Act->>QP: 转发积压请求
else replicas ≥ 1
IG->>QP: 热路径
end
QP->>QP: containerConcurrency,满了先本地排队
QP->>App: localhost:8080
App-->>QP: 响应
QP-->>C: 响应
AS->>QP: 每秒拉 :9090
Queue-Proxy:Pod 里的唯一入口
业务容器不直接对外。流量、管理、指标拆在三个端口上,避免混用:

| 端口 | 平面 | 职责 |
|---|---|---|
| 8012 | 数据 | HTTP handler,反代到 localhost:<userPort> |
| 8022 | 管理 | preStop 进入 draining:停接新请求,等在途走完,再给业务 SIGTERM |
| 9090 | 指标 | protobuf metrics,给 Autoscaler 算并发 / RPS |
containerConcurrency 是硬限制,写在 Revision 的 Pod spec 里,由 Queue-Proxy 强制。打满之后 QP 先在 sidecar 里本地排队,不是立刻 503。Autoscaler 的 target 是软目标,用来算副本数。两个不是一回事:前者保护进程,后者决定扩多少。503 / 504 属于 Activator 那一侧的队列满和 300 秒超时,不要和 QP 的本地排队混为一谈。
缩容时顺序固定:Deployment 删 Pod → Queue-Proxy preStop → 8022 draining → 在途归零 → user-container 收 SIGTERM。业务容器不感知 Activator,也不参与扩缩容决策。
Activator:排队的是 goroutine,不是消息队列
缩到零之后,请求必须有人接着,否则客户端直接连不上。突发时它也可以留在路径上当缓冲。Activator 做三件事:接请求、按 Revision / Pod 容量限流、把排队数推给 Autoscaler。
分发不是「先落盘再转发」。HTTP server 为每个请求起一个 goroutine;http.Request 在堆上,ResponseWriter 指针在栈上。没容量时,这个 goroutine 堵在 semaphore 的 channel 上——请求人还在,连接还在,只是处理协程睡着了。
sequenceDiagram
participant Client
participant Activator
participant Rev as Revision Breaker
participant Pod as Pod Breaker
participant QP as Queue-Proxy
Client->>Activator: HTTP
Activator->>Rev: 要容量
alt Revision 有容量
Rev->>Pod: 选一个 Pod 并 Reserve
alt Pod 有容量
Pod->>QP: 转发
QP-->>Client: 响应
else Pod 满
Pod-->>Rev: 重新入队
end
else 仍是零副本
Rev->>Rev: goroutine 阻塞在 channel
Note over Rev: 等 Pod Ready,或等到超时
QP->>Rev: 容量更新,唤醒
Rev->>QP: 继续转发
end
代价按内存算,不按磁盘算:
| 项 | 量级 |
|---|---|
| 等待中的 goroutine 栈 | 2KB+ |
| 单个请求(含 Header / Body) | 约 10–50 KB |
| channel 本身 | 几乎只传信号 |
| 队列深度 10000 | 大约 100–500 MB |
积压不会无限涨。默认 TimeoutHandler 300 秒后回 504,goroutine 退出,内存释放。队列满了,新请求立刻 503;已经进去的继续等到超时。这是冷启动保护,不是业务消息队列。
热路径、冷路径,以及 Activator 何时还在路上
没有 Ready Pod 时,SKS 把 public Service 指到 Activator,这是从零唤醒:
Client → LB → Ingress → Activator
├─ WebSocket poke → Autoscaler → Decider → KPA → Deployment.scale
└─ Endpoints 出现后 → Queue-Proxy → user-container
有 Ready Pod 时,最短路径不经过 Activator:
Client → LB → Ingress Gateway → Revision Service → Queue-Proxy:8012 → user-container
Activator 不只在零副本时上场。autoscaler.target-burst-capacity 决定它还在不在热路径上:
| 值 | 含义 |
|---|---|
| 200(默认) | 突发时仍把一部分请求交给 Activator 缓冲 |
| 0 | 只在从零唤醒时经过 Activator |
| -1 | 请求永远经过 Activator |
会话亲和要在 Activator 的 Envoy 上种 Cookie,必须用 -1,否则直打 Pod 的请求看不见这条链。具体做法在后面。
Autoscaler 侧的节拍也要分开写:Queue-Proxy 指标大约 1s 拉一次,DesiredScale 大约 2s 算一次,写入 Decider,再由 KPA Controller 改 Deployment。Serving Controller 不管副本数字。
第一个 Service
本地用 kind 起集群,再按官方 YAML 装 Serving(网络层选 Istio 或 Kourier)。CLI 与安装步骤以文档为准,不在这里复述:
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: echo-server
spec:
template:
spec:
containers:
- image: ghcr.io/knative/helloworld-go:latest
ports:
- containerPort: 8080
env:
- name: TARGET
value: "World"
没有 External IP 时,把网关端口转到本机:
kubectl port-forward svc/istio-ingressgateway -n istio-system 8080:80
curl -H "Host: echo-server.default.example.com" http://localhost:8080
自动伸缩
KPA 默认看并发。注解写在 Revision template 上,创建后随这次 Revision 固定。
| 注解 | 含义 |
|---|---|
autoscaling.knative.dev/class |
默认 kpa.autoscaling.knative.dev |
autoscaling.knative.dev/metric |
concurrency 或 rps |
autoscaling.knative.dev/target |
每个 Pod 的目标负载 |
autoscaling.knative.dev/target-utilization-percentage |
提前扩容的水位,70 表示目标的 70% 就开始加副本 |
autoscaling.knative.dev/min-scale / max-scale |
副本上下限。min-scale: "0" 才是 scale-to-zero |
autoscaling.knative.dev/initial-scale |
首次部署拉起几个 |
autoscaling.knative.dev/activation-scale |
从零唤醒时至少起几个 |
autoscaling.knative.dev/scale-down-delay |
空闲多久才缩 |
autoscaling.knative.dev/window |
稳态窗口 |
containerConcurrency |
Queue-Proxy 硬限制,不是注解,写在 spec |
生产如果受不了冷启动,把 min-scale 留在 1。要观察扩缩容,把 target 压低,再用 hey 打 30 秒:副本会跟着并发爬升,流量停了再按 scale-down-delay 掉下去。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: scaling-demo
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/class: kpa.autoscaling.knative.dev
autoscaling.knative.dev/metric: "concurrency"
autoscaling.knative.dev/target: "2"
autoscaling.knative.dev/target-utilization-percentage: "50"
autoscaling.knative.dev/min-scale: "0"
autoscaling.knative.dev/max-scale: "10"
autoscaling.knative.dev/initial-scale: "1"
autoscaling.knative.dev/scale-down-delay: "30s"
autoscaling.knative.dev/window: "60s"
spec:
containerConcurrency: 20
containers:
- image: ghcr.io/knative/helloworld-go:latest
ports:
- containerPort: 8080
hey -z 30s -c 10 -host "scaling-demo.default.example.com" http://127.0.0.1:8080
Revision 与流量
业务上的「一个 APP」对应一个 Knative Service;每次改 template 得到一个不可变 Revision;traffic[].tag 是给人用的版本名,revisionName 才是集群里的对象。
所有版本共用同一个 Host。不带 header 走 percent;要钉某个版本,加 Knative-Serving-Tag:
kubectl get revisions -l serving.knative.dev/service=echo-server
curl -H "Host: echo-server.default.example.com" http://<gateway-ip>
curl -H "Knative-Serving-Tag: v2" -H "Host: echo-server.default.example.com" http://<gateway-ip>
灰度、回滚、预发,都是改同一份 traffic,不必复制 Service。典型写法:新 Revision 先 percent: 0 加 tag 做 staging,确认后再把 percent 切过去;旧版本可以留着只靠 tag 访问。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: echo-server
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/min-scale: "0"
autoscaling.knative.dev/max-scale: "10"
spec:
containers:
- image: ghcr.io/knative/helloworld-go:latest
ports:
- containerPort: 8080
env:
- name: TARGET
value: "Knative v3"
traffic:
- latestRevision: true
percent: 10
tag: v3
- revisionName: echo-server-00002
percent: 90
tag: v2
- revisionName: echo-server-00001
percent: 0
tag: v1
停用一个版本:从 traffic 里拿掉它。没有 Route 引用后,对应 Pod 会缩到零。Revision 创建后 annotation 基本不可变,去 patch 旧 Revision 的 maxScale 不可靠。删除版本也是同一条路:先移出 traffic,再 kubectl delete revision <name>。删整个 APP 用 kubectl delete ksvc echo-server,Configuration、Route、Revision 一起走。
| 要做的事 | 做法 |
|---|---|
| 新 APP | 建 Service,traffic 给第一个 tag |
| 新版本 / 预发 | 改 template,新 tag 指向 latestRevision,percent: 0 |
| 灰度 | 调各 revisionName 的 percent |
| 回滚 | 旧 Revision 调回 100%,新的调 0 或移走 |
| 停用但保留对象 | 移出 traffic,或 percent: 0 且不留 tag |
| 删版本 | 先移出 traffic,再删 Revision |
| 删 APP | kubectl delete ksvc <name> |
Istio 上的会话亲和:Passthrough 与 WASM
Serving 的热路径不保证「下一次还打到同一个 Pod」。有状态服务、MCP 长连接、多轮对话,要绑死某个后端,得在 Activator 的 Envoy 上动手。
每个 Knative Service 会多出 public / private 等 Kubernetes Service,再叠加自动伸缩,Endpoint 抖动很勤。istiod 默认把全集群 Service / Endpoint / VirtualService 塞进每个 Sidecar,Activator 上的 Envoy 内存会先涨到几十到几百 MB。
Passthrough 能把这份配置从 MB 打到 KB:Envoy 不再查 Cluster 的 Endpoint 列表,直接转到原始目标 IP。代价是 stateful_session 失效——它解析 Cookie 后写下目标 Pod,Passthrough 不理这个地址。会话保持和配置裁剪,默认配置下互斥。
补法是让 Passthrough 去信 x-envoy-original-dst-host。Cookie 里记下 pod-ip:port,WASM 把它写成这个 Header。
| 组件 | 职责 |
|---|---|
| Sidecar | 限制 Activator Envoy 的 egress,配置体积从 MB 降到 KB |
EnvoyFilter stateful_session |
首次响应里写 Cookie,编码这次选中的 Pod |
EnvoyFilter use_http_header |
让 PassthroughCluster 读 Header 选目标 |
| WASM Plugin | 从 Cookie 取出地址,写成 x-envoy-original-dst-host |
config-network |
mesh-compatibility-mode: disabled,打开 Passthrough |
config-autoscaler |
target-burst-capacity: "-1",请求必须经过 Activator |
Activator Pod
└─ istio-proxy
├─ WASM 有 Cookie → 写 x-envoy-original-dst-host
├─ stateful_session 无 Cookie → 负载均衡选 Pod,响应里种 Cookie
└─ PassthroughCluster use_http_header: true → 转到 Header 里的 Pod
首次请求:WASM 无事可做 → filter 选 Pod → 响应带 Cookie。后续请求:WASM 解码 Cookie → 写 Header → Passthrough 钉死那个 Pod。目标挂了,filter 会另选并换 Cookie。
Activator 要开 Sidecar 注入:
kubectl patch deployment activator -n knative-serving \
-p '{"spec":{"template":{"metadata":{"labels":{"sidecar.istio.io/inject":"true"}}}}}'
Placeholder Service 给 Sidecar egress 一个最小出口,保证 Activator 还能出网:
apiVersion: v1
kind: Service
metadata:
name: placeholder
namespace: default
spec:
type: ClusterIP
sessionAffinity: None
ports:
- name: http
port: 80
targetPort: 8012
- name: https
port: 443
targetPort: 8112
- name: http-autometric
port: 9090
targetPort: 9090
- name: http-usermetric
port: 9091
targetPort: 9091
- name: http-queueadm
port: 8022
targetPort: 8022
- name: http-istio
port: 8012
targetPort: 8012
apiVersion: networking.istio.io/v1
kind: Sidecar
metadata:
name: activator
namespace: knative-serving
spec:
egress:
- hosts:
- default/placeholder.default.svc.cluster.local
- istio-system/*
- kube-system/*
- default/kubernetes.default.svc.cluster.local
只下发这几条之后,istiod 不再把全集群 Service 塞进 Activator。还要访问别的服务,把 host 加进这份列表。
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: activator-session-affinity
namespace: knative-serving
spec:
workloadSelector:
labels:
app: activator
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_OUTBOUND
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.stateful_session
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession
session_state:
name: envoy.http.stateful_session.cookie
typed_config:
"@type": type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState
cookie:
name: session-affinity-cookie
ttl: 259200s
- applyTo: CLUSTER
match:
context: SIDECAR_OUTBOUND
cluster:
name: PassthroughCluster
patch:
operation: MERGE
value:
original_dst_lb_config:
use_http_header: true
apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
name: session-affinity-original-dest
namespace: knative-serving
spec:
selector:
matchLabels:
app: activator
url: registry-cn-beijing.ack.aliyuncs.com/acs/asm-wasm-original-dest:v0.1.0-g69a6c0.aliyun
phase: AUTHN
match:
- mode: CLIENT
pluginConfig:
cookie: "session-affinity-cookie"
failStrategy: FAIL_OPEN
Knative 两侧各改一处。mesh-compatibility-mode: disabled 打开 Passthrough;target-burst-capacity: "-1" 让请求不要抄近路绕过 Activator——否则 Cookie 和 WASM 都看不见流量,亲和从根上不成立。
# config-network
data:
mesh-compatibility-mode: "disabled"
# config-autoscaler
data:
target-burst-capacity: "-1"
Cookie 的 ttl 按会话长度改。WASM 镜像要是集群拉得到的地址。failStrategy: FAIL_OPEN 表示插件挂了先放行,会话丢了,请求还在。
标准路径依赖 Cluster 里的 Endpoint 列表:
目标 service-a.default.svc.cluster.local
→ Cluster outbound|80||service-a...
→ 从 Endpoint 里挑一个 Pod
Passthrough 不查表,用原始目的 IP。use_http_header: true 之后,优先信 x-envoy-original-dst-host。Cookie 里是 Base64 的 pod-ip:port;WASM 解码后写入这个 Header。Envoy 于是不再需要那份会过期的 Endpoint 清单,也能回到同一次会话选中的 Pod。
kubectl get pods -n knative-serving -l app=activator \
-o jsonpath='{range .items[*]}{.metadata.name}: {.spec.containers[*].name}{"\n"}{end}'
kubectl get sidecar,envoyfilter,wasmplugin -n knative-serving
curl -c cookies.txt -H "Host: your-service.default.example.com" http://<gateway-ip>
curl -b cookies.txt -H "Host: your-service.default.example.com" http://<gateway-ip>
POD=$(kubectl get pods -n knative-serving -l app=activator -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n knative-serving "$POD" -c istio-proxy -- \
pilot-agent request GET config_dump | wc -c
Activator Pod 里应同时有 activator 和 istio-proxy。多次带 Cookie 的请求应落到同一后端。config_dump 体积应比裁剪前小一个数量级。
相对稳的是三件事:Service 是唯一入口对象;Revision 不可变,流量用 percent 和 tag 切;Activator 用 goroutine 把请求留在内存里,Queue-Proxy 是 Pod 的唯一入口。字段会改,这三条不该改。