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 这个词本身推导出不会产生影响。

来源