Readiness 与 liveness 回答不同的运维问题。就绪探针判断当前容器是否应该参与一个能够接收普通 Service 流量的 Pod,存活探针则判断是否应该执行预先配置的容器重启恢复。两者都可以使用 HTTP,但协议相同不代表语义相同。应先确定希望平台采取什么动作,再设计触发该动作的条件,否则一个健康接口可能同时承担互相冲突的责任。
同时观察响应和平台动作
就绪探针失败会让容器变成未就绪,并影响 Pod 的 Ready 条件。对于普通带选择器的 Service,端点协调随后改变哪些后端可以参加正常路由。Pod 可以保持 Running,同时处于未就绪状态,应用进程也可能继续运行。重启次数、Pod 标识和 Ready 条件回答的是不同问题,排查时不能只看其中一个状态就推导全部行为。
存活探针连续失败达到配置阈值后,kubelet 会终止容器并按相应重启规则处理。普通 Always 策略应用 Pod 会在原 Pod 中获得替换容器,而不是必然创建一个新 Pod 对象,也不是发布另一版应用。应在比较 Pod 身份的同时查看容器重启次数与终止状态,避免把一次进程恢复误报为完整工作负载替换。
检查应便宜,而且成功条件要具有实际含义。TCP 连接成功只证明监听端能够接受连接,不证明应用能够完成有用工作。HTTP 可以表达更丰富的问题,但如果它递归调用昂贵的依赖检查,也可能成为额外负载来源。探针自己的执行成本应符合工作负载容量和超时预算,不能让健康检查本身把健康服务压慢。
坏掉的样子
把共享数据库故障当成存活失败,可能导致所有正常应用进程不断重启,却完全无法修复数据库。把同一个依赖直接加入就绪检查也需要判断:撤走全部副本可能同时撤掉本来有价值的降级响应,在局部故障中还可能把流量集中到剩余副本。应依据服务实际契约定义就绪,包括已经明确支持的部分可用行为,而不是照抄统一依赖清单。
就绪状态不是网络访问控制。即使某个 Pod 不再属于 Service 的就绪目标,它的地址仍可能被直接访问。既有连接、其他路由层,以及 publishNotReadyAddresses 等 Service 设置,也需要单独分析。因此,不能把 readiness 失败描述成一个瞬时保证,认为从此没有任何请求能够到达进程。它改变的是对应服务发现和路由流程中的资格。
阈值和时序决定敏感程度。超时过短可能把 CPU 争用误认成故障,要求连续多次失败则可以避免对一次短暂延迟立即反应。启动保护是另一回事:配置了 startup 探针时,在它成功之前不会执行就绪与存活探针。这些参数定义采样和恢复策略,并不证明终止过程中不会丢失请求;可靠停机仍需应用参与。
快速验证
准备可丢弃的 Kubernetes 集群,确保 kubectl 可用,节点能够使用 python:3.11-slim-bookworm 镜像。保存为 probe-actions.py,执行 python3 probe-actions.py KUBECONFIG_PATH CONTEXT,明确传入测试集群参数。账号需要创建、删除命名空间。原文记录的服务端为 Kubernetes 1.34.11,本轮没有重新执行集群实验;脚本创建一个 HTTP Pod 和采用默认端点就绪行为的普通 Service。
import json
import subprocess
import sys
import time
from uuid import uuid4
assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'
def k(*args, payload=None, expected=0):
result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
capture_output=True, text=True, timeout=150)
if expected is not None:
assert result.returncode == expected, result.stderr
return result
def obj(namespace, kind, name=None, selector=None):
args = ['get', kind, '-n', namespace, '-o', 'json']
if name:
args.append(name)
if selector:
args += ['-l', selector]
return json.loads(k(*args).stdout)
def apply(namespace, data):
return k('apply', '-n', namespace, '-f', '-', payload=data)
def wait_for(read, ready, seconds=120):
until = time.monotonic() + seconds
while time.monotonic() < until:
value = read()
if ready(value):
return value
time.sleep(0.5)
raise AssertionError('expected Kubernetes condition did not arrive')
def pod_ready(pod):
return any(c['type'] == 'Ready' and c['status'] == 'True'
for c in pod.get('status', {}).get('conditions', []))
def spec(script='import time; time.sleep(600)'):
return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
'command': ['python3', '-c', script],
'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}
namespace = 'notes-probes-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
server = '''from http.server import BaseHTTPRequestHandler,HTTPServer
from pathlib import Path
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
flag={'/live':'/tmp/live-fail','/ready':'/tmp/ready-fail'}.get(self.path)
code=503 if flag and Path(flag).exists() else 200
body=b'worker'; self.send_response(code); self.send_header('Content-Length',str(len(body)))
self.end_headers(); self.wfile.write(body)
def log_message(self,*args): pass
HTTPServer(('0.0.0.0',8080),Handler).serve_forever()
'''
podspec = spec(server)
c = podspec['containers'][0]
c['readinessProbe'] = {'httpGet': {'path': '/ready', 'port': 8080}, 'periodSeconds': 1, 'failureThreshold': 2}
c['livenessProbe'] = {'httpGet': {'path': '/live', 'port': 8080}, 'periodSeconds': 1, 'failureThreshold': 3}
apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'worker', 'labels': {'app': 'worker'}}, 'spec': podspec})
apply(namespace, {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'web'},
'spec': {'selector': {'app': 'worker'}, 'ports': [{'port': 80, 'targetPort': 8080}]}})
def targets():
return {e['targetRef']['uid'] for s in obj(namespace, 'endpointslices', selector='kubernetes.io/service-name=web')['items']
for e in s.get('endpoints', []) if e.get('conditions', {}).get('ready') and e.get('targetRef')}
initial = wait_for(lambda: obj(namespace, 'pod', 'worker'), pod_ready)
uid = initial['metadata']['uid']
wait_for(targets, lambda value: value == {uid})
def change(path, create):
method = 'touch' if create else 'unlink'
k('exec', '-n', namespace, 'worker', '--', 'python3', '-c',
'from pathlib import Path; Path('+repr(path)+').'+method+'()')
change('/tmp/ready-fail', True)
wait_for(lambda: obj(namespace, 'pod', 'worker'), lambda p: not pod_ready(p))
wait_for(targets, lambda value: not value)
time.sleep(4)
unready = obj(namespace, 'pod', 'worker')
assert unready['metadata']['uid'] == uid and unready['status']['containerStatuses'][0]['restartCount'] == 0
address = 'http://' + unready['status']['podIP'] + ':8080/api'
probe = 'from urllib.request import urlopen; print(urlopen('+repr(address)+',timeout=3).read().decode())'
assert k('exec', '-n', namespace, 'worker', '--', 'python3', '-c', probe).stdout.strip() == 'worker'
change('/tmp/ready-fail', False)
wait_for(targets, lambda value: value == {uid})
change('/tmp/live-fail', True)
restarted = wait_for(lambda: obj(namespace, 'pod', 'worker'), lambda p:
pod_ready(p) and p['status']['containerStatuses'][0]['restartCount'] >= 1)
assert restarted['metadata']['uid'] == uid
assert restarted['status']['containerStatuses'][0]['restartCount'] == 1
wait_for(targets, lambda value: value == {uid})
print(json.dumps({'readiness_removed_ready_target_without_restart': True,
'unready_pod_direct_http_still_succeeds': True,
'readiness_recovery_restores_target': True,
'liveness_container_restarts': 1, 'same_pod_uid': True}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
起初 Pod 已就绪,它的标识出现在 Service 的 EndpointSlice 就绪目标中。创建本地标记文件后,只有 readiness 路径返回 503。达到失败阈值并完成协调后,就绪目标集合为空。再过四秒,Pod 标识仍相同,容器重启次数仍为零。这同时验证了路由资格变化与进程没有重启,而不是仅检查接口返回码。
在同一个 Pod 内,向它自己的未就绪 Pod 地址直接发送 HTTP 请求,仍能得到 worker。这没有测试其他 Pod 或外部客户端的可达性。删除就绪失败标记,目标资格便恢复。接着创建另一个标记,让 liveness 路径失败,kubelet 实际重启容器一次。新容器的可写层不再包含该标记,Service 就绪目标随之恢复。整个过程中 Pod 的标识没有变化,显示重启发生在容器层面。
原文报告全部断言通过,命名空间随后删除;这里保留的是历史执行归属,不表示本轮独立重现。实验展示了不同平台动作与直接可达性,没有测试已有连接的优雅排空,也没有证明生产可用性。真实发布还需要共同验证端点传播、应用停机、未完成工作和客户端行为。健康探针返回成功,只能说明该探针检查的条件成立,不能代替整个请求生命周期的交付证据。
来源
- Kubernetes: Configure Probes,失败阈值以及就绪和存活探针的不同动作,已核验 2026-10-15。
- Kubernetes: Pod Lifecycle,容器重启与 Pod 就绪状态,已核验 2026-10-15。
- Kubernetes: EndpointSlices,后端就绪条件及服务发现行为,已核验 2026-10-15。