前两篇把 Flask 应用写出来、用 Docker 跑起来,算是把"能运行"这件事解决了。但真实环境里,光能运行远远不够——进程崩了要自动拉起,流量涨了要自动扩容,发新版要不断服,这些靠 docker run 和手动维护是撑不住的。Kubernetes 这套系统就是用来干这个的。这篇作为系列第三篇,我不打算把 K8s 的概念从头讲一遍,而是直接带你把 Flask 应用完整地部署进集群,再把自动化管理那套东西逐一配好。你会看到一个真实的部署链路:镜像怎么构建上传、Deployment 和 Service 怎么写、自动伸缩怎么配、Ingress 怎么暴露域名,以及我实际踩过的几个坑。适合已经把 Flask 应用容器化、想正式上 K8s 的开发者,也适合刚接触集群、想知道"部署到底要走哪些步骤"的人。
1. 让集群愿意收下你的镜像:构建、标签与私有仓库规范
很多人在本地 docker run 一切正常,一上集群就报 ImagePullBackOff,十有八九问题出在镜像这一环。Kubernetes 本身不构建镜像,它只会从镜像仓库拉取。所以你得先保证镜像在某个仓库里,而且每个节点都能访问到这个仓库。
1.1 Flask 应用 Dockerfile 的几个关键细节
如果你的 Dockerfile 还是简单的"FROM python + pip install + flask run",那放到 K8s 里会出不少问题。先说个最常见的:不要用 root 用户跑应用。集群里的安全策略越来越严,很多环境会直接拒绝 root 启动的容器,即便放行,一旦容器被攻破,权限太大也是风险。稳妥的做法是创建一个非 root 用户,再指定用户运行。我给一个生产可用的 Flask Dockerfile 片段,你可以直接参考:
FROM python:3.11-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ TZ=Asia/Shanghai RUN groupadd -r app && useradd -r -g app app WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . USER app EXPOSE 5000 CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "app:app"]这里有两个很容易被忽略的点。第一是时区,很多 Flask 应用会生成日志或记录时间,如果基础镜像默认是 UTC,日志时间和本地时间对不上,排查问题的时候特别难受。设置TZ环境变量在 Debian 系镜像里是生效的,但如果你的基础镜像是精简版,可能还需要安装 tzdata 包,这个按基础镜像情况来定。第二是启动命令,生产环境千万别用 flask run,它自带的是开发服务器,性能和稳定性都不适合线上,我统一用 gunicorn 做 WSGI 服务器。Worker 数量按 CPU 核数估算,一般 2~4 个起步,后面再根据压测结果调整。
提示:如果应用里有静态文件,建议让 nginx 这类服务来处理,Flask 只负责 API 逻辑。gunicorn 对静态文件的支持很弱,没必要硬扛。
1.2 镜像标签与私有仓库推送
本地构建完镜像,下一步是打标签再推送。这里我强烈建议你弃用 latest 标签,至少在生产环境不要用。原因是:latest 是可变的,你没法通过这个标签准确知道线上跑的是哪个版本。更麻烦的是,K8s 的镜像拉取策略对 latest 和其他标签的处理不一样——latest 默认每次都会去仓库拉最新,而固定版本标签默认只在本地没有时才拉取。
我的习惯是每次构建都打两个标签,一个是大版本号,一个是版本号加 git 提交哈希,比如registry.example.com/flask-app:1.4.0和registry.example.com/flask-app:1.4.0-a3f9c2e。后者能精确对应到代码提交,回滚的时候一眼就能看出来当时部署的是什么代码。推送命令基本是这样:
docker build -t registry.example.com/flask-app:1.4.0-a3f9c2e . docker tag registry.example.com/flask-app:1.4.0-a3f9c2e registry.example.com/flask-app:1.4.0 docker push registry.example.com/flask-app:1.4.0-a3f9c2e docker push registry.example.com/flask-app:1.4.0如果你是自建的私有仓库,节点拉取镜像时需要凭证。这个凭证不能写在 YAML 里,而是通过创建 imagePullSecret 来完成,具体写法在第 5 章排坑部分会详细说明。还有一个值得留意的点:镜像仓库地址要用 IP 或域名,不要用 localhost。因为 K8s 的节点和你的构建机不是同一台机器,localhost 指向的是节点自己,不是你的仓库。
2. Deployment 与 Service:从"能跑"到"能管"的第一个 YAML
镜像准备好之后,我们要在集群里创建第一个工作负载。我建议你从 Deployment 开始,而不是直接上 StatefulSet 或者其他控制器。Flask 这类无状态应用,Deployment 是语义最匹配的:它负责维持指定数量的副本,支持滚动更新,也支持回滚。
2.1 Deployment 核心字段逐段拆解
这是一份我实际在用的 Deployment 配置,做了脱敏处理,你先看整体,我再逐段解释:
apiVersion: apps/v1 kind: Deployment metadata: name: flask-app namespace: production labels: app: flask-app spec: replicas: 2 selector: matchLabels: app: flask-app strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: flask-app spec: containers: - name: flask-app image: registry.example.com/flask-app:1.4.0 imagePullPolicy: IfNotPresent ports: - containerPort: 5000 env: - name: APP_ENV value: "production" resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi readinessProbe: httpGet: path: /healthz port: 5000 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 2 failureThreshold: 3 livenessProbe: httpGet: path: /healthz port: 5000 initialDelaySeconds: 10 periodSeconds: 15 timeoutSeconds: 2 failureThreshold: 3先说selector.matchLabels。这个字段很多人会漏,或者写得不一致。它的作用是把 Deployment 和它管理的 Pod 关联起来。template 里的 labels 必须包含 selector 里的所有键值对,否则 Deployment 会找不到自己的 Pod。比如这里的 selector 是app: flask-app,那 template 的 labels 里就必须有这个标签,否则控制器无法正确跟踪副本。
然后是strategy。我设置了maxSurge: 1和maxUnavailable: 0,意思是滚动更新时,最多先多创建 1 个新 Pod,同时不允许旧 Pod 减少。这样能保证整个发布过程服务不中断,但代价是发布期间需要额外的资源配额。如果你对资源把控没信心,可以把 maxUnavailable 设成 1,允许先停掉一个旧 Pod 再创建新 Pod,牺牲一点可用性换资源余量。默认值是两个都是 25%,对于小应用通常够用,但我更喜欢显式指定,避免默认值在不同版本间产生歧义。
2.2 探针:决定容器是"活着"还是"就绪"
这一节是 Flask 部署里最容易出问题的部分。很多人的探针配置就是复制粘贴,结果应用刚启动还没加载完就被探针判定失败,直接重启,陷入 CrashLoopBackOff。
这里有两个探针,职责完全不同:
- readinessProbe(就绪探针):判断 Pod 是否准备好接收流量。如果失败,Pod 不会从 Service 那里得到流量,但容器不会被重启。它适合处理"启动了但还在加载模型、连接数据库"这类场景。
- livenessProbe(存活探针):判断容器是否卡死。如果失败,kubelet 会杀掉容器并重启。它适合发现死锁、内存泄漏这类问题。
针对 Flask,我建议两个探针都指向同一个轻量接口/healthz,但不要指向业务接口。业务接口可能因为下游依赖故障而返回 500,这时候存活探针会把容器杀掉重启,其实根本没有解决问题,反而是掩盖了问题。健康检查接口就应该是简单的:
@app.route("/healthz") def healthz(): return {"status": "ok"}initialDelaySeconds要给足。如果你的应用启动需要 10 秒才能接受请求,那这个值至少要 15 秒。给太少的话,启动慢一点的 Pod 会被误杀。periodSeconds是探测频率,10~15 秒对于大多数应用足够了,不需要设成 1 秒,反而增加无谓的负载。
2.3 Service:为什么用 ClusterIP 而不是 NodePort
Deployment 创建之后,Pod 的 IP 是不固定的,随时可能因为重启、重建而变化。Service 的作用是提供一个稳定的访问入口。对于内部调用场景,我统一用 ClusterIP 类型。
apiVersion: v1 kind: Service metadata: name: flask-app namespace: production spec: type: ClusterIP selector: app: flask-app ports: - port: 80 targetPort: 5000这里有个容易混淆的地方:port是 Service 对外暴露的端口,targetPort是容器的端口。我这个配置的意思是,访问 Service 的 80 端口,流量会被转发到容器的 5000 端口。为什么不用 NodePort?因为 NodePort 会在每个节点上开一个高位端口,直接暴露在宿主机上,安全性和灵活性都比较差。如果服务要向集群外暴露,应该用后面要讲的 Ingress,这是更规范的路径。
注意:Service 的 selector 要和你 Pod 的 labels 匹配,否则 Endpoints 会一直是空的。排错时可以执行 kubectl get endpoints 查看,如果没有任何地址,基本就是标签没对上。
3. 自动化管理三件套:自动伸缩、配置外置与滚动发布
标题里说的"自动化管理",核心落在这一章。对于无状态的 Flask 应用,我觉得自动化主要体现在三件事上:按需伸缩、配置可动态调整、发布不中断且可回滚。
3.1 HPA:让副本数跟着压力走
HPA(Horizontal Pod Autoscaler)是 K8s 的自动伸缩机制。它通过监控 Pod 的 CPU、内存或自定义指标,自动调整 Deployment 的副本数。配置之前,有一个前置条件不能忽略:集群里必须已经运行了 metrics-server。没有它,HPA 拿不到 Pod 的资源使用数据,扩容永远不会触发。
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: flask-app-hpa namespace: production spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: flask-app minReplicas: 2 maxReplicas: 8 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60关于扩容阈值,我踩过几次坑之后总结出一个经验:不要把 averageUtilization 设得太低,比如 30% 甚至更低。因为单个 Pod 的 CPU 使用率本来就是波动的,阈值设太低会导致稍微一点流量上涨就触发扩容,而扩容后流量被分摊,CPU 降下来了,HPA 又开始缩容,形成抖动。60% 是一个相对稳妥的起点。如果你用 4 个 worker 的 gunicorn,Pod 的 CPU 利用率一般在多核之间分摊,60% 的阈值可以给突发流量留出一定缓冲。
验证 HPA 是否生效,最直接的办法是压测。我用过压测工具直接怼着 Service 的地址打流量,同时观察副本数变化。当 CPU 持续超过阈值几分钟后,副本数会逐步增加,整个过程大概是这样的:
kubectl get hpa flask-app-hpa输出里的 TARGETS 列会显示当前 CPU 实际值和目标值,比如78%/60%,而 REPLICAS 会从 2 慢慢涨上去。要注意的是,HPA 对扩容和缩容的处理速度不一样——扩容相对快,缩容则要等默认的 5 分钟冷却,这是故意设计的,防止抖动。
3.2 ConfigMap 与 Secret:把配置从镜像里抽出来
我不喜欢把配置写死在镜像里。同一个镜像要在测试、预发、生产三个环境跑,数据库地址、日志级别、告警开关全都不一样,所以配置必须外置。K8s 的解法是 ConfigMap 和 Secret。
ConfigMap 适合放非敏感配置,比如:
apiVersion: v1 kind: ConfigMap metadata: name: flask-config namespace: production data: FLASK_ENV: "production" LOG_LEVEL: "INFO" API_TIMEOUT: "15"Secret 专门放敏感信息,比如数据库密码、API key。网上很多教程把 Secret 说得神乎其神,实际它的默认存储也就是 base64 编码而已,并不是加密,读取 Secret 需要 RBAC 权限控制才能真正保障安全。但不管怎样,用 Secret 总比把密码明文写进镜像或 YAML 里强得多。
这两类配置在 Deployment 里怎么用?我推荐的做法是用环境变量引用:
envFrom: - configMapRef: name: flask-config - secretRef: name: flask-secret这样 Flask 应用里直接os.getenv("FLASK_ENV")就能拿到值。也可以把 ConfigMap 挂载成文件,适合应用要读取配置文件的情况。但挂载文件有一个隐藏坑:更新 ConfigMap 会自动更新挂载的文件,但不会重启 Pod,也不会让进程重新加载配置。如果你依赖这个机制做配置热更新,要确认你的应用是否监听文件变化。Flask 应用通常不会自动重新读取环境变量或配置文件,所以更稳妥的做法是改完配置后触发一次滚动更新,比如通过修改 Deployment 的一个 annotation 让 Pod 重建。
3.3 滚动更新与回滚:发布新版本的正确姿势
当你把镜像标签从 1.4.0 改成 1.5.0 并 apply 之后,Deployment 会按照前面配置的 RollingUpdate 策略启动新 Pod、停止旧 Pod。这个过程中,新 Pod 只有通过了 readinessProbe 之后才会被加入到 Service 的 Endpoints 里,所以不会出现流量打到还没就绪的 Pod 上。
这里我想重点强调一个观念:发布不是"更新镜像"这一个动作,而是一个可观测、可中断、可回滚的过程。所以在发布前,你应该先看一眼当前状态:
kubectl rollout status deployment/flask-app这个命令会阻塞在那里,直到发布完成或者超时。如果发布卡住,说明新 Pod 一直不就绪,此时不用干等,直接看原因:
kubectl get pods kubectl describe pod <pod-name>如果新 Pod 一直 Pending,大概率是资源不足;如果一直 CrashLoopBackOff,大概率是启动失败或探针没过。这时候第一反应不应该是继续调,而是先回滚到上一个稳定版本:
kubectl rollout undo deployment/flask-app回滚的粒度是"上一次发布的整个状态",包括镜像版本和所有模板变更。发布历史可以用kubectl rollout history deployment/flask-app查看。这个能力是 Docker 单机时代没有的,也是我强烈建议所有 Flask 应用都上 K8s 的核心原因之一——发布出问题可以秒级回滚。
4. 让用户能找到你的服务:Ingress、域名与 HTTPS 配置
ClusterIP 服务只能在集群内部访问,对外暴露还需要一层东西。这层的标准解法是 Ingress。Ingress 有点像集群里的"反向代理控制器",它负责把外部 HTTP/HTTPS 流量按域名和路径路由到对应的 Service。
4.1 Ingress Controller 的选型与部署
要澄清一个常见误区:Ingress 只是 API 对象,真正干活的是 Ingress Controller。你必须先装一个 Controller,Ingress 规则才会生效。社区里常用的有基于 nginx 的实现,也有 traefik 这种轻量一点的。对小团队来说,基于 nginx 的实现最稳,文档多,遇到问题也好搜。
Controller 装好之后,确认它的 Pod 正常运行,然后创建 Ingress 规则。我的配置通常是这样的:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: flask-app-ingress namespace: production spec: ingressClassName: nginx tls: - hosts: - api.example.com secretName: example-tls rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: flask-app port: number: 80这里有几个细节值得展开。第一,ingressClassName必须写,否则 Controller 不知道要处理这个 Ingress。第二,TLS 证书来自一个 Secret,里面是 PEM 格式的证书和私钥。证书可以手动创建,也可以用自动化工具申请。第三,pathType的语义要搞清楚:Prefix表示匹配该路径下的所有请求,Exact是精确匹配。对于 Flask 应用,通常用 Prefix 把整个域名的流量都导进去,因为 Flask 的路由自己会处理 /api 之类的路径。
4.2 会话保持:多副本下 Flask 的一个隐藏问题
当你有多个 Pod 副本时,Ingress 默认会把请求负载均衡到不同的 Pod。如果你的 Flask 应用用了默认的会话机制(即把 session 数据存到客户端 cookie 里),一般来说没问题,因为 cookie 里存的是签名的数据,任何一个 Pod 都能验证。但如果你用了服务端 session、或者应用里保存了进程内存状态,那就需要处理多副本下的一致性问题了。
我见过不少人在这里栽跟头:本地单副本跑得好好的,一上集群多个副本,用户一会儿能登录一会儿掉登录。根因往往是 session 数据存在了某个 Pod 的内存里,下一次请求被路由到另一个 Pod,自然就找不到 session 了。有两个解法:
- 配置 Ingress 的会话保持(sticky session / session affinity),让同一个客户端的请求始终路由到同一个 Pod。
- 更推荐的做法是让 Flask 应用变成彻底无状态,把 session 和状态存在数据库、缓存这类外部存储里。
第一种方案只是绕过问题,第二种方案才是正解。无状态化是你的 Flask 应用能在 K8s 里自如伸缩、随意迁移的前提,值得在这上面多花一点时间。
5. 上线排坑实录:三个最常见的翻车现场
最后这部分,我整理几个我在部署 Flask 到 K8s 时实际踩过、且反复有人问的坑。每个都按"现象、排查链路、根因、解法"来写,方便你对照。
5.1 坑一:ImagePullBackOff,镜像拉不下来
现象:Pod 状态一直是 ImagePullBackOff,Events 里出现 Failed to pull image 或者 manifest unknown。
排查链路:
kubectl describe pod <pod-name>看 Events 部分的具体报错。如果错误是"pull access denied"或者"unauthorized",基本可以确定是私有仓库的认证问题。
根因:节点上没有配置私有仓库的访问凭证。你在自己机器上 docker login 过,但节点没有你的登录状态。
解法:先创建 Secret,再在 Deployment 里引用它:
kubectl create secret docker-registry regcred \ --docker-server=registry.example.com \ --docker-username=<用户名> \ --docker-password=<密码> \ --docker-email=<邮箱> \ -n production在 Deployment 的 template.spec 里加上:
imagePullSecrets: - name: regcred还有一个容易被忽略的细节:如果你在本地手动 docker pull 测试过某个镜像,然后把 Deploy 的 imagePullPolicy 设成 IfNotPresent,K8s 可能会复用节点上已有的镜像,而不是从仓库拉取最新版本。如果你怀疑线上跑的不是最新代码,把 imagePullPolicy 改成 Always 再试,或者把镜像标签换一个新的。
5.2 坑二:Pod 一直重启,CrashLoopBackOff
现象:Pod 状态在 CrashLoopBackOff 和 Running 之间反复横跳。看日志一切正常,但过一会儿就被杀了。
排查链路:
kubectl describe pod <pod-name>拉到最后看 State 部分,如果显示 Last State: Terminated,Reason: OOMKilled,那就是内存超限。如果 Reason 是 Error 而日志又没有输出,多半是探针的问题。
根因:最常见的是 livenessProbe 配置得太激进。比如 initialDelaySeconds 设成 3 秒,但 Flask 应用启动需要 15 秒,探针在应用还没就绪时就发请求,失败次数超过 failureThreshold,kubelet 就杀容器。
解法:把 initialDelaySeconds 调大,确保覆盖应用启动时间。另外,如果应用启动时依赖数据库或其他外部服务,而这些服务还没就绪,探针也会失败。这种情况可以把外部依赖的等待逻辑做成重试,而不是让探针来迁就。
OOMKilled 的情况则是 resources.limits.memory 给得太小。Python 应用加 gunicorn 的内存占用比想象的大,特别是如果你的应用加载了较大的模型或数据。我建议先给 512Mi,压测后按实际占用上浮 30% 再定 limits。
5.3 坑三:滚动更新卡住,新 Pod 一直 Pending
现象:执行 kubectl rollout status 后一直没有输出,查看 Pod 发现新 Pod 一直是 Pending 状态。
排查链路:
kubectl describe pod <new-pod-name>Events 里如果出现 0/2 nodes are available,说明调度不了,节点资源不足以容纳新的 Pod。
根因:滚动更新策略设置了 maxSurge: 1,意味着要先创建一个新 Pod 才会停旧的。如果此时集群资源已经满了,新 Pod 无法被调度,更新就会卡住。
解法:三个层面解决。第一个是直接回滚,把版本恢复到上一版,让集群先恢复稳定。第二个是调整 maxSurge 为 0、maxUnavailable 为 1,让更新先停一个旧 Pod 腾出资源,再创建新 Pod。第三个是审视你的 resources.requests 和 limits——很多 Pod 的 requests 设得虚高,比如 CPU 直接给 1 核,导致节点上明明有空闲资源却因为 requests 不匹配而无法调度。requests 应该是你的应用实际需要的资源,不是你想给它多少就用多少。
5.4 关于资源配额的一个额外提醒
说到资源,有个概念必须区分清楚:requests 和 limits 完全不是一回事。requests 是调度依据,K8s 按它来决定 Pod 放在哪个节点上;limits 是运行时约束,超过之后容器可能被杀死。如果 Pod 的 CPU 使用超过 limits,会被限流;内存超过 limits,会被 OOMKilled 而不是只被限流。
很多人的误区是只写 limits 不写 requests。这种配置会导致调度器认为 Pod 不需要资源,把所有 Pod 都堆在一个节点上,然后运行时就因为 limits 互相争抢。另一个误区是 requests 和 limits 写同一个值,这样虽然合法,但 Pod 几乎没有弹性空间,稍微波动就会触发 OOM 或限流。我的经验是 requests 按基线负载设,limits 按峰值负载设,两者之间留 20%~50% 的余量。
6. 一些收尾的经验:这套配置我跑了一段时间后的体会
文章已经很长了,最后不多说什么框架性的东西,就分享两个我在实操中切实体会到的点。
第一个是关于调试效率的。刚开始用 K8s 部署 Flask 时,我最不习惯的是排错链路变长了:以前 docker logs 一下就能看到全部输出,现在要 kubectl get pods 找到 Pod,再 kubectl logs 拿日志,如果 Pod 被重启过,还得加 --previous 参数看之前的输出。这个摩擦感是很真实的。后来我直接把 Python 应用的日志输出到标准输出,集成了集群日志收集链路,让应用本身不关心日志存储,才真正从"容器编排"中解放出来。如果你还在起步阶段,建议早点把日志这件事想清楚,它会成为你运维 Flask 的最大效率杠杆。
第二个是关于"自动化管理"这套东西本身。HPA、探针、滚动更新,每一项单看都是 K8s 的一个功能点,但组合起来的价值是质变的——它让"应用的运行状态"变成了一个可以被声明、被观察、被控制的对象。Flask 应用本身很简单,但一旦你有了"把它作为一套系统来管理"的视角,部署的目标就不只是"让它跑起来",而是"让它稳定地、自动地、可预期地跑下去"。这个观念的转变,比任何具体的 YAML 配置都更有用。
这篇作为系列第三篇到这里就完整了。下一篇我可以顺着这个环境继续深挖:比如把 Flask 应用拆成微服务之后,服务间调用和链路追踪怎么做;或者讲讲怎么在集群里做数据库的部署与备份。有想看的可以在评论区告诉我,我会按大家的需求往下写。