命名空间为 Kubernetes 集群中的许多对象限定范围。两个团队都可以创建名为 settings 的 ConfigMap,因为包含命名空间后的完整身份不同。这种组织边界很有价值,但不会自动提供网络隔离、独立内核、专属节点容量或完整租户隔离。工作负载实际需要哪一种保障,就应确认额外由什么政策和执行机制落实,而不是把它们都归因于命名空间。

名称范围取决于资源类型

Pod、Service、ConfigMap 和许多工作负载控制器属于命名空间资源。Node、命名空间本身以及其他集群级资源,则不会因为客户端指定了某个命名空间,就被划分成另一份独立副本。应查看具体资源的 API 范围,不能假定所有对象都住在某个房间里面。命名空间也不是由独立子集群构成的嵌套层级。

在 Pod 内部,服务短名称通常按所在命名空间解析。带限定的名称可以指向别处,例如默认集群域下的 api.other.svc.cluster.local。这种约定负责定位目的地址,并不决定允许还是拒绝连接。集群域也可以另行配置,因此示例中的后缀不是所有安装都必须采用的常量。诊断 DNS 时需要结合实际域和工作负载所在位置。

自动化中的命名空间选择应保持明确。对象名称即使正确,如果当前活动命名空间错误,仍然会访问另一个资源。尤其是修改操作,应有意指定 kubeconfig、上下文和命名空间,而不是依赖操作者终端之前留下的状态。对象范围让身份更清楚,但具有广泛权限的管理员依然能够跨范围修改内容,不能因此省略权限审查。

坏掉的样子

没有适用网络政策的命名空间,仍然按照集群网络本身的行为保持连通。仅创建命名空间,或在 DNS 中加入它的名称,并不会拒绝其他 Pod。网络隔离需要正确的政策,以及真正执行这些政策的网络实现。应用认证还是另一层:域名能解析,只说明找到了地址,并不能证明某个调用者应该获准读取返回内容。

RBAC 则单独控制 API 操作。一个 RoleBinding 可以让账号读取本命名空间的 ConfigMap,而不给它读取其他范围的能力。但权限是叠加的,另一个广泛绑定仍可能授予更多访问。应以目标身份实际验证允许和拒绝的请求,不能认为添加一份较小角色,就自动取消先前更大的授权。网络流量政策也不会代替这一层 API 权限。

ResourceQuota 主要在准入阶段约束对象数量,以及汇总的资源请求或限制等指标。它本身不会节流正在运行的进程,也不会预留物理节点容量。LimitRange 可以为新接纳的工作负载提供默认值和边界,运行时资源限制则由其他机制执行。这些政策都需要明确配置;只有一个命名空间,并不能让会争抢共享资源的邻居自动变得无害。

快速验证

历史集群集成示例保持原样;当前没有 Kubernetes,因此本次没有重跑。

准备使用默认 cluster.local 域的临时 Kubernetes 集群,确保 kubectl 管理员能够执行测试 impersonation,节点已有 python:3.11-slim-bookworm 镜像。把代码保存为 namespaces.py,执行 python3 namespaces.py KUBECONFIG_PATH CONTEXT,明确传入测试配置。实测集群是 Kubernetes 1.34.11,采用 kind 网络。脚本创建两个唯一命名空间,并在结束后删除两者。

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'}}}]}

suffix = uuid4().hex[:8]
first, second = 'notes-a-' + suffix, 'notes-b-' + suffix
created = []
try:
    for namespace, value in [(first, 'A'), (second, 'B')]:
        k('create', 'namespace', namespace)
        created.append(namespace)
        apply(namespace, {'apiVersion': 'v1', 'kind': 'ConfigMap', 'metadata': {'name': 'settings'}, 'data': {'scope': value}})
        script = '''from http.server import BaseHTTPRequestHandler,HTTPServer
class Handler(BaseHTTPRequestHandler):
 def do_GET(self):
  data=VALUE.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('VALUE', repr(value))
        podspec = spec(script)
        podspec['containers'][0]['readinessProbe'] = {'httpGet': {'path': '/', 'port': 8080}, 'periodSeconds': 1}
        apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'web', 'labels': {'app': 'web'}}, 'spec': podspec})
        apply(namespace, {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'api'},
              'spec': {'selector': {'app': 'web'}, 'ports': [{'port': 80, 'targetPort': 8080}]}})
        wait_for(lambda namespace=namespace: obj(namespace, 'pod', 'web'), pod_ready)
        wait_for(lambda namespace=namespace: obj(namespace, 'endpointslices', selector='kubernetes.io/service-name=api')['items'],
                 lambda slices: any(e.get('conditions', {}).get('ready') for s in slices for e in s.get('endpoints', [])))
    assert obj(first, 'configmap', 'settings')['data']['scope'] == 'A'
    assert obj(second, 'configmap', 'settings')['data']['scope'] == 'B'
    nodes_a = {n['metadata']['uid'] for n in obj(first, 'nodes')['items']}
    nodes_b = {n['metadata']['uid'] for n in obj(second, 'nodes')['items']}
    assert nodes_a == nodes_b and nodes_a
    apply(first, {'apiVersion': 'v1', 'kind': 'ServiceAccount', 'metadata': {'name': 'reader'}, 'automountServiceAccountToken': False})
    apply(first, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'Role', 'metadata': {'name': 'read-config'},
                  'rules': [{'apiGroups': [''], 'resources': ['configmaps'], 'verbs': ['get']}]})
    apply(first, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'RoleBinding', 'metadata': {'name': 'read-config'},
        'subjects': [{'kind': 'ServiceAccount', 'name': 'reader', 'namespace': first}],
        'roleRef': {'apiGroup': 'rbac.authorization.k8s.io', 'kind': 'Role', 'name': 'read-config'}})
    identity = 'system:serviceaccount:' + first + ':reader'
    assert k('--as', identity, 'get', 'configmap', 'settings', '-n', first).returncode == 0
    denied = k('--as', identity, 'get', 'configmap', 'settings', '-n', second, expected=None)
    assert denied.returncode != 0 and 'Forbidden' in denied.stderr
    apply(first, {'apiVersion': 'v1', 'kind': 'ResourceQuota', 'metadata': {'name': 'one-service'},
                  'spec': {'hard': {'services': '1'}}})
    wait_for(lambda: obj(first, 'resourcequota', 'one-service'), lambda q: q.get('status', {}).get('used', {}).get('services') == '1')
    extra = {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'extra', 'namespace': first},
             'spec': {'ports': [{'port': 80}]}}
    quota_denial = k('create', '-f', '-', payload=extra, expected=None)
    assert quota_denial.returncode != 0 and 'exceeded quota' in quota_denial.stderr
    assert not obj(first, 'networkpolicies')['items'] and not obj(second, 'networkpolicies')['items']
    remote = 'api.' + second + '.svc.cluster.local'
    probe = '''import json,socket
from urllib.request import urlopen
print(json.dumps({'short_name':socket.gethostbyname('api'),
                  'qualified_name':socket.gethostbyname(REMOTE),
                  'cross_namespace_body':urlopen('http://'+REMOTE+'/',timeout=3).read().decode()}))'''.replace('REMOTE', repr(remote))
    network = json.loads(k('exec', '-n', first, 'web', '--', 'python3', '-c', probe).stdout)
    assert network['short_name'] == obj(first, 'service', 'api')['spec']['clusterIP']
    assert network['qualified_name'] == obj(second, 'service', 'api')['spec']['clusterIP']
    assert network['cross_namespace_body'] == 'B'
    print(json.dumps({'same_names_different_configmaps': True, 'nodes_are_cluster_scoped': True,
                      'rbac_allows_local_and_denies_other_namespace': True,
                      'quota_rejects_second_service': True, 'short_and_qualified_dns_match_services': True,
                      'cross_namespace_http_succeeds_without_network_policy': True}))
finally:
    for namespace in reversed(created):
        k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

每个命名空间都有名为 settings 的 ConfigMap、名为 web 的 Pod 和名为 api 的 Service。两份配置分别保留 A 与 B 值。带着任一命名空间查询 Node,得到的仍是同一组集群级节点身份。这验证了名称范围,而没有把共享节点想象成两套各自独立的容量池。

测试服务账号只获准读取第一个命名空间的配置。以它的身份发出的请求,在本地成功,在第二个范围返回 Forbidden。随后 ResourceQuota 把第一个命名空间的 Service 数量限制为一;确认已有使用量后,再创建另一个服务会被拒绝。这验证真实准入决策,而不是仅检查配额对象已经存在。

最后,从第一个 Pod 发出的请求把短名称 api 解析为本地 Service,把第二个命名空间的完整服务名称解析到对应地址,并通过 HTTP 收到 B。两个命名空间都没有 NetworkPolicy,全部断言运行通过。这个结果展示命名、API 授权、一种数量配额及网络可达性各自独立成立。

实验没有证明网络政策已经被执行,没有验证 CPU 公平性,也没有建立面对敌对租户的完整隔离。真实系统需要哪些边界,就应分别审查并实际测试,不能用属于不同命名空间这一事实代替所有安全和容量要求。

来源