Startup 探针为容器提供单独的初始化契约。在已经配置的启动探针成功之前,Kubernetes 不会执行这个容器的 liveness 或 readiness 探针。这比“宽限期内忽略一些存活失败”更具体:另外两种检查根本还没有开始。启动检查成功之后,后续健康和就绪判断才按各自规则接管,不能把三个名称理解成同一个接口的不同标签。
为每个阶段定义可观察条件
应用进程必须先运行,启动探针才可能成功。因此,容器已经 Running 时,container status 中的 started 仍可能为 false,所在 Pod 也可能尚未 Ready。运行状态描述进程正在执行,启动成功则描述配置的检查条件已经满足。提前开放的 HTTP 监听端可以报告进度,但不应该因此自动声称全部准备已经完成。
启动条件应代表必要的本地准备,例如加载索引或验证初始状态。只有打开端口确实就是所需前置条件时,单纯检查端口才足够。如果监听器先出现,昂贵的初始化稍后才结束,过早成功的检查就会提前解除存活保护,再次造成原本想避免的重启循环。接口名称不会替应用定义初始化完成的真实含义。
启动检查一旦成功,kubelet 就停止当前容器实例的 startup 探针,并按独立配置运行存活与就绪检查。启动成功不是永久健康证明:应用稍后仍可能变成未就绪,或者需要重启。新的容器实例还要重新完成自己的启动检查,不能继承上一实例已经成功的结论。把实例身份和状态变化一起记录,才能解释重复启动的原因。
坏掉的样子
启动探针如果一直不成功,最终会达到失败阈值。Kubelet 随后终止容器,并根据对应重启策略处理。因此,启动保护不是允许初始化无限等待。周期、超时和失败阈值应依据真实初始化耗时设置,同时考虑环境中可能发生的资源争用与可以接受的恢复时间,避免只按开发机器的一次快速启动选择参数。
failureThreshold 与 periodSeconds 的乘积可用于规划启动预算,但不能把它写成所有实际重启都严格遵守的秒表。初始延迟、探针执行与调度、终止宽限和重启退避也影响完整时间线。排查慢发布时,应记录真实尝试和状态变化,而不是仅根据两个配置数字推断容器应该已经重启,或断定平台行为错误。
除非通过 startup 明确建立依赖,否则不应认为 liveness 会等 readiness 成功才开始。就绪与存活原本回答两个持续运行阶段的问题。反过来,启动成功也不会强迫就绪成功:后续 readiness 可能因为另一个条件拒绝流量资格,而进程仍然正常存活。把这些结果分开,才能选择正确恢复动作,避免用反复重启处理暂时不可服务。
修改探针也需要应用实际配合。一个永远返回成功的接口,不会因为名称叫 startup,就具备验证初始化步骤的能力。状态转换应有明确实现,并提供足够诊断信息解释为什么仍在等待。接口还应避免向非预期调用方暴露凭据或敏感初始化内容。下面的测试仅输出计数和合成状态,便于核对调用顺序。
快速验证
准备可丢弃的 Kubernetes 集群、kubectl,以及节点可用的 python:3.11-slim-bookworm 镜像。保存为 startup-gate.py,执行 python3 startup-gate.py KUBECONFIG_PATH CONTEXT,明确指定测试配置与上下文。账号需要创建、删除命名空间。实测服务端为 Kubernetes 1.34.11,本次短实验设置一秒周期及六十次启动失败阈值,为人工控制的放行留出时间。
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-startup-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
server = '''from http.server import BaseHTTPRequestHandler,HTTPServer
from pathlib import Path
import json
counts={'startup':0,'live':0,'ready':0}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
key=self.path.removeprefix('/'); code=200
if key in counts:
counts[key]+=1
if key=='startup' and not Path('/tmp/start').exists(): code=503
body=json.dumps(counts).encode(); 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['startupProbe'] = {'httpGet': {'path': '/startup', 'port': 8080}, 'periodSeconds': 1, 'failureThreshold': 60}
for field, path in [('livenessProbe', '/live'), ('readinessProbe', '/ready')]:
c[field] = {'httpGet': {'path': path, 'port': 8080}, 'periodSeconds': 1, 'failureThreshold': 2}
apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'slow'}, 'spec': podspec})
wait_for(lambda: obj(namespace, 'pod', 'slow'), lambda p:
any('running' in c['state'] for c in p.get('status', {}).get('containerStatuses', [])))
read = 'from urllib.request import urlopen; print(urlopen("http://127.0.0.1:8080/stats",timeout=3).read().decode())'
def counts():
result = k('exec', '-n', namespace, 'slow', '--', 'python3', '-c', read, expected=None)
return json.loads(result.stdout) if result.returncode == 0 else None
before = wait_for(counts, lambda d: d and d['startup'] >= 3)
assert before['live'] == before['ready'] == 0
starting = obj(namespace, 'pod', 'slow')
assert not pod_ready(starting)
assert starting['status']['containerStatuses'][0]['started'] is False
assert starting['status']['containerStatuses'][0]['restartCount'] == 0
k('exec', '-n', namespace, 'slow', '--', 'python3', '-c',
'from pathlib import Path; Path("/tmp/start").touch()')
ready = wait_for(lambda: obj(namespace, 'pod', 'slow'), pod_ready)
assert ready['status']['containerStatuses'][0]['started'] is True
enabled = wait_for(counts, lambda d: d and d['live'] > 0 and d['ready'] > 0)
time.sleep(3)
after = counts()
assert after['startup'] == enabled['startup']
assert after['live'] > enabled['live'] and after['ready'] > enabled['ready']
assert obj(namespace, 'pod', 'slow')['status']['containerStatuses'][0]['restartCount'] == 0
print(json.dumps({'before_startup_success': before, 'after_startup_success': after,
'other_probes_suppressed_before_startup': True,
'startup_probe_stops_after_success': True, 'container_restarts': 0}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
HTTP 服务分别记录 startup、live、ready 路径的请求次数。统计路径只读取数据,不增加这三个计数。启动路径最初返回 503,直到本地放行文件出现。观察到三次启动请求时,另外两个计数都为零,容器没有重启,started 字段为 false。这直接验证其他探针尚未执行,而不是把它们的失败隐藏起来。
创建放行文件后,下一次启动检查成功。Pod 变成 Ready,started 变成 true,存活与就绪计数开始增加。再等三秒,启动计数保持固定,另外两个计数继续增加。本次最终分别观察到四、四、五次;持续探针的精确次数会受到时序影响,因此断言检查的是变化关系,而不是强制所有运行都得到同一个数字。
全部断言通过,容器没有重启,命名空间随后删除。实验通过真实 HTTP 调用验证了抑制与交接,没有测量生产启动耗时分位数,也没有耗尽启动失败预算。选择真实阈值时需要补充这些观测,同时保持初始化完成与持续服务健康之间的区别,不能用一次启动通过替代后续运行质量。
延伸阅读
来源
- Kubernetes: Configure Probes,启动失败预算与探针配置,已核验 2026-09-13。
- Kubernetes: Pod Lifecycle,启动成功前抑制存活与就绪检查,已核验 2026-09-13。