限制有多少副本可被主动停掉的一种策略。

PodDisruptionBudget 给可中断副本数设定上限

PodDisruptionBudget 是一道地板,不是天花板:它规定在有东西主动把副本拿走时,一个工作负载至少还要留下多少个可用。动手拿走副本的通常是 kubectl drain、节点自动扩缩容,或者某个在压缩节点占用量的 operator。 它真正编码的区分是“事故”和“决定”。节点挂掉属于被动中断,没有任何预算拦得住;排空是主动的,一旦预算用尽,驱逐 API 就会拒绝。把这两者混为一谈,会导向两种经典结局:预算把维护永久挡住,或者一次维护把法定多数带走。预算说的是受控操作能走多远,而它到底好不好用,完全取决于它背后那些副本的健康程度。 只有主动中断才受管控 主动中断是有人做出的选择:为维修排空节点、把节点组缩容、为了塞进更大的 Pod 而挪走一个。被动中断——硬件故障、内核 panic、节点因网络分区失联、kubelet 在内存压力下驱逐 Pod——完全不受预算管控。但文档明确写着,它们照样会消耗预算,这正是为什么一次崩溃会悄无声息地吃掉你下一次排空所需的空间。 唯一的关卡是驱逐 API。直接删除 Pod 或 Deployment 会绕过命名空间里的所有预算,滚动更新也一样。expectedPods 取自拥有这些 Pod 的工作负载的 .spec.replicas,通过 Pod 的 ownerReferences 反查得到,而不是数一数匹配的 Pod——这就是选择器同时匹配到两个控制器的 Pod 时表现诡异的原因。预算也看不见已经不健康的 Pod:默认的 IfHealthyBudget 策略在应用处于中断状态时,拒绝驱逐“在跑但没 Ready”的 Pod,于是一个 CrashLoopBackOff 的 Pod 就能把整次排空钉死;AlwaysAllow 的存在正是为了打破这个死锁。 ...

四月 10, 2027 · 1 分钟 · XB Field Notes
一种让带选中标签的 Pod 在拓扑上彼此远离的调度规则。

Pod 反亲和把同组峰值彼此分散

反亲和就是那条让副本彼此分开的调度规则。亲和说“把我放在这些 Pod 旁边”,反亲和说“把我放得离它们远一点”,而理由几乎总是故障域:三个副本要是都落在一台机器上,这台机器一挂就是彻底不可用。 矛盾全在同一个词上。required 让规则变成过滤器,一个无处可去的 Pod 会永远停在 Pending;preferred 让它变成打分,同一条规则就变成优雅降级。同一份 YAML 的形状,写哪一个词,决定了它是死锁还是软偏好。 一条规则 = 一个选择器加一个拓扑 这条规则有两部分。labelSelector 决定哪些 Pod 算作同类,topologyKey 指定用哪个节点标签来划分域。kubernetes.io/hostname 让每台节点各自成一个域;topology.kubernetes.io/zone 把节点按可用区分组,于是“分开”就变成了“分散到不同可用区”。namespaces 字段默认取 Pod 自己所在的命名空间,所以反亲和不会意外跨租户生效。 没有这个 topologyKey 标签的节点不属于任何域。文档明确提醒,反亲和要求节点标签一致,缺少这个键会导致意料之外的行为。实际表现是:没有标签的节点对这条规则完全隐形——既不会被滤掉,也不会被计入——于是它反而会聚起那些本该被分散开的副本。还有一个细节:已经存在的 Pod 身上带的 preferred 规则,在调度新 Pod 时会被忽略。只有待调度 Pod 自己的规则算数,所以给一个正在运行的负载补上分散规则,并不会重新洗牌已经落位的副本。 required 是上限,不是保证 required 反亲和是一个节点过滤器,因此它把负载的副本数上限压到“每个域一个”。九副本铺在六台节点上,会永远留下三个 Pending,无论还有多少空闲容量。铺在三个可用区上时,四副本会留下一个 Pending。每个被拒的 Pod 都报 FailedScheduling,自动扩缩容一直表示帮不上忙,而这一切从不报错。 ...

三月 18, 2027 · 1 分钟 · XB Field Notes
一个 Pod 在就绪状态变化和随后容器重启期间始终保留相同身份。

就绪探针改变路由资格,存活探针触发容器重启

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 失败描述成一个瞬时保证,认为从此没有任何请求能够到达进程。它改变的是对应服务发现和路由流程中的资格。 ...

一月 13, 2027 · 3 分钟 · XB Field Notes
配置认可的失败尝试会暂时排除节点,主动健康探测则是另一项能力。

被动上游健康判断来自失败流量,不来自探测定时器

