标签是 Kubernetes 用于选择对象的键值元数据。Service 选择器、运维查询和工作负载控制器都可能依赖标签,但每个消费者有自己的 API 和处理方式。因此,标签变化可能改变对象所属集合,同时底层进程仍然健康。审阅时应把标签视为真实消费者的输入,而不是仅用于展示的贴纸,否则看似简单的文字修改也可能改变路由。
确认正在使用哪一种选择语言
标签键采用限定名称格式,值也有字符和长度约束。标签值可以为空;不为空时,其允许字符及首尾位置受到限制,最大长度为六十三个字符。任意长段落或不受约束的 JSON 并不适合放在标签值里。描述性信息可以采用 annotation,但仍然要遵守对应格式和消费契约,不能把换字段当成取消所有限制。
等值条件检查某个键是否具有指定值,集合条件还支持成员关系与存在性检查。同一个选择器里的多个条件需要同时满足。有一个容易忽略的细节:track!=canary 也会匹配根本没有 track 键的对象。如果不允许键缺失,应另外要求这个键存在,不能认为“不等于某值”就已经隐含“这个键肯定存在”。
不同资源的选择字段结构并不相同。普通带选择器的 Service 使用等值映射,不能把完整集合表达式随意塞进某个映射值。另一些 API 提供 LabelSelector 结构,而 kubectl 列表查询能够接受选择表达式。把命令行中方便使用的查询转换成清单之前,应核对目标字段的真实结构,避免把同一个概念误当成完全相同的输入格式。
坏掉的样子
Service 选择器会识别本命名空间中匹配的 Pod,端点机制再跟踪地址与就绪状态。标签匹配本身不证明服务已经准备好,也不证明端口配置正确。在普通就绪策略下,路由可用目标还取决于端点条件。检查时应查看相应 EndpointSlice 和应用探测结果,不能在一次标签查询成功后就停止验证。
publishNotReadyAddresses 设置会改变服务发现对就绪状态的处理。在这种配置下,不能仅凭端点被标成就绪,就断言对应容器已经通过应用探测。下面实验采用普通默认行为,没有测试这个覆盖设置,因此结果也不应无条件推广到使用该选项的服务。
给健康 Pod 修改标签,可能把它移出 Service,却并不终止进程。过宽的条件则可能把原本不该提供服务的工作负载加入后端。如果 Pod 由控制器管理,手工改标签还可能影响所属关系与协调行为。应通过负责的控制器更新预期状态,并在改变共享键含义之前核对主要消费者,避免修好一个查询却破坏另一条路由。
选择器不是网络授权。某个 Pod 即使不属于一个 Service,也可能继续通过自身地址或其他 Service 被访问,除非另有控制阻止。NetworkPolicy、应用授权和路由分别解决不同部分,不能把选择器描述成能够拒绝所有未选中对象直连流量的访问名单。对象是否被发现,与请求最终是否允许,必须分别成立。
快速验证
以下实验及观察结果沿用原文记录。本次审阅没有重新执行 Kubernetes 集成实验;文中的成功结果属于历史记录,不代表本轮取得了新的运行证据。
准备可丢弃的 Kubernetes 集群,确保 kubectl 可用,节点已有 python:3.11-slim-bookworm 镜像。保存代码为 label-selectors.py,执行 python3 label-selectors.py KUBECONFIG_PATH CONTEXT,明确传入测试集群配置与上下文。脚本在独有命名空间创建三个独立 HTTP Pod 和一个 Service,结束后删除它们。实测服务端版本是 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-labels-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
for name, role, track in [('a', 'reader', 'stable'), ('b', 'reader', 'canary'), ('c', 'writer', None)]:
script = '''from http.server import BaseHTTPRequestHandler,HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
data=LABEL.encode();self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
def log_message(self,*args): pass
HTTPServer(('0.0.0.0',8080),Handler).serve_forever()
'''.replace('LABEL', repr(name))
podspec = spec(script)
podspec['containers'][0]['readinessProbe'] = {'httpGet': {'path': '/', 'port': 8080}, 'periodSeconds': 1}
labels = {'app': 'catalog', 'role': role}
if track:
labels['track'] = track
apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': name, 'labels': labels}, 'spec': podspec})
for name in ('a', 'b', 'c'):
wait_for(lambda name=name: obj(namespace, 'pod', name), pod_ready)
apply(namespace, {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'readers'},
'spec': {'selector': {'app': 'catalog', 'role': 'reader'}, 'ports': [{'port': 80, 'targetPort': 8080}]}})
def selected():
slices = obj(namespace, 'endpointslices', selector='kubernetes.io/service-name=readers')['items']
return {e['targetRef']['name'] for item in slices for e in item.get('endpoints', [])
if e.get('conditions', {}).get('ready') and e.get('targetRef')}
before = wait_for(selected, lambda names: names == {'a', 'b'})
inequality = {p['metadata']['name'] for p in obj(namespace, 'pods', selector='app=catalog,track!=canary')['items']}
assert inequality == {'a', 'c'}
expression = {p['metadata']['name'] for p in obj(namespace, 'pods', selector='app in (catalog),role=reader')['items']}
assert expression == {'a', 'b'}
writer_ip = obj(namespace, 'pod', 'c')['status']['podIP']
probe = 'from urllib.request import urlopen; print(urlopen(' + repr('http://'+writer_ip+':8080/') + ',timeout=3).read().decode())'
assert k('exec', '-n', namespace, 'a', '--', 'python3', '-c', probe).stdout.strip() == 'c'
k('label', 'pod', 'b', '-n', namespace, 'role=writer', '--overwrite')
after = wait_for(selected, lambda names: names == {'a'})
assert pod_ready(obj(namespace, 'pod', 'b'))
invalid = k('patch', 'pod', 'a', '-n', namespace, '--type=merge',
'-p', json.dumps({'metadata': {'labels': {'notes.example/value': 'has spaces'}}}), expected=None)
assert invalid.returncode != 0 and 'Invalid' in invalid.stderr
print(json.dumps({'service_targets_before': sorted(before), 'service_targets_after': sorted(after),
'inequality_also_matches_missing_key': sorted(inequality),
'set_selector_matches': sorted(expression), 'excluded_pod_directly_reachable': True,
'invalid_label_value_rejected': True}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
Pod a 与 b 的 role 为 reader,c 为 writer;三者共享 app=catalog,并通过 HTTP 就绪探测。a 的 track 为 stable,b 为 canary,c 则没有这个键。reader Service 最初具有 a、b 两个就绪目标。集合式查询能够找到这两个 reader,而 track!=canary 会找到 a、c,实际确认缺少键时也会匹配不等式。
从 a 向 c 的 Pod 地址发起真实 HTTP 请求,会返回 c,即使 c 不属于 reader Service。随后把 b 的 role 改为 writer,Service 的就绪端点集合只剩 a,但 b 自己仍然处于就绪状态。另一个包含空格标签值的补丁被 API 拒绝。原文报告所有断言都已运行通过,结果同时覆盖查询、控制器维护的端点和实际网络请求。
实验验证了隔离测试网络中的 API 选择、EndpointSlice 变化和直连可达性,没有测试所有控制器的接管规则,没有执行网络策略隔离,也没有测量 Service 性能。审阅真实路由变化时,应分别观察选择意图、端点就绪和访问政策,使每一层都有可检查的结果,而不是靠某个标签名称推断完整安全边界。
来源
- Kubernetes: Labels and Selectors,合法值、等值及集合式选择条件,已核验 2026-10-15。
- Kubernetes: Services,基于选择器的后端发现,已核验 2026-10-15。
- Kubernetes: EndpointSlices,端点身份及就绪条件,已核验 2026-10-15。