009、容器编排实战:Kubernetes上的Python服务
一、从一次深夜告警说起
上周三凌晨两点,手机突然狂震。监控显示线上某个Python服务的P99延迟飙到了5秒,但CPU和内存曲线却平静得像条直线。登录集群一看,Pod状态全是Running,日志里连个Error都没有。直觉告诉我,这肯定不是代码问题------服务在本地和测试环境跑得飞快。
用kubectl describe pod看了一眼,发现所有Pod的Ready状态都在反复横跳,Readiness探针时不时失败。问题就出在这里:我们的探针配置用的是HTTP GET /health,而那个健康检查接口里不小心调用了数据库查询。当晚数据库某个从库网络波动,导致健康检查偶尔超时,K8s认为Pod"不健康",就把流量切走了。剩下的Pod压力激增,队列堆积,延迟自然就上去了。
这件事让我重新审视在K8s上跑Python服务的细节------容器编排不是把镜像扔进去就能自愈的,里面全是魔鬼。
二、Python镜像:别从latest开始
很多人喜欢偷懒,Dockerfile第一行就写FROM python:latest。这玩意儿在线上就是个定时炸弹。
dockerfile
# 坏例子:别这样写
FROM python:latest
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
问题在于:
- latest标签今天可能是3.12,明天就变3.13,版本突变可能直接搞崩你的依赖
- 基础镜像太大,默认的python镜像包含一堆你用不着的工具,上传下载慢,安全漏洞还多
建议用具体版本号,并且选slim版本:
dockerfile
# 靠谱写法:锁死版本,用alpine或slim
FROM python:3.11-slim
# 系统依赖单独装,避免apt update和pip冲突
RUN apt-get update && apt-get install -y \
gcc libpq-dev --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# 先单独拷贝依赖文件,利用Docker缓存层
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 再拷贝代码
COPY . .
# 非root用户运行,安全第一
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]
这里踩过坑:alpine镜像虽然小,但有些Python的C扩展编译需要musl库,可能会遇到奇怪的兼容性问题。生产环境我更喜欢用debian-slim,平衡体积和兼容性。
三、Deployment配置:那些容易忽略的参数
写Deployment YAML的时候,很多人直接抄模板,这几个参数特别容易掉坑:
yaml
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # 保证至少有一个Pod在服务,避免中断
template:
spec:
containers:
- name: api
image: your-python-app:v1.2.3
ports:
- containerPort: 8000
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30 # 给应用启动留足时间
periodSeconds: 10
timeoutSeconds: 3 # 别设太长,默认1秒就行
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # 给正在处理的请求留出退出时间
重点说下探针配置:
- livenessProbe:失败会重启Pod。检查逻辑要轻量,千万别在里面调数据库或外部API,否则网络抖动一下你的Pod就重启狂欢了
- readinessProbe:失败只是把Pod从Service的Endpoint里摘掉。这里可以检查依赖状态(比如数据库连接池是否就绪)
- 两个探针的path最好分开,
/health做存活检查(只返回200),/ready做就绪检查(检查依赖项)
四、Python应用的特殊处理
4.1 Gunicorn worker数量
很多人直接写CMD ["gunicorn", "-w", "4", ...],worker数写死。但在K8s里,Pod的CPU limit可能随时调整。
yaml
# 在Deployment的env里动态计算
env:
- name: WORKERS_PER_CORE
value: "2"
- name: MAX_WORKERS
value: "8"
- name: WEB_CONCURRENCY
value: "1" # 默认值,会被下面的启动脚本覆盖
然后在Dockerfile的启动脚本里:
bash
#!/bin/bash
# 根据CPU limit计算worker数
cores=$(nproc)
workers=$((cores * WORKERS_PER_CORE))
workers=$((workers > MAX_WORKERS ? MAX_WORKERS : workers))
workers=$((workers < 1 ? 1 : workers))
exec gunicorn app:app -w $workers -b 0.0.0.0:8000
4.2 优雅关闭
Python服务收到SIGTERM后,Gunicorn默认会等所有worker处理完当前请求,但K8s的terminationGracePeriodSeconds默认只有30秒。如果有些长请求超时,Pod会被强制杀掉。
python
# 在Flask/FastAPI里加个优雅关闭钩子
import signal
from app import app
def handle_shutdown(signum, frame):
# 标记服务不可用
app.config['SHUTDOWN'] = True
# 这里可以加个等待逻辑,比如等10秒
time.sleep(10)
signal.signal(signal.SIGTERM, handle_shutdown)
同时把Deployment的terminationGracePeriodSeconds调到60秒以上。
五、ConfigMap和Secret管理配置
别把配置写死在代码里,也别打进镜像。Python应用的环境变量读取有个细节:
python
# config.py
import os
from dotenv import load_dotenv
load_dotenv() # 本地开发用.env文件
class Config:
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
# 敏感信息用Secret
DB_PASSWORD = os.environ["DB_PASSWORD"] # 故意不设默认值,让它在缺失时直接报错
K8s里这样挂载:
yaml
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: app-config
key: db-host
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
如果配置项很多,也可以整个文件挂载:
yaml
volumes:
- name: config-volume
configMap:
name: app-config
containers:
- volumeMounts:
- mountPath: /app/config
name: config-volume
六、本地调试技巧
在本地用kubectl debug其实很方便:
bash
# 如果Pod起不来,进容器看看
kubectl run -it --rm debug-pod \
--image=busybox \
--restart=Never \
-- sh
# 或者直接附加到已有Pod(需要EphemeralContainers特性)
kubectl debug pod/myapp-xxx -it --image=python:3.11-slim
# 端口转发到本地
kubectl port-forward pod/myapp-xxx 8000:8000
但更推荐用telepresence,能把本地进程"嫁接"到K8s集群里,直接使用集群内的Service和ConfigMap。
七、监控和日志
Python服务在K8s里打日志要注意:
- 别写文件,直接打到stdout/stderr,让Docker收集
- 日志里带上request_id,方便追踪链路
- 用json格式输出,方便ELK解析
python
import json
import logging
class JsonFormatter(logging.Formatter):
def format(self, record):
log_record = {
"time": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"request_id": getattr(record, 'request_id', 'none')
}
return json.dumps(log_record)
# 在Flask里用
@app.before_request
def set_request_id():
g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
在Deployment里加上sidecar收集日志也行,但大多数情况下用DaemonSet模式的Fluentd/Filebeat更省资源。
八、个人经验包
-
镜像标签别用latest:生产环境一定要用具体版本号,并且做好版本回滚预案。我习惯用git commit短哈希做标签,一目了然。
-
资源限制一定要设:不设limits的Pod就是"噪音邻居",可能吃光节点资源。requests可以设低点,limits要留足余量。Python服务内存尤其要关注,因为Python进程自己不太会主动释放内存给系统。
-
探针超时设短点:默认1秒够用了,设太长会拖慢故障发现。但initialDelaySeconds要给足,特别是Python冷启动加载模型或连接池的时候。
-
本地要有minikube或kind环境:别直接在线上集群试配置。我本地常备一个kind集群,YAML改完先在这里跑一遍。
-
Python依赖锁死版本 :requirements.txt里别出现
flask>=2.0.0这种范围依赖,不同时间构建的镜像可能装到不同版本,导致线上行为不一致。 -
关注文件描述符限制 :Python的Gunicorn+gevent模式可能开大量连接,默认的1024不够用。在Dockerfile里加一句
RUN ulimit -n 65535其实没用(容器启动时会重置),要在Pod的securityContext里设:yamlsecurityContext: sysctls: - name: fs.file-max value: "65535" -
别迷信HPA:自动扩缩容听起来美好,但Python服务启动慢(特别是要加载机器学习模型时),等Pod起来流量高峰可能都过去了。有时候预先多部署几个副本反而更稳。
在K8s上跑Python服务,更像是一门平衡艺术------既要利用容器编排的弹性,又要照顾Python生态的脾气。配置项多试几次,监控多看几天,慢慢就能摸清你那个服务的"性格"了。记住,没有放之四海而皆准的模板,只有适合你业务场景的配置。