Annotation 为 Kubernetes 对象附加非识别性元数据,可以记录来源、负责人说明,或供工具消费的配置。标准标签选择器不会按 annotation 值筛选对象,但这并不意味着 annotation 没有作用。控制器、准入组件或发布工具都可能解释约定的键并改变行为。判断元数据修改的影响之前,应先确认具体消费者,不能仅凭字段名称认定它只是无害备注。
不参与选择,不等于没有校验
Annotation 的键和值都必须是字符串。键遵循限定名称格式,可以包含可选的 DNS 风格前缀和名称部分;一个对象的 annotation 键和值合计大小不能超过 256 KiB。因此,它不是不受限制的任意类型 JSON 存储。如果某个工具把 JSON 序列化后放进字符串,额外的格式与解释规则仍然由这个工具负责,而不是自动成为 Kubernetes 的通用语义。
自动化写入的键应使用负责组织控制的前缀。普通负责人说明与某个控制器专用参数,即使都位于同一张映射中,也具有不同含义。应记录哪个组件负责写入、哪个组件负责读取每个操作性键。来自另一种控制器、看起来很熟悉的 annotation,不能在没有核对契约时就当作可移植配置使用。
标签与 annotation 的选择角色也不同。标准选择器可以匹配标签表达式,而描述性的 annotation 通常需要另外读取和解释。这不意味着每一个任意标签都拥有专门存储索引,更不意味着 annotation 无法影响协调过程。字段选择应依据消费者的 API,而不是机械遵循“机器只读标签、人只读备注”的错误分工。
坏掉的样子
字段所在的位置会改变效果。修改 Deployment 顶层 metadata,与修改 spec.template 里面的 metadata,是不同操作。即使容器镜像和选择器标签没有改变,Pod 模板变化仍可能触发滚动更新。反过来,只写在 Deployment 对象上的 annotation,也不会自动变成 Pod 模板里的变化。审阅补丁时应明确完整字段路径,不能只比较键名。
Generation 不是滚动更新计数器。在 Kubernetes 1.34.11 的 Deployment 实现中,annotation 变化也会增加 generation,因为这些值可以传播给 ReplicaSet。因此,generation 增加可能仅表示需要协调元数据,并没有新的 Pod 模板或替换 Pod。汇报滚动结果之前,应分别查看模板、ReplicaSet 身份及实际 Pod 标识,避免用一个数字代替生命周期证据。
发布脚本如果每次渲染都在 Pod 模板里写入当前时间,即使代码和配置未变,也可能不断触发新的工作负载替换。应让用于版本识别的值来自真实输入变化,并把仅作审计的时间记录放在合适层次,同时核对该层次的消费者。否则,一个原本用于说明来源的字段,会变成意外的持续发布开关。
Annotation 同样不是秘密保护边界。能够读取对象元数据的主体可以查看它,工具还可能把内容复制到其他对象或日志。敏感值不应混进描述性元数据。过大的序列化记录可能触及总量限制,而没有编码成字符串的数值则可能在控制器看到之前就被 API 拒绝。描述性用途与结构约束并不矛盾,两者都需要遵守。
快速验证
准备可丢弃的 Kubernetes 集群,确保 kubectl 可访问,节点能够使用 python:3.11-slim-bookworm 镜像。把代码保存为 annotations.py,执行 python3 annotations.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'}}}]}
def deployment(namespace, name, podspec):
return apply(namespace, {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {'name': name},
'spec': {'replicas': 1, 'selector': {'matchLabels': {'app': name}},
'template': {'metadata': {'labels': {'app': name}}, 'spec': podspec}}})
def deployment_pod(namespace, name):
wait_for(lambda: obj(namespace, 'deployment', name), lambda d:
d.get('status', {}).get('observedGeneration', 0) >= d['metadata']['generation']
and d.get('status', {}).get('updatedReplicas') == 1
and d.get('status', {}).get('availableReplicas') == 1
and d.get('status', {}).get('replicas') == 1)
def pods():
return [p for p in obj(namespace, 'pods', selector='app=' + name)['items']
if not p['metadata'].get('deletionTimestamp')]
return wait_for(pods, lambda p: len(p) == 1 and pod_ready(p[0]))[0]
namespace = 'notes-annotations-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
deployment(namespace, 'web', spec())
first = deployment_pod(namespace, 'web')
initial = obj(namespace, 'deployment', 'web')
replicasets_before = {r['metadata']['uid'] for r in obj(namespace, 'replicasets')['items']}
k('annotate', 'deployment', 'web', '-n', namespace, 'notes.example/release=two')
annotated = obj(namespace, 'deployment', 'web')
assert annotated['metadata']['generation'] > initial['metadata']['generation']
assert annotated['metadata']['annotations']['notes.example/release'] == 'two'
assert deployment_pod(namespace, 'web')['metadata']['uid'] == first['metadata']['uid']
assert {r['metadata']['uid'] for r in obj(namespace, 'replicasets')['items']} == replicasets_before
wait_for(lambda: obj(namespace, 'replicasets')['items'],
lambda records: len(records) == 1 and records[0]['metadata'].get('annotations', {}).get('notes.example/release') == 'two')
invalid = k('patch', 'deployment', 'web', '-n', namespace, '--type=merge',
'-p', json.dumps({'metadata': {'annotations': {'notes.example/count': 3}}}), expected=None)
assert invalid.returncode != 0
assert 'notes.example/count' not in obj(namespace, 'deployment', 'web')['metadata']['annotations']
k('patch', 'deployment', 'web', '-n', namespace, '--type=merge', '-p', json.dumps({
'spec': {'template': {'metadata': {'annotations': {'notes.example/config': 'two'}}}}}))
second = deployment_pod(namespace, 'web')
final = obj(namespace, 'deployment', 'web')
assert second['metadata']['uid'] != first['metadata']['uid']
assert final['metadata']['generation'] > annotated['metadata']['generation']
assert final['spec']['selector'] == initial['spec']['selector']
assert len(obj(namespace, 'replicasets')['items']) == 2
assert second['metadata']['annotations']['notes.example/config'] == 'two'
selected = obj(namespace, 'pods', selector='app=web')['items']
assert any(p['metadata']['uid'] == second['metadata']['uid'] for p in selected)
print(json.dumps({'top_level_annotation_preserved_pod': True,
'generation_sequence': [d['metadata']['generation'] for d in (initial, annotated, final)],
'top_level_annotation_copied_to_replicaset': True,
'non_string_annotation_rejected': True,
'template_annotation_created_new_pod': True,
'replicasets_after_template_change': 2, 'selector_unchanged': True}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
第一阶段创建 Deployment,记录 Pod 标识、generation 和 ReplicaSet 标识。向 Deployment 顶层添加 notes.example/release 后,generation 从一变成二;既有 ReplicaSet 收到这项 annotation,但原 Pod 与 ReplicaSet 身份都保持不变。这是实际观察到的协调行为,不能沿用“顶层备注不会改变 generation”的错误假设。
随后,程序尝试把 annotation 值补丁为数字。API 拒绝操作,读取对象也确认这个键没有出现。最后,在 Pod 模板内添加 notes.example/config,generation 变为三,创建第二个 ReplicaSet,并替换 Pod。选择器保持相同,新 Pod 则携带模板中的 annotation。这把选择规则与模板版本变化的影响明确地区分开。
原文报告全部断言通过,命名空间随后清理;这里保留历史执行归属,不表示本轮独立重现。实验验证的是这些 Deployment 行为与一个类型校验失败,没有覆盖任意第三方键、准入政策或调度插件。实际修改应依据具体字段路径与消费者契约判断,并报告已经观察到的生命周期结果,而不是从 annotation 这个词本身推导出不会产生影响。
来源
Kubernetes: Annotations,非识别性元数据与字符串值,已核验 2026-10-15。
Kubernetes: Deployments,Pod 模板变化触发滚动更新,已核验 2026-10-15。
Kubernetes 1.34.11: Deployment strategy source,annotation 传播给 ReplicaSet,因此变更会增加 generation,已核验 2026-10-15。
Kubernetes 1.34.11:对象元数据校验,ValidateAnnotationsSize 合计键和值的字节数并检查 256 KiB 上限,已核验 2026-10-15。