本节把 TaskHub 的 15MB 镜像真正部署到集群里:一份 Deployment 管住副本与探针,一个 Service 提供稳定入口,一个 Ingress 把外部流量接进来。
上一章结束时,TaskHub 已经是一个合格的生产镜像。但镜像本身不会跑——它需要一个编排系统来回答这些问题:跑几个副本、副本挂了怎么办、流量怎么分发、滚动更新怎么不丢请求。Kubernetes 就是干这个的。
必须提前说明:本机没有 Kubernetes 环境。
kubectl、kind、helm、minikube全部未安装,也没有可连的集群。因此本章所有清单都未经kubectl apply验证,也没有真实 Pod 的运行数据。凡是能脱离集群验证的部分(探针端点的状态码、优雅停机的行为、ConfigMap/Secret 的挂载语义),我都用docker run在本机实测并给出真实输出;凡是必须在集群里才能验证的(调度、HPA 扩缩容、滚动更新),会加粗标注「本机未实测」。
15.1.1 一个最小可用部署:Deployment + Service
先看 TaskHub 的完整最小清单。一份 Deployment 加一个 Service,够跑起来:
apiVersion: apps/v1
kind: Deployment
metadata:
name: taskhub-api
namespace: taskhub
labels:
app.kubernetes.io/name: taskhub-api
app.kubernetes.io/version: "1.0.0"
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: taskhub-api
template:
metadata:
labels:
app.kubernetes.io/name: taskhub-api
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
containers:
- name: api
image: registry.example.com/taskhub:1.0.0
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: taskhub-config
env:
- name: TASKHUB_DB_PASSWORD
valueFrom:
secretKeyRef:
name: taskhub-secrets
key: db-password
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz/live
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
startupProbe:
httpGet:
path: /healthz/live
port: http
periodSeconds: 2
failureThreshold: 30
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
配套的 Service:
apiVersion: v1
kind: Service
metadata:
name: taskhub-api
namespace: taskhub
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: taskhub-api
ports:
- name: http
port: 80
targetPort: http
这份清单不算长,但几乎每个字段都有讲究。下面逐块拆开。
15.1.2 Deployment 关键字段解释
| 字段 | 值 | 为什么 |
|---|---|---|
replicas | 3 | 至少 2 才有高可用;3 能容忍 1 个节点故障仍保持多数 |
selector.matchLabels | app.kubernetes.io/name | 不可变,改了要重建 Deployment |
template.labels | 同上 | 必须与 selector 匹配,否则 API Server 拒绝 |
runAsNonRoot | true | 镜像 USER 是 root 时拒绝启动(第 14 章) |
runAsUser | 65532 | distroless nonroot 的 uid |
readOnlyRootFilesystem | true | 根文件系统只读,攻击者无法落盘 |
capabilities.drop | ["ALL"] | 丢掉全部 Linux capabilities |
resources.requests | cpu 100m / mem 128Mi | 调度依据,决定 Pod 落在哪个节点 |
resources.limits | cpu 1 / mem 512Mi | 硬上限,超内存 OOMKilled、超 CPU 被限流 |
三个最容易踩的点:
一、selector 与 template.labels 必须匹配。 这不是「建议」,是 API Server 的硬校验——不匹配直接报错。而且 selector 创建后不可修改(Deployment 的 selector 是 immutable 字段),要改只能删了重建。
二、requests 和 limits 的差值是 QoS 等级的关键。 Kubernetes 按两者关系给 Pod 分三档:
| QoS 等级 | 条件 | 被驱逐优先级 |
|---|---|---|
| Guaranteed | 每个容器 requests == limits | 最低(最不容易被驱逐) |
| Burstable | 设了 requests,但有 limit 更大或未设 | 中 |
| BestEffort | 完全没设 resources | 最高(最先被驱逐) |
TaskHub 设成 requests < limits,是 Burstable——这是大多数 Web 服务的合理选择:正常负载下按 requests 调度,突发时允许用满 limits。
三、port 的命名(name: http)不是装饰。 探针里 port: http 引用的是这个名字,Service 的 targetPort: http 也引用它。用命名端口而不是数字,能让「改端口」只改一处——这是清单可维护性的细节。
15.1.3 三类探针:语义完全不同
Kubernetes 有三种探针,搞混它们是最常见的生产事故来源:
| 探针 | 回答的问题 | 失败后果 | 检查什么 |
|---|---|---|---|
livenessProbe | 进程还活着吗? | 重启容器 | 只查进程自身,绝不查外部依赖 |
readinessProbe | 现在能接流量吗? | 摘掉流量(不重启) | 查数据库/缓存等依赖 |
startupProbe | 启动完成了吗? | 重启容器 | 启动期间用,成功后交给 liveness |
最致命的错误是把外部依赖写进 livenessProbe。 设想 TaskHub 的 liveness 去 ping 数据库:数据库抖动 5 秒 → 三个副本的 liveness 同时失败 → Kubernetes 把所有副本一起重启 → 重启期间数据库压力更大 → 雪崩。正确做法是 liveness 只检查进程自身(能响应 HTTP 就说明进程没死),依赖状态交给 readiness。
startupProbe 是给「冷启动慢」的服务准备的。它一旦成功,liveness 才开始计时。上面的配置给了 periodSeconds: 2 × failureThreshold: 30 = 60 秒 的启动预算——TaskHub 如果加载模型或建大连接池超过 60 秒,就要调大这个值。
15.1.4 探针参数怎么调
livenessProbe:
httpGet: { path: /healthz/live, port: http }
initialDelaySeconds: 3 # 容器启动后等 3s 再开始探测
periodSeconds: 10 # 每 10s 探一次
timeoutSeconds: 2 # 单次探测超时 2s
failureThreshold: 3 # 连续失败 3 次才判失败(3×10s ≈ 30s 才重启)
这些参数共同决定「多久才判死」:failureThreshold × periodSeconds。上面是 30 秒——意味着服务真的要死透 30 秒才被重启。太短会误杀(GC 停顿、瞬时抖动),太长则故障恢复慢。
| 服务类型 | periodSeconds | failureThreshold | 判死时间 |
|---|---|---|---|
| 无状态 API | 10 | 3 | 30s |
| 有状态服务 | 10 | 5 | 50s |
| 后台任务 | 30 | 3 | 90s |
有 startupProbe 之后,liveness 的 initialDelaySeconds 可以设得很小(甚至 0),因为启动阶段已经被 startupProbe 接管了。这是「用 startupProbe 替代大 initialDelaySeconds」的价值。
15.1.5 实测:探针端点真的返回正确状态码
清单没法 apply,但探针打的那个 HTTP 端点可以真实验证。我把 TaskHub 的探针逻辑跑在容器里,用 curl 从容器外打进去:
$ docker run -d --name th15 -p 28100:8080 taskhub15:1
$ curl -s -o /dev/null -w 'live=%{http_code} ' http://127.0.0.1:28100/healthz/live
$ curl -s -o /dev/null -w 'ready=%{http_code}\n' http://127.0.0.1:28100/healthz/ready
live=200 ready=200
正常运行状态下两个探针都返回 200,livenessProbe 和 readinessProbe 都会通过。
更关键的是排空期间的行为。收到 SIGTERM 后,服务把 readiness 立刻置假、liveness 保持正常,我每 0.4 秒轮询一次:
t≈0.6s ready=503 live=200
t≈1.0s ready=503 live=200
t≈1.4s ready=503 live=200
t≈2.2s ready=000 live=000
这正是我们想要的语义:ready=503 让 Kubernetes 把流量从这个实例摘掉(readinessProbe 失败 → 从 Service 的 Endpoints 里移除),而 live=200 保证容器不会被重启。等 2 秒排空窗口过去,srv.Shutdown() 开始,连接被关闭(000 表示连接失败),进程干净退出。
这段行为在本机用 docker stop(发 SIGTERM)完整复现了,所以虽然清单没 apply,但「探针 + 优雅停机」这套语义是经过真实验证的,不是纸上谈兵。
15.1.6 Service 与 Ingress:入口的两层
Service 解决「Pod IP 会变」的问题。Pod 重建后 IP 变了,但 Service 的 ClusterIP 和 DNS 名(taskhub-api.taskhub.svc.cluster.local)不变,由 kube-proxy 维护转发规则。几个关键点:
selector匹配 Pod 标签,所有匹配的、且 readiness 通过的 Pod 才会进入 Endpoints。这就是 readinessProbe 决定「谁接流量」的机制。ClusterIP只在集群内可达。要让集群外访问,需要NodePort、LoadBalancer或 Ingress。targetPort: http指向容器端口名,不是 Service 端口。
Ingress 是七层入口,负责路由、TLS 终止:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: taskhub-api
namespace: taskhub
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "2g"
spec:
ingressClassName: nginx
tls:
- hosts: ["api.taskhub.example.com"]
secretName: taskhub-tls
rules:
- host: api.taskhub.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: taskhub-api
port:
number: 80
proxy-body-size: "2g" 是必须的——Nginx Ingress 默认只允许 1MB 请求体,而 TaskHub 要传附件(第 13 章)。这个注解不改,所有大附件上传都会收到 413 Request Entity Too Large,而且错误来自 Ingress 而不是应用,排查起来很绕。
15.1.7 清单常见错误
| 错误 | 现象 | 修正 |
|---|---|---|
selector 与 template.labels 不匹配 | API Server 拒绝创建 | 两处用同一组标签 |
探针 port 写成容器里没监听的端口 | 一直重启 | 用命名端口,与 containerPort 对齐 |
| liveness 查数据库 | 数据库抖动导致全量重启 | liveness 只查进程自身 |
忘了 startupProbe | 冷启动被误判、反复重启 | 给足启动预算 |
limits.memory 设太小 | 频繁 OOMKilled | 结合 GOMEMLIMIT 留余量(第 14 章) |
| Ingress 没放开 body 大小 | 大文件上传 413 | 加 proxy-body-size |
镜像 tag 用 latest | 滚动更新拉不到新镜像 | 用不可变 tag 或 digest |
最后一条要单独强调:永远不要在生产用 latest tag。Deployment 的滚动更新依赖镜像变化触发,latest 加上 imagePullPolicy: IfNotPresent 会导致「明明推了新镜像,Pod 却没更新」。用版本号或 commit hash(第 14 章的 --build-arg VERSION=$(git rev-parse --short HEAD))作为 tag。
小结
- 本机无 Kubernetes 集群,本章清单未经
kubectl apply验证,字段语义与取舍来自官方规范,端点行为用 docker 实测。 - Deployment 管副本与探针,Service 提供稳定入口,Ingress 做七层路由与 TLS。
selector与template.labels必须匹配,且selector创建后不可改。requests/limits决定 QoS 等级;requests < limits是 Burstable,适合 Web 服务。- 三类探针语义不同:liveness 失败重启、readiness 失败摘流量、startup 管启动期。
- liveness 绝不能查外部依赖,否则数据库抖动会引发全量重启雪崩。
- 实测探针端点:正常
live=200 ready=200;排空期间ready=503 live=200,语义完全符合预期。 - Ingress 默认 1MB 请求体上限,大附件场景必须调
proxy-body-size。 - 生产禁用
latesttag,用不可变 tag 或 digest。
清单跑起来了,但配置和密钥还写死在镜像和 YAML 里——数据库地址换了要改清单,密码明文躺在 Git 里。下一节我们用 ConfigMap 管配置、Secret 管密钥,并加上 HPA 让副本数跟着流量自动伸缩。
阅读导航:上一节:14.3 非 root、健康检查与资源限制 · 下一节:15.2 ConfigMap/Secret 与 HPA 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。