返回归档
🛻Kubernetes

Knative:Service、Revision 与 Scale-to-Zero

Knative 把 Deployment、Service、Ingress、Autoscaler 收成一个 CRD。一次 apply 换来 Revision、路由和缩容到零;叠 Istio 时再用 Passthrough 和 WASM 把会话钉在同一个 Pod 上。

文章目录

Knative Serving 解决的不是「再写一套调度器」,而是把 HTTP 工作负载在 Kubernetes 上收成一个对象。一次 Service apply,换来 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-servingnet-istio-controller 听 KIngress,写出 VirtualService;Istio 自己再把抽象路由编成数据面配置。Route 决定「这个 Host 的多少流量、哪个 tag 打到哪个 Revision」;SKS 决定「此刻有没有 Ready Pod,没有就指到 Activator」。

Service 创建后落下的资源,以及请求如何走进 Activator / Queue-Proxy

创建后真正要盯的,通常是这几个:

  • ksvc / configuration / revision / route:意图与版本
  • pa / sks:扩缩容与公私 Service 的切换
  • 对应的 Kubernetes deploymentendpoints:副本是否真的起来

控制面与数据面

Serving 组件:Webhook、Controller、Activator、Queue-Proxy、Autoscaler

组件 平面 不变量
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。

关系再收三句:

  1. Webhook 先于 Serving Controller。提交时就拦住坏配置。
  2. 零副本时,SKS 把 public Service 指到 Activator。有副本时默认可以直打 Queue-Proxy;target-burst-capacity 决定 Activator 还在不在路上。
  3. 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 里的唯一入口

业务容器不直接对外。流量、管理、指标拆在三个端口上,避免混用:

Queue-Proxy:8012 反代、8022 drain、9090 指标

端口 平面 职责
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 concurrencyrps
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 指向 latestRevisionpercent: 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 里应同时有 activatoristio-proxy。多次带 Cookie 的请求应落到同一后端。config_dump 体积应比裁剪前小一个数量级。

相对稳的是三件事:Service 是唯一入口对象;Revision 不可变,流量用 percent 和 tag 切;Activator 用 goroutine 把请求留在内存里,Queue-Proxy 是 Pod 的唯一入口。字段会改,这三条不该改。

Ref