前言
去年参与了一次K8s安全审计,发现我们集群的一个重大安全隐患:所有kubelet都使用了同一个cluster-admin证书!这意味着任何一个节点被攻破,攻击者都能控制整个集群。
正确的做法应该是:为每个kubelet颁发独立的客户端证书,并启用Node鉴权模式。Node鉴权会限制kubelet只能操作自己节点上的Pod,即使证书泄露,影响范围也仅限于单个节点。
今天我们就深入源码,看看Node鉴权是如何实现的。
为什么需要Node鉴权?
kubelet的权限困境
kubelet是运行在每个节点上的代理,它需要执行很多操作:
- 读取绑定到本节点的Pod信息
- 更新Pod状态
- 读取Secret和ConfigMap(为Pod挂载卷)
- 创建事件
- 更新节点状态
如果没有限制,kubelet可以:
- 读取所有节点的所有Pod
- 读取整个集群的所有Secret(包括其他namespace的)
- 删除任意Pod
这显然是不可接受的。
Node鉴权的核心思想
┌─────────────────────────────────────────────────────────────────┐
│ Node鉴权核心思想 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Node A │───────→│ Pod X │───────→│ Secret │ │
│ │ (kubelet) │ │ (绑定到A) │ │ (Pod使用) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ │ 只能访问 │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Node B │──X────→│ Pod Y │ ← 不能访问其他节点Pod │
│ │ (kubelet) │ │ (绑定到B) │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Node鉴权的基本原则:
- kubelet只能操作绑定到自己节点上的Pod
- kubelet只能读取被Pod引用的Secret/ConfigMap/PVC
- kubelet不能读取其他节点的任何资源
Node鉴权的4条规则
Node鉴权器的逻辑非常清晰,遵循4条规则:
// 规则1: 如果不是来自node的请求,不表态(让其他鉴权器处理)
if !isNode {
return DecisionNoOpinion
}
// 规则2: 如果无法识别node名称,拒绝
if len(nodeName) == 0 {
return DecisionNoOpinion, "unknown node"
}
// 规则3: 如果是敏感资源(secret/configmap/pv/pvc),需要检查绑定关系
if resource in [secret, configmap, pvc, pv] {
// - 只允许get操作
// - 检查资源是否被绑定到该节点的Pod引用
return checkBinding(nodeName, resource)
}
// 规则4: 其他资源使用静态RBAC规则
return checkStaticRules(attrs)
源码解析:NodeAuthorizer的实现
Authorize方法入口
// plugin/pkg/auth/authorizer/node/node_authorizer.go
func (r *NodeAuthorizer) Authorize(
ctx context.Context,
attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
// ===== 规则1: 识别请求者是否为kubelet =====
nodeName, isNode := r.identifier.NodeIdentity(attrs.GetUser())
if !isNode {
// 不是kubelet,不表态,让其他鉴权器处理
return authorizer.DecisionNoOpinion, "", nil
}
// ===== 规则2: 识别具体的node名称 =====
if len(nodeName) == 0 {
klog.V(2).Infof("NODE DENY: unknown node for user %q", attrs.GetUser().GetName())
return authorizer.DecisionNoOpinion,
fmt.Sprintf("unknown node for user %q", attrs.GetUser().GetName()),
nil
}
// ===== 规则3: 敏感资源的特殊处理 =====
if attrs.IsResourceRequest() {
requestResource := schema.GroupResource{
Group: attrs.GetAPIGroup(),
Resource: attrs.GetResource(),
}
switch requestResource {
case secretResource:
// Secret只能读,且必须是被Pod引用的
return r.authorizeReadNamespacedObject(nodeName, secretVertexType, attrs)
case configMapResource:
// ConfigMap只能读,且必须是被Pod引用的
return r.authorizeReadNamespacedObject(nodeName, configMapVertexType, attrs)
case pvcResource:
// PVC可以读,也可以update status(如果启用了ExpandPersistentVolumes)
if r.features.Enabled(features.ExpandPersistentVolumes) {
if attrs.GetSubresource() == "status" {
return r.authorizeStatusUpdate(nodeName, pvcVertexType, attrs)
}
}
return r.authorizeGet(nodeName, pvcVertexType, attrs)
case pvResource:
// PV只能读
return r.authorizeGet(nodeName, pvVertexType, attrs)
case vaResource:
// VolumeAttachment只能读
return r.authorizeGet(nodeName, vaVertexType, attrs)
case svcAcctResource:
// ServiceAccount可以创建token(用于ServiceAccountTokenVolume)
return r.authorizeCreateToken(nodeName, serviceAccountVertexType, attrs)
case leaseResource:
// Lease用于节点心跳
return r.authorizeLease(nodeName, attrs)
case csiNodeResource:
// CSINode用于CSI驱动
return r.authorizeCSINode(nodeName, attrs)
}
}
// ===== 规则4: 其他资源使用静态RBAC规则 =====
if rbac.RulesAllow(attrs, r.nodeRules...) {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionNoOpinion, "", nil
}
NodeIdentity:如何识别kubelet
Node鉴权器使用NodeIdentifier来识别请求是否来自kubelet,并获取节点名称:
// staging/src/k8s.io/apiserver/pkg/authentication/node/node_identifier.go
type DefaultNodeIdentifier struct{}
func (DefaultNodeIdentifier) NodeIdentity(user user.Info) (string, bool) {
name := user.GetName()
// kubelet的用户名格式:system:node:<node-name>
if !strings.HasPrefix(name, "system:node:") {
return "", false
}
// 提取node名称
nodeName := strings.TrimPrefix(name, "system:node:")
return nodeName, true
}
关键点:
- kubelet必须使用
system:node:<node-name>格式的用户名 - 这是X.509证书的CommonName(CN字段)
证书示例:
# 为kubelet创建证书
openssl req -new -key kubelet.key -out kubelet.csr \
-subj "/CN=system:node:node-1/O=system:nodes"
# CN = system:node:node-1
# O = system:nodes(用户组)
authorizeReadNamespacedObject:敏感资源的鉴权
对于Secret、ConfigMap等敏感资源,Node鉴权器会执行更严格的检查:
func (r *NodeAuthorizer) authorizeReadNamespacedObject(
nodeName string,
startingType vertexType,
attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
// 1. 只允许读操作(get/list/watch)
switch attrs.GetVerb() {
case "get", "list", "watch":
// ok
default:
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion,
"can only read resources of this type",
nil
}
// 2. 不允许读取子资源
if len(attrs.GetSubresource()) > 0 {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion,
"cannot read subresource",
nil
}
// 3. 必须有namespace
if len(attrs.GetNamespace()) == 0 {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion,
"can only read namespaced object of this type",
nil
}
// 4. 检查资源是否被该节点的Pod引用
return r.authorize(nodeName, startingType, attrs)
}
authorize:检查资源绑定关系
这是Node鉴权的核心逻辑——检查资源是否被节点的Pod引用:
func (r *NodeAuthorizer) authorize(
nodeName string,
startingType vertexType,
attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
// 必须有资源名称
if len(attrs.GetName()) == 0 {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "No Object name found", nil
}
// 检查是否存在从Node到该资源的路径
ok, err := r.hasPathFrom(
nodeName, // 起点:节点名称
startingType, // 资源类型(secret/configmap等)
attrs.GetNamespace(), // 资源namespace
attrs.GetName(), // 资源名称
)
if err != nil {
klog.V(2).InfoS("NODE DENY", "err", err)
return authorizer.DecisionNoOpinion,
fmt.Sprintf("no relationship found between node '%s' and this object", nodeName),
nil
}
if !ok {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion,
fmt.Sprintf("no relationship found between node '%s' and this object", nodeName),
nil
}
// 找到了路径,允许访问
return authorizer.DecisionAllow, "", nil
}
Pod-Node关系图:Node鉴权的核心数据结构
为什么需要关系图?
要判断"Secret是否被Node上的Pod引用",最直接的方法是:
- 查询该Node上的所有Pod
- 检查每个Pod是否引用了该Secret
但这种方法每次请求都要查询etcd,性能很差。
Node鉴权器的解决方案是:维护一个内存中的Pod-Node关系图,实时跟踪Pod调度、Secret引用等关系。
Graph数据结构
// plugin/pkg/auth/authorizer/node/graph.go
type Graph struct {
lock sync.RWMutex
// 各种类型的顶点
nodes map[types.UID]nodeVertex
pods map[types.UID]podVertex
secrets map[types.UID]secretVertex
configmaps map[types.UID]configMapVertex
pvcs map[types.UID]pvcVertex
pvs map[types.UID]pvVertex
// 邻接表,记录边关系
// 从destination可以到达哪些source
destEdgeIndex map[vertex][]edge
}
// 顶点类型
type vertex interface {
ID() types.UID
Type() vertexType
}
// Pod顶点
type podVertex struct {
uid types.UID
namespace string
name string
nodeUID types.UID // 绑定的Node
serviceAccount string
secrets []types.UID // 引用的Secret
configmaps []types.UID // 引用的ConfigMap
pvcs []types.UID // 引用的PVC
}
// 边关系
type edge struct {
from vertex
to vertex
}
关系图示例
┌─────────────────────────────────────────────────────────────────┐
│ Pod-Node关系图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Node: node-1 │ │
│ │ UID: abc-123 │ │
│ └────────┬────────┘ │
│ │ │
│ │ 调度到 │
│ ▼ │
│ ┌─────────────────┐ 引用 ┌─────────────────┐ │
│ │ Pod: web-1 │──────────────→│ Secret: db-pass │ │
│ │ UID: def-456 │ │ UID: ghi-789 │ │
│ │ NodeUID: abc-123│ 引用 └─────────────────┘ │
│ └─────────────────┘──────────────→┌─────────────────┐ │
│ │ │ ConfigMap: cfg │ │
│ │ 引用 │ UID: jkl-012 │ │
│ ▼ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ PVC: data-pvc │ │
│ │ UID: mno-345 │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
如何查询关系
// hasPathFrom检查是否存在从Node到指定资源的路径
func (r *NodeAuthorizer) hasPathFrom(
nodeName string,
startingType vertexType,
namespace, name string,
) (bool, error) {
r.graph.lock.RLock()
defer r.graph.lock.RUnlock()
// 1. 找到Node的顶点
nodeVertex, exists := r.graph.getNodeByName(nodeName)
if !exists {
return false, fmt.Errorf("node %q not found in graph", nodeName)
}
// 2. 找到目标资源的顶点
destVertex, exists := r.graph.getVertex(startingType, namespace, name)
if !exists {
return false, fmt.Errorf("destination %s/%s/%s not found in graph",
startingType, namespace, name)
}
// 3. 检查是否存在从Node到destVertex的路径
// 使用BFS或DFS遍历图
return r.graph.hasPath(nodeVertex, destVertex), nil
}
关系图的动态更新
关系图通过Informer监听资源变化,实时更新:
// plugin/pkg/auth/authorizer/node/graph_builder.go
func (b *graphBuilder) Run(stopCh <-chan struct{}) {
// 监听Pod变化
b.podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: b.addPod,
UpdateFunc: b.updatePod,
DeleteFunc: b.deletePod,
})
// 监听Node变化
b.nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: b.addNode,
UpdateFunc: b.updateNode,
DeleteFunc: b.deleteNode,
})
// 监听Secret变化
b.secretInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: b.addSecret,
UpdateFunc: b.updateSecret,
DeleteFunc: b.deleteSecret,
})
// ... 其他资源
<-stopCh
}
// Pod添加时的处理
func (b *graphBuilder) addPod(obj interface{}) {
pod := obj.(*v1.Pod)
b.graph.addPod(pod)
}
func (g *Graph) addPod(pod *v1.Pod) {
g.lock.Lock()
defer g.lock.Unlock()
vertex := &podVertex{
uid: pod.UID,
namespace: pod.Namespace,
name: pod.Name,
nodeUID: pod.Spec.NodeName, // 记录绑定的Node
}
// 提取引用的Secret
for _, volume := range pod.Spec.Volumes {
if volume.Secret != nil {
vertex.secrets = append(vertex.secrets, volume.Secret.SecretName)
}
if volume.ConfigMap != nil {
vertex.configmaps = append(vertex.configmaps, volume.ConfigMap.Name)
}
if volume.PersistentVolumeClaim != nil {
vertex.pvcs = append(vertex.pvcs, volume.PersistentVolumeClaim.ClaimName)
}
}
// 添加到图
g.pods[pod.UID] = vertex
// 添加边:Node -> Pod
if len(pod.Spec.NodeName) > 0 {
nodeUID := g.getNodeUID(pod.Spec.NodeName)
g.addEdge(nodeUID, pod.UID)
}
// 添加边:Pod -> Secret
for _, secretName := range vertex.secrets {
secretUID := g.getSecretUID(pod.Namespace, secretName)
g.addEdge(pod.UID, secretUID)
}
// ... 其他边
}
静态RBAC规则
对于非敏感资源,Node鉴权器使用预定义的静态RBAC规则:
// plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go
func NodeRules() []rbacv1.PolicyRule {
return []rbacv1.PolicyRule{
// 认证相关:可以创建tokenreviews和subjectaccessreviews
rbacv1helpers.NewRule("create").Groups(authenticationGroup).Resources("tokenreviews").RuleOrDie(),
rbacv1helpers.NewRule("create").Groups(authorizationGroup).Resources("subjectaccessreviews", "localsubjectaccessreviews").RuleOrDie(),
// 服务发现:可以读取Service
rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("services").RuleOrDie(),
// 节点管理:可以操作Node资源(结合NodeRestriction准入控制限制只能操作自己)
rbacv1helpers.NewRule("create", "get", "list", "watch").Groups(legacyGroup).Resources("nodes").RuleOrDie(),
rbacv1helpers.NewRule("update", "patch").Groups(legacyGroup).Resources("nodes/status").RuleOrDie(),
// 事件:可以创建事件
rbacv1helpers.NewRule("create", "update", "patch").Groups(legacyGroup).Resources("events").RuleOrDie(),
// Pod管理:可以读取、创建、删除Pod
// NodeRestriction准入控制限制只能操作绑定到自己的Pod
rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("pods").RuleOrDie(),
rbacv1helpers.NewRule("create", "delete").Groups(legacyGroup).Resources("pods").RuleOrDie(),
rbacv1helpers.NewRule("update", "patch").Groups(legacyGroup).Resources("pods/status").RuleOrDie(),
rbacv1helpers.NewRule("create").Groups(legacyGroup).Resources("pods/eviction").RuleOrDie(),
// 证书轮转:可以操作CertificateSigningRequest
rbacv1helpers.NewRule("create", "get", "list", "watch").Groups(certificatesGroup).Resources("certificatesigningrequests").RuleOrDie(),
// 租约:用于节点心跳
rbacv1helpers.NewRule("get", "create", "update", "patch", "delete").Groups("coordination.k8s.io").Resources("leases").RuleOrDie(),
// CSI相关
rbacv1helpers.NewRule("get").Groups(storageGroup).Resources("volumeattachments").RuleOrDie(),
rbacv1helpers.NewRule("get", "watch", "list").Groups("storage.k8s.io").Resources("csidrivers").RuleOrDie(),
rbacv1helpers.NewRule("get", "create", "update", "patch", "delete").Groups("storage.k8s.io").Resources("csinodes").RuleOrDie(),
// RuntimeClass
rbacv1helpers.NewRule("get", "list", "watch").Groups("node.k8s.io").Resources("runtimeclasses").RuleOrDie(),
}
}
Node鉴权 + NodeRestriction准入控制
Node鉴权器通常与NodeRestriction准入控制插件配合使用,实现双重保护:
| 层级 | 组件 | 作用 |
|---|---|---|
| 鉴权层 | Node Authorizer | 限制kubelet能访问哪些资源 |
| 准入层 | NodeRestriction | 限制kubelet能修改哪些字段 |
NodeRestriction的作用:
// 限制kubelet只能修改自己的Node资源
// 不允许:
// - 修改Node的labels(除了kubernetes.io前缀的)
// - 修改Node的annotations
// - 删除Node
// 限制kubelet只能操作绑定到自己的Pod
// 不允许:
// - 创建不绑定到自己节点的Pod
// - 修改Pod的spec(只能修改status)
启用方式:
kube-apiserver \
--authorization-mode=Node,RBAC \
--enable-admission-plugins=NodeRestriction
配置Node鉴权
1. 启用Node鉴权模式
kube-apiserver \
--authorization-mode=Node,RBAC # Node必须在RBAC之前
注意:Node模式必须在RBAC之前,因为Node模式对kubelet有特殊的处理逻辑。
2. 为kubelet颁发证书
# 创建证书签名请求
openssl req -new -newkey rsa:2048 -nodes \
-keyout kubelet.key \
-out kubelet.csr \
-subj "/CN=system:node:node-1/O=system:nodes"
# 使用CA签名
cat > kubelet-csr.json <<EOF
{
"CN": "system:node:node-1",
"key": {
"algo": "rsa",
"size": 2048
},
"names": [
{
"O": "system:nodes"
}
]
}
EOF
cfssl gencert \
-ca=ca.pem \
-ca-key=ca-key.pem \
-config=ca-config.json \
-profile=kubernetes \
kubelet-csr.json | cfssljson -bare kubelet
3. kubelet配置
# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
x509:
clientCAFile: "/etc/kubernetes/pki/ca.crt"
webhook:
enabled: true
cacheTTL: 2m0s
authorization:
mode: Webhook
webhook:
cacheAuthorizedTTL: 5m0s
cacheUnauthorizedTTL: 30s
tlsCertFile: "/etc/kubernetes/pki/kubelet.crt"
tlsPrivateKeyFile: "/etc/kubernetes/pki/kubelet.key"
踩坑实录:Node鉴权常见问题
坑1:kubelet无法读取Secret
现象:kubelet报错cannot get secret,Pod无法启动
排查:
# 检查kubelet证书CN
openssl x509 -in /etc/kubernetes/pki/kubelet.crt -noout -subject
# Subject: CN = system:node:node-1, O = system:nodes
# 检查Pod是否调度到该节点
kubectl get pod <pod-name> -o wide
# NODE列应该是 node-1
# 检查Secret是否被Pod引用
kubectl get pod <pod-name> -o yaml | grep -A5 volumes
常见原因:
- kubelet证书CN不正确(必须是
system:node:<node-name>) - Pod没有调度到该节点
- Secret没有被Pod引用(直接volume或env引用)
坑2:Node鉴权顺序错误
现象:启用了Node鉴权,但kubelet仍有全局权限
根因:鉴权模式顺序不对
# 错误:RBAC在Node之前,RBAC可能给kubelet授权
--authorization-mode=RBAC,Node
# 正确:Node在RBAC之前,Node模式先处理kubelet请求
--authorization-mode=Node,RBAC
坑3:kubelet无法更新节点状态
现象:节点显示NotReady,但kubelet正常运行
排查:
# 查看kubelet日志
journalctl -u kubelet -f
# 检查Node对象
kubectl get node <node-name> -o yaml
可能原因:
- NodeRestriction准入控制限制了节点更新
- kubelet证书过期
- 缺少
nodes/status的update权限
坑4:镜像拉取失败
现象:Pod卡在ImagePullBackOff,但Secret存在
排查:
# 检查Secret是否被ServiceAccount引用
kubectl get serviceaccount default -o yaml
# 检查Pod是否使用了正确的ServiceAccount
kubectl get pod <pod-name> -o yaml | grep serviceAccount
# 检查imagePullSecrets
kubectl get pod <pod-name> -o yaml | grep -A3 imagePullSecrets
Node鉴权限制:kubelet只能读取被Pod直接引用的Secret,不能读取通过ServiceAccount间接引用的Secret。
安全最佳实践
1. 每个节点独立的证书
# 不要这样做:所有节点使用同一个证书
# CN=system:node (没有指定具体节点名)
# 应该这样做:每个节点有自己的证书
# CN=system:node:node-1
# CN=system:node:node-2
# CN=system:node:node-3
2. 定期轮换kubelet证书
# 使用CertificateSigningRequest自动轮换
# kubelet启动时会自动创建CSR
kubectl get csr
kubectl certificate approve <csr-name>
3. 启用NodeRestriction准入控制
kube-apiserver \
--enable-admission-plugins=NodeRestriction
4. 监控Node鉴权拒绝
# 查看apiserver日志中的NODE DENY
kubectl logs -n kube-system kube-apiserver-<pod-name> | grep "NODE DENY"
# 设置告警规则
- alert: KubeletAuthorizationDeny
expr: increase(apiserver_authorization_decisions_denied{authorizer="Node"}[5m]) > 0
总结
通过今天的分析,我们深入理解了Node鉴权机制:
- 核心思想:限制kubelet只能操作自己节点上的资源
- 4条规则:识别node→识别node名→检查敏感资源绑定关系→静态规则
- Pod-Node关系图:内存中的图结构,实时跟踪资源关系
- 静态RBAC规则:非敏感资源使用预定义规则
- 与NodeRestriction配合:实现鉴权+准入的双重保护
Node鉴权是保护K8s集群安全的重要屏障,正确使用它可以有效防止节点被攻破后的横向移动。

361

被折叠的 条评论
为什么被折叠?