nginx 被动上游健康判断利用真实请求结果,决定某个节点是否暂时停止接收流量。max_fails 设置失败尝试阈值,fail_timeout 同时规定计数区间和不可用时长。这些配置不会安排周期性的应用探测。主动检查需要被支持的检查能力与额外配置,不能把真实请求结果策略直接称作一个已经运行的探活定时器。 坏掉的样子 不是每个看起来像错误的 HTTP 响应,都算一次失败上游尝试。代理重试配置参与定义这种分类。在本次设置中,只有列入 http_503,HTTP 503 才参与相应被动失败处理。如果认为所有 5xx 都会自动导致健康失败,就可能期待节点已被摘除,而 nginx 仍依据真实规则继续选择它。必须先核对什么结果被配置认定为失败。 另一个错误是把超时窗口结束当成恢复证明。不可用时段结束后,节点可以重新获得真实流量资格,未必先通过独立健康探测。如果后端仍坏着,这次请求仍会失败。least_conn 依据活动连接改变选择,并不提供渐进恢复权重的升温过程,也不能代替受支持的 slow-start 机制。资格恢复、健康证明和逐渐加量需要分别理解。 重试也有边界。响应数据已经发送给客户端后,更换节点不能透明修复中途错误;重放有副作用的操作还可能产生重复。下面只使用 GET,并在成功正文发送之前产生失败,且明确限制尝试次数。它没有为所有应用方法证明安全重试语义,不能把这个成功切换案例直接用于任何写操作。 快速验证 将脚本保存为 check-passive-health.py,准备 Python、Docker 和固定镜像后运行。记录服务器为单 worker 的 nginx 1.28.0。可丢弃容器里的两个 HTTP 端点报告自身身份,并能受控返回 503。另一个控制监听提供计数,查询它不会通过被均衡组产生业务请求,便于区分观察动作与真实探测流量: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None,extra_ports=()): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','-d','--name',containers[0],'--network',name,*published,tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import itertools,json,threading lock=threading.Lock();ids=itertools.count(1);counts={'A':0,'B':0};active={'A':0,'B':0};faults={'A':200,'B':200};gates={} class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def setup(self):super().setup();self.ident=next(ids) def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);label=self.server.label;code=200 if label=='control': query=parse_qs(parsed.query) with lock: if parsed.path=='/fail':faults[query['peer'][0]]=int(query['status'][0]) if parsed.path=='/release':gates[query['path'][0]].set() data={'counts':dict(counts),'active':dict(active),'faults':dict(faults),'held':list(gates)} else: with lock: counts[label]+=1;active[label]+=1;code=faults[label] if parsed.path.startswith('/hold/'):gates[parsed.path]=threading.Event() if parsed.path.startswith('/hold/'):assert gates[parsed.path].wait(20) data={'peer':label,'path':self.path,'connection':self.ident} body=json.dumps(data,sort_keys=True).encode();self.send_response(code) self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body))) self.end_headers();self.wfile.write(body) if label!='control': with lock:active[label]-=1 for port,label in [(8081,'A'),(8082,'B'),(8083,'control')]: server=ThreadingHTTPServer(('0.0.0.0',port),Handler);server.label=label threading.Thread(target=server.serve_forever,daemon=True).start() threading.Event().wait() ''' extra=''' upstream passive { server backend:8081 max_fails=1 fail_timeout=2s; server backend:8082; } upstream plain { server backend:8081 max_fails=1 fail_timeout=30s; server backend:8082; } upstream single { server backend:8081 max_fails=1 fail_timeout=30s; } add_header X-Upstream $upstream_addr always; add_header X-Attempts $upstream_status always; ''' locations=''' location /passive { proxy_next_upstream error timeout http_503; proxy_next_upstream_tries 2; proxy_pass http://passive; } location /plain { proxy_next_upstream error timeout; proxy_pass http://plain; } location /single { proxy_next_upstream error timeout http_503; proxy_pass http://single; } location /control/ { proxy_pass http://backend:8083/; } ''' active_config=config('location / { proxy_pass http://peers; health_check; }', 'upstream peers { zone peers 64k; server backend:8081; server backend:8082; }') with lab(config(locations,extra),{'active.conf':active_config},backend) as (proxy,request): def control(path): status,_,body=request('/control/'+path);assert status==200;return json.loads(body) def call(path): status,h,b=request(path);return {'status':status,'attempts':h['x-attempts'],'upstream':h['x-upstream'],'peer':json.loads(b)['peer']} control('fail?peer=A&status=503');results={} plain=[call('/plain') for _ in range(6)] assert [r['status'] for r in plain].count(503)==3 and all(',' not in r['attempts'] for r in plain) results['unlisted_http_503']=plain for _ in range(2): first=call('/passive') if first['attempts']=='503, 200':break assert first['status']==200 and first['attempts']=='503, 200' and first['peer']=='B',first results['failed_attempt_then_success']=first after_failure=control('state')['counts']['A'] excluded=[call('/passive') for _ in range(4)] assert all(r['peer']=='B' and r['attempts']=='200' for r in excluded) assert control('state')['counts']['A']==after_failure time.sleep(.4);assert control('state')['counts']['A']==after_failure results['during_exclusion']=excluded;results['background_probe_count']=0 control('fail?peer=A&status=200');deadline=time.monotonic()+6;recovered=None while time.monotonic()<deadline: observed=call('/passive') if observed['peer']=='A':recovered=observed;break time.sleep(.1) assert recovered and recovered['status']==200;results['live_request_after_window']=recovered control('fail?peer=A&status=503');before=control('state')['counts']['A'] single=[call('/single') for _ in range(2)] assert all(r['status']==503 and r['attempts']=='503' for r in single) assert control('state')['counts']['A']==before+2;results['single_peer_still_attempted']=single probe=docker('exec',proxy,'nginx','-t','-c','/srv/active.conf',check=False) assert probe.returncode!=0 and 'unknown directive "health_check"' in probe.stderr results['active_health_check_directive_supported']=False print(json.dumps(results,sort_keys=True)) 首先让 A 返回真实 HTTP 503,B 返回 200。一个对照组只对连接错误和超时重试。六次请求仍各选择 A、B 三次,没有多次尝试的响应。A 的 max_fails=1 不会因为这个未列入的 HTTP 结果把它移除。实验检查状态与所选上游地址,不只查看最终正文,从而避免把应用返回值与节点资格变化混淆。 ...

一月 5, 2027 · 3 分钟 · XB Field Notes