k8s技术预研10--深入分析kubernetes集群安全机制

Kubernetes过一系列机制来实现集群的安全机制,包括 API Server的认证授权、准入控制机制及保护敏感信息的Secret机制等。
集群的安全性必须考虑以下的几个目标:
(1)保证容器与其所在宿主机的隔离;
(2)限制容器给基础设施及其他容器带来消极影响的能力;
(3)最小权限原则,合理限制所有组件的权限,确保组件只执行它被授权的行为,通过限制单个组件的能力来限制他所能到达的权限范围;
(4)明确组件间边界的划分;
(5)划分普通用户和管理员角色;
(6)在必要的时候允许将管理员权限赋给普通用户;
(7)允许拥有Secret数据(Keys、Certs、Passwords)的应用在集群中运行;
下面分别从Authentication、Authorization、Admission Control、Secret和Service Account等方面来说明集群的安全机制。

1、API Server认证管理(Authentication)

我们知道,Kubernetes集群中所有资源的访问和变更都是通过Kubernetes API Server的REST API来实现的,所以集群安全的关键点在于如何识别并认证客户端身份(Authentication)以及随后访问权限的授权(Authorization)这两个关键问题。本节对认证管理进行说明。
Kubernetes集群提供了3种级别的客户端身份认证方式:
  • 最严格的HTTPS证书认证:基于CA根证书签名的双向数字证书认证方式;
  • HTTP Token认证:通过一个Token来识别合法用户;
  • HTTP Base认证:通过用户名+密码的方式认证;

1.1 HTTPS证书认证的原理

首先,需要一个CA证书。CA通过证书证实他人的公钥信息,而证书上有CA的签名。很多情况下,CA与用户是相互独立的实体,CA作为服务提供方,有可能因为服务质量问题而给用户带来损失。在证书中绑定了公钥数据和相应私钥拥有者的信息,并带有CA的数字签名。证书中也包含了CA的名称,以便于依赖方找到CA的公钥,验证证书上的数字签名。

CA认证涉及诸多概念,比如根证书、自签名证书、密钥、私钥、加密算法及HTTPS等。
下面通过大致讲述SSL协议的流程,帮助理解CA认证和Kubernetes CA认证的配置过程。
(1)HTTPS通信双方的务器端向CA机构申请证书,CA机构是可信的第三方机构,它可以是一个公认的权威的企业,也可以是企业自身。企业内部系统一般都使用企业自身的认证系统。CA机构下发根证书、服务端证书及私钥给申请者;
(2)HTTPS通信双方的客户端向CA机构申请证书,CA机构下发根证书、客户端证书及私钥给申请者;
(3)客户端向服务器端发起请求,服务端下发服务端证书给客户端。客户端接收到证书后,通过私钥解密证书,并利用服务器端证书中的公钥认证证书信息比较证书里的消息,例如域名和公钥与服务器刚刚发送的相关消息是否一致,如果一致,则客户端认可这个服务器的合法身份;
(4)客户端发送客户端证书给服务器端,服务端接收到证书后,通过私钥解密证书,获得客户端的证书公钥,并用该公钥认证证书信息,确认客户端是否合法;
(5)客户端通过随机秘钥加密信息,并发送加密后的信息给服务器端。服务器端和客户端协商好加密方案后,客户端会产生一个随机的秘钥,客户端通过协商好的加密方案,加密该随机秘钥,并发送该随机秘钥到服务器端。服务器端接收这个秘钥后,双方通信的所有内容都通过该随机秘钥加密。

上述是双向SSL协议的具体通信过程,这种情况要求服务器和用户双方都有证书。单向认证SSL协议不需要客户拥有CA证书,对应上面的步骤,只需将服务器端验证客户端证书的过程去掉,以及在协商对称密码方案和对称通话秘钥时,服务器端发送给客户端的是没有加过密的(这并不影响SSL过程的安全性)密码方案。

1.2 HTTP Token的认证原理

HTTP Token的认证是用一个很长的特殊编码方式的并且难以被模仿的字符串——Token来表明客户身份的一种方式。

在通常情况下,Token是一个复杂的字符串,比如我们用私钥签名一个字符串的数据就可以作为一个Token。此外每个Token对应一个用户名,存储在API Server能访问的一个文件中。当客户端发起API调用请求时,需要在HTTP Header里放入Token,这样一来API Server就能够识别合法用户和非法用户了。

1.3 HTTP Base认证原理

这种认证方式是把“用户名+冒号+密码”用BASE64算法进行编码后的字符串放在HTTP REQUEST中的Header Authorization域里发送给服务端。服务端收到后进行解码,获取用户名及密码,然后进行用户身份的鉴权过程。

2、API Server授权管理(Authorization)

对合法用户进行授权(Authorization)并且随后在用户访问时进行鉴权,是权限与安全系统的重要一环。授权就是授予不同用户不同访问权限。
API Server目前支持以下集中授权策略(通过API Server的启动参数--authorization-mode设置):
  • AlwaysDeny:拒绝所有请求,该配置一般用于测试;
  • AlwaysAllow:接收所有请求,如果集群不需要授权流程,可以采用该策略,此为Kubernetes默认的策略;
  • ABAC:(Attribute-Base Access Control)为基于属性的访问控制,表示使用用户配置的授权规则对用户的请求进行匹配和控制;
  • Webhook:通过调用外部REST服务对用户进行授权;
  • RBAC:Role-Based Access Control,基于角色的访问控制。
API Server在接收到请求后,会读取该请求中的数据,生成一个访问策略对象,如果该请求中不带某些属性,则会被自动填充默认值。然后将这个访问策略对象和授权策略文件中的所有访问策略对象逐条匹配。如果至少有一个策略对象被匹配,则该请求将被鉴权通过,否则终止API调用流程,并返回客户端的错误调用码。

2.1 ABAC授权模式详解

在API Server启用ABAC模式时,需要指定授权文件的路径和名字(--authorization_policy_file=SOME_FILENAME),授权策略文件里的每一行都是一个Map类型的JOSN对象,这被称为访问策略对象,我们可以通过设置“访问策略对象”中的如下属性来确定具体的授权策略:
(1)apiVersion: 当前版本为abac.authorization.kubernetes.io/v1beta1
(2)kind: 设置为"Policy"。
(3)spec: 详细的策略设置,包括下列字段。
  • 主体属性
    • user:用户名,字符串类型,该字符串类型的用户名来源于Token文件(--token-auth-file参数设置的文件)或基本认证文件中的用户名字段的值。
    • group:用户组,设置为"system:authenticated"时表示匹配所有已认证的请求,设置为"system:unauthenticated"时表示匹配所有未认证的请求。
  • 资源属性
    • apiGroup(API组):字符串类型,表明匹配哪些API Group,例如extensions或*(表示匹配所有API Group)。
    • namespace:字符串类型,表明该策略允许访问某个Namespace的资源,例如kube-system或*(表示匹配所有namespace)。
    • resource(资源):字符串类型,API资源对象,例如pods或*(表示匹配所有资源对象)。
  • 非资源属性
    • nonResourcePath(非资源对象类路径):非资源对象类的URL路径,例如/version或/apis,*表示匹配所有非资源对象类的请求路径,也可以设置为子路径,/foo/*表示匹配所有/foo路径下的所有子路径。
    • readonly(只读标识):布尔类型,当它的值为true时,表明仅允许GET请求通过。
1)ABAC授权算法
API Server进行ABAC授权的算法如下:在API Server收到请求之后,这些请求携带的策略对象的属性就被组装为一个数组,接下来使用策略文件对其进行逐条匹配。如果至少一行匹配成功,那么这个请求就通过了授权(不过还是可能会在后续其他授权校验中失败)。
常见的配置策略如下:
  • 要允许所有用户做某件事,可以写一个策略,将group属性设置为system:authenticated;
  • 要允许所有未认证用户做某件事,可以把策略的group属性设置为system:unauthenticated;
  • 要允许一个用户做任何可,将策略的apiGroup、namespace、resource和nonResourcePath属性设置为"*"即可。
2)使用kubectl时的授权机制
kubectl 使用API Server的/api和/apis端点来获取版本信息。
要验证kubectl create/update命令发送给服务器的对象,kubectl需要向swagger资源进行查询,k8s v1版的API对应的是/swaggerapi/api/v1和swaggerapi/experimental/v1。
当使用ABAC授权模式时,下列特殊资源必须显式地用nonResourcePath属性来表达:
  • API版本协商过程中的/api、/api/*、/apis和/apis/*。
  • 使用kubectl version命令从服务器获取版本时的/version。
  • create/update操作过程中的/swaggerapi/*。
在使用kubectl操作时,如果需要查看发送到API Server的HTTP请求,可以将日志级别设置为8,例如:
# kubectl --v=8 version

下面通过几个授权策略文件(JSON格式)示例说明ABAC的访问控制用法。
(1)允许用户alice对所有资源做任何操作
{"apiVersion": " abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "alice", "namespace": "*", "resource": "*", "apiGroup": "*"}}

(2)kubelet可以读取任意Pod
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "kubelet", "namespace": "*", "resource": "pods", "readonly": "true"}}

(3)kubelet可以读写Event对象
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "kubelet", "namespace": "*", "resource": "events"}}

(4)用户bob只能读取projectCaribou中的Pod
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "bob", "namespace": " projectCaribou", "resource": "pods", "readonly": true}}

(5)任何认证用户都可以对非资源类路径进行只读请求
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"group": "system:authenticated", "readonly": true, "nonResourcePath": "*"}}
任何非认证用户都可以对非资源类路径进行只读请求
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"group": "system:unauthenticated", "readonly": true, "nonResourcePath": "*"}}

如果添加了新的ABAC策略,则需要重启API Server以使其生效。

3)Service Account与授权
Service Account会自动生成一个用户(user),用户的名称按照以下规则产生:
system:serviceaccount:<namespace>:<serviceaccountname>
创建新的命名空间时,会产生一个如下形式的Service Account:
system:serviceaccount:<namespace>:default

如果希望kube-system命名空间中的Service Account——"default"具有全部权限,就要在策略文件中加入如下内容:
{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "system:serviceaccount:kube-system:default", "namespace": " *", "resource": "*", "apiGroup": "*"}}

2.2 Webhook授权模式详解

Webhook定义了一个HTTP回调接口,实现Webhook的应用会在指定事件发生时,向一个URL地址POST通知信息。启用Webhook授权模式后,k8s会调用外部REST服务对用户进行授权。
Webhook模式用参数--authorization-webhook-config-file=SOME_FILENAME来设置远端授权服务的信息。

配置文件使用的是kubeconfig文件的格式。文件里user一节的内容指的是API Server。相对于远程授权服务来说,API Server是客户端,也就是用户。cluster一节的内容是远程授权服务器的配置。
下面的例子为设置一个使用HTTPS客户端认证的配置:
clusters:
  - name: name-of-remote-authz-service
    cluster:
      certificate-authority: /path/to/ca.pem   # 用于验证远端服务的CA
      server: https://authz.example.com/authorize  # 远端服务的URL,必须使用HTTPS
users:
  - name: name-of-api-server
    user:
      client-certificate: /path/to/cert.pem # Webhook插件使用的证书
      client-key: /path/to/key.pem  # 证书的key
current-contex: webhook    # kubeconfig文件需要设置context
contexts:
  - context:
    cluster: name-of-remote-authz-service
    user: name-of-api-server
  name: webhook

在授权开始时,API Server会生成一个api.authorization.v1beta1.SubjectAccessReview对象,用于描述操作信息,在进行JSON序列化之后POST出来。这个对象中包含了用户尝试访问资源的请求动作的描述,以及被访问资源的属性。

Webhook API对象和其他API对象一样,遵循同样的版本兼容性规则,在实现时要注意apiVersion字段的版本,以实现正确的反序列化操作。
另外,API Server必须在启用时启用authorization.k8s.io/v1beta1 API扩展(--runtime-config=authorization.k8s.io/v1beta1=true)。
下面是一个希望获取Pod列表的请求报文示例:
{
"apiVersion": "authorization.k8s.io/v1beta1",
"kind": "SubjectAccessReview",
"spec": {
  "resourceAttributes": {
    "namespace": "kittensandponies",
    "verb": "get",
    "group": "unicorn.example.org",
    "resource": "pods"
  },
  "user": "jane",
  "group": [
    "group1",
    "group2"
    ]
  }
}

远端服务需要填充请求中的SubjectAccessReviewStatus字段,并返回允许或不允许访问的结果。应答报文中的spec字段是无效的,也可以省略。
一个返回“允许访问”的应答报文示例如下:
{
  "apiVersion": "authorization.k8s.io/v1beta1",
  "kind": "SubjectAccessReview",
  "status": {
    "allowed": true
  }
}

一个返回”不允许访问“的应答报文示例如下:
{
  "apiVersion": "authorization.k8s.io/v1beta1",
  "kind": "SubjectAccessReview",
  "status": {
    "allowed": false,
    "reason": "user does not have read access to the namespace"
  }
}

非资源的访问请求路径包括/api、/apis、/metrics、/resetMetrics、/logs、/debug、/healthz、/swagger-ui、/swaggerapi、/ui和/version。
通常可以对/api、/api/*、/apis、/apis/*和/version对于客户端发现服务器提供的资源和版本信息给予”允许“的授权。对于其他非资源的访问一般可以禁止,以限制客户端对API Server进行没必要的查询。
查询/debug的请求报文示例如下:
{
  "apiVersion": "authorization.k8s.io/v1beta1",
  "kind": "SubjectAccessReview",
  "spec": {
    "nonResourceAttributes": {
      "path": "/debug",
      "verb": "get"
    },
    "user": "jane",
    "group": [
      "group1",
      "group2"
    ]
  }
}

2.3 RBAC授权模式详解

RBAC,基于角色的访问控制,在k8s v1.5中引入,在v1.6版本时升级为Beta版本,并成为kubeadm安装方式下的默认选项。
RBAC具有如下优势:
  • 对集群中的资源和非资源权限均有完整的覆盖。
  • 整个RBAC完全由几个API对象完成,同其他API对象一样,可以用kubectl或API进行操作。
  • 可以在运行时进行调整,无须重新启动API Server。
要使用RBAC授权模式,则需要在API Server的启动参数中加上--authorization-mode=RBAC .

下面对RBAC的原理和用法进行说明。

1)RBAC的API资源对象说明

RBAC引入了4个新的顶级资源对象:
  • Role
  • ClusterRole
  • RoleBinding
  • ClusterRoleBinding
同其他API资源对象一样,用户可以使用kubectl或API调用等方式操作这些资源对象。
(1)角色(Role)
一个角色就是一组权限的集合,这里的权限都是许可形式的,不存在拒绝的规则(效果类似于白名单)。在一个命名空间中,可以用角色来定义一个角色,如果是集群级别的,就需要使用ClusterRole了。
角色只能对命名空间内的资源进行授权。下面的例子中定义的角色具备读取Pod的权限:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  namespace: default
  name: pod-reader
rules:
- apiGroups: [""]    # ""空字符串,表示核心API群
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

rules中的参数说明如下:
  • apiGroups: 支持的API组列表,例如"apiVersion: batch/v1"、"apiVersion: extensions:v1beta1"、"apiVersion:apps/v1beta1"等。
  • resources:支持的资源对象列表,例如pods、deployments、jobs等。
  • verbs:对资源对象的操作方法列表,例如get、watch、list、delete、replace、patch等。

(2)集群角色(ClusterRole)
集群角色除了具有和角色一致的命名空间内资源的管理能力,因其集群级别的生效范围,还可以用于以下特殊元素的授权管理上:
  • 集群范围的资源,如Node。
  • 非资源型的路径,如"/healthz"。
  • 包含全部命名空间的资源,例如pods(用于kubectl get pods --all-namespaces这样的操作授权)

下面的集群角色可以让用户有权访问任意一个或所有命名空间的secrets(视其绑定方式而定):
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  # ClusterRole不受限于命名空间,所以省略了namespace name的定义
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "watch", "list"]


(3)角色绑定(RoleBinding)和集群角色绑定(ClusterRoleBinding)
角色绑定或集群角色绑定用来把一个角色绑定到一个目标上,绑定目标可以是User(用户)、Group(组)或者Service Account。使用RoleBinding可以为某个命名空间授权,使用ClusterRoleBinding可以为集群范围内授权。
RoleBinding可以引用Role进行授权。
下例中的RoleBinding将在default命名空间中把pod-reader角色授予用户jane,这一操作让jane可以读取default命名空间中的Pod:
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: read-pods
  namespace: default
subjects:
- kind: User
    name: jane
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

RoleBinding也可以引用ClusterRole进行授权。
RoleBinding可以引用ClusterRole,对属于同一命名空间内ClusterRole定义的资源主体进行授权。一种很常用的做法就是,集群管理员为集群范围预定义好一组角色(ClusterRole),然后在多个命名空间中重复使用这些ClusterRole。这样可以大幅提高授权管理工作效率,也使得各个命名空间下的基础性授权规则与使用体验保持一致。
例如下面, 虽然secret-reader是一个集群角色,但是因为使用了RoleBinding,所以dave只能读取development命名空间中的secret。
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: read-secrets
  namespace: development   # 集群角色中,只有在development命名空间中的权限才能赋予dave
subjects:
- kind: User
  name: dave
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: secret-reader
  apiGroup: rbac.authorization.k8s.io

ClusterRoleBinding,集群角色绑定中的角色只能是集群角色。用于进行集群级别或者对所有命名空间都生效的授权。
下面的例子中允许manager组的用户读取任意namespace中的secret:
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: read-secrets-global
subjects:
- kind: Group
  name: manager
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: secret-reader
  apiGroup: rbac.authorization.k8s.io

下图展示了上述对Pod的get/watch/list操作进行授权的Role和RoleBinding逻辑关系。


2)对资源的引用方式

多数资源可以用其名称的字符串来表达,也就是Endpoint中的URL相对路径,例如pods。
然而,某些k8s API包含下级资源,例如Pod的日志(logs)。Pod日志的Endpoint是GET/api/v1/namespaces/{namespace}/pods/{name}/log 。
在这个例子中,Pod是一个命名空间内的资源,log就是一个下级资源。要在RBAC角色中体现,则需要用斜线“/”来分隔资源和下级资源。
若想授权让某个主体同时能够读取Pod和Pod log,则可以配置resources为一个数组:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  namespace: default
  name: pod-and-pod-logs-reader
rules:
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list"]

资源还可以通过名字(ResourceName)进行引用(这里指的是资源实例的名子)。在指定ResourceName后,使用get、delete、update和patch动词的请求,就会被限制在这个资源实例的范围内。
例如下面的声明让一个主体只能对一个configmap进行get和update操作:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  namespace: default
  name: configmap-updater
rules:
- apiGroups: [""]
  resources: ["configmap"]
  resourceNames: ["my-configmap"]
  verbs: ["update", "get"]
可想而知,resourceName这种用法对list、watch、create或deletecollection操作是无效的。这是因为必须要通过URL进行鉴权,而 资源名称在list、watch、create或deletecollection请求中只是请求Body数据的一部分

3)常用的角色(Role)示例

下面的例子只展示了rules部分的内容。
(1)允许读取核心API组中的Pod资源
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
(2)允许读写"extensions"和"apps"两个API组中的"deployments"资源
rules:
- apiGroups: ["extensions", "apps"]
  resources: ["deployments"]
  verbs: ["get", "list" , "watch", "create", "update", "patch", "delete"]
(3)允许读取"pods"及读写"jobs"
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
- apiGroups: ["batch", "extensions"]
  resources: ["jobs"]
   verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
(4)允许读取一个名为"my-config"的ConfigMap(必须绑定到一个RoleBinding来限制到一个namespace下的ConfigMap)
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["my-config"]
  verbs: ["get"]
(5)读取核心组的"node"资源(Node属于集群级的资源,所以必须存在于ClusterRole中,并使用ClusterRoleBinding进行绑定)
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
(6)允许对非资源端点/healthz及其所有子路径进行GET和POST操作(必须使用ClusterRole和ClusterRoleBinding)
rules:
- nonResourceURLs: ["/healthz", "/healthz/*"]
  verbs: ["get", "post"]

4)常用的角色绑定(RoleBinding)示例

注意,下面的例子中只包含subjects部分的内容。
(1)用户名"alice@example.com"
subjects:
- kind: User
  name: "alice@example.com"
  apiGroup: rbac.authorization.k8s.io
(2)组名"frontend-admins"
subjects:
- kind: Group
  name: "frontend-admins"
  apiGroup: rbac.authorization.k8s.io
(3)kube-system命名空间中的默认Service Account
subjects:
- kind: ServiceAccount
  name: default
  namespace: kube-system
(4)"qa"命名空间中的所有Service Account
subjects:
- kind: Group
  name: system:serviceaccounts:qa
  apiGroup: rbac.authorization.k8s.io
(5)所有Service Account
subjects:
- kind: Group
  name:  system:serviceaccounts
  apiGroup: rbac.authorization.k8s.io
(6)所有认证用户(v1.5版本以上)
subjects:
- kind: Group
  name:  system:authenticated
  apiGroup: rbac.authorization.k8s.io
(7)所有未认证用户(v1.5版本以上)
subjects:
- kind: Group
  name:  system:unauthenticated
   apiGroup: rbac.authorization.k8s.io
(8)全部用户(v1.5版本以上)
subjects:
- kind: Group
  name:  system:authenticated
    apiGroup: rbac.authorization.k8s.io
- kind: Group
  name:  system:unauthenticated
   apiGroup: rbac.authorization.k8s.io

5)默认的角色和角色绑定

API Server 创建了一系列的默认 ClusterRole 和 ClusterRoleBinding 对象, 其中许多对象以 "system:" 前缀开头,代表其中绑定的资源是作为基础设施适用和占有的。修改这些资源会导致整个集群不可用。一个例子是 system:node 。这个ClusterRole角色拥有一系列的kubelet 权限,如果这个集群角色被修改了,可能会让kubelet 出现异常。
所有默认的集群角色 (ClusterRole) 和其角色绑定(role binding)都带有如下标记
kubernetes.io/bootstrapping=rbac-defaults

下面对一些常见的默认ClusterRole和ClusterRoleBinding对象进行说明。
对系统角色的说明如下表所示:

默认的ClusterRole
默认的ClusterRoleBinding
描述
system:basic-user
system:authenticated和system:unauthenticated组
让用户能够读取自身的信息
system:discovery
system:authenticated和system:unauthenticated组
对API发现Endpoint的只读访问,用于API级别的发现和协商
对用户角色的说明如下表所示:
默认的ClusterRole
默认的ClusterRoleBinding
描述
cluster-admin
system:masters组
让超级用户可以对任何资源执行任何操作。如果在ClusterRoleBinding中使用,则影响的是整个集群的所有namespace中的任何资源;如果使用的是RoleBinding,则能控制这一绑定的namespace中的资源,还包括namespace本身。
cluster-status
None
可以对基础集群状态信息进行只读访问。
admin
None
允许admin访问,可以限制在一个namespace中使用RoleBinding。如果在RoleBinding中使用,则允许对namespace中大多数资源进行读写访问,其中包含创建角色和角色绑定的能力。这一角色不允许操作namespace本身,也不能写入资源限额。
edit
None
允许对命名空间内的大多数资源进行读写操作,不允许查看或修改角色,以及角色绑定。
view
None
允许对多数对象进行只读操作,但是对角色、角色绑定及secret是不可访问的。

注:有些默认角色不是以"system:"为前缀的,这部分角色是针对用户的。其中包含超级用户角色(cluster-admin),有的用于集群一级的角色(cluster-staus),还有针对namespace的角色(admin,edit,view)。


对核心Master组件角色的说明如下表所示:
默认的ClusterRole
默认的ClusterRoleBinding
描述
system:kube-scheduler
system:kube-scheduler用户
能够访问kube-scheduler组件所需的资源
system:kube-controller-manager
system:kube-controller-manager用户
能够访问kube-controller-manager组件所需的资源,不同的控制所需的不同权限参见下表。
system:node
system:nodes组
允许访问kubelet所需的资源,包括对secret的读取,以及对Pod的写入。未来会把上面的两个权限限制在分配到本Node的对象上。今后的鉴权过程,kubelet必须以system:node及一个system:node形式的用户名进行。参看https://pr.k8s.io/40476
system:node-proxier
system:kube-proxy用户
允许访问kube-proxy所需的资源
system:kube-scheduler
system:kube-scheduler用户
能够访问kube-scheduler组件所需的资源
对其他组件角色的说明如下表所示:
默认的ClusterRole
默认的ClusterRoleBinding
描述
system:auth-delegator
None
允许对授权和认证进行托管,通常用于附加的API服务器
system:heapster
None
Heapster组件的角色
system:kube-aggregator
None
kube-aggregator的角色
system:kube-dns
在kube-system namespace中kube-dns的Service Account
kube-dns角色
system:node-bootstrapper
None
允许访问kubelet TLS启动所需的资源
system:node-problem-detector
None
允许访问node-problem-detector组件所需的资源
system:persistent-volume-provisioner
None
允许访问多数动态卷供给所需的资源
对Controller角色的说明如下表所示:
Controller角色
system:controller:attachdetach-controller
system:controller:certificate-controller
system:controller:cronjob-controller
system:controller:daemon-set-controller
system:controller:deployment-controller
system:controller:disruption-controller
system:controller:endpoint-controller
system:controller:generic-garbage-collector
system:controller:horizontal-pod-autoscaler
system:controller:job-controller
system:controller:namespace-controller
system:controller:node-controller
system:controller:persistent-volume-binder
system:controller:pod-garbage-collector
system:controller:replicaset-controller
system:controller:replication-controller
system:controller:route-controller
system:controller:service-account-controller
system:controller:service-controller
system:controller:statefulset-controller
system:controller:ttl-controller
K8s Controller Manager负责的是核心控制流。如果启用--user-service-account-credentials,则每个控制过程都会使用不同的Service Account启动。因此就有了对应各个控制过程的角色,前缀是system:controller。如果未启用这一功能,则将使用各自的凭据运行各个控制流程,这就需要为该凭据授予所有相关角色。

6)授权注意事项:预防提权和授权初始化

RBAC API拒绝用户利用编辑角色或者角色绑定的方法进行提权。这一限制是在API层面做出的,因此即使RBAC没有启动也仍然有效。
用户只能在拥有一个角色的所有权限,且与该角色的生效范围一致的前提下,才能对角色进行创建和更新。
要让一个用户能够创建或更新角色,需要:
  • 为其授予一个允许创建/更新Role或ClusterRole资源对象的角色;
  • 为用户授予角色,要覆盖该用户所能控制的所有权限范围。用户如果尝试创建超出其自身权限的角色或集群角色,则该API调用会被禁止。

如果一个用户的权限包含了一个角色的所有权限,那么就可以为其创建和更新角色绑定。或者如果被授予了针对某个角色的绑定授权,则也有权完成此操作。
要使用户能够创建、更新这一角色绑定或者集群角色绑定的角色,需要有如下做法:
  • 为其授予一个允许其创建和更新角色绑定或者集群角色绑定的角色;
  • 为其授予绑定某一角色的权限。

需要注意的是,在进行第1个角色绑定时,必须让初始用户具备其尚未被授予的权限,要进行初始的角色和角色绑定设置,有以下两种办法:
  • 使用属于system:masters组的身份,这一群组默认具有cluster:admin这一超级角色的绑定。
  • 如果API Server以--insecure-port参数运行,则客户端通过这个非安全端口进行接口调用,这一端口没有认证鉴权的限制。
7)对Service Account的授权管理
默认的RBAC策略为控制平台组件、节点和控制器授予有限范围的权限,但是在"kube-system"之外的Service Account是没有任何权限的。除了所有认证用户都具有的discovery权限。
在使用中,要求用户为Service Account赋予所需的权限。细粒度的角色分配能够提高安全性,但也会提高管理成本。粗放的授权方式可能会给Service Account多余的权限,但会更易于管理。

下面的实践以安全性递减的方式排序。
(1)为一个应用专属的Service Account赋权(最佳实践)
这个应用需要在Pod的Spec中指定一个serviceAccountName,用API、Application Manifest、kubectl create serviceaccount命令等创建Service Account,例如为"my-namespace"中的"my-sa"Service Account授予只读权限:
$ kubectl create rolebinding my-sa-view --clusterrole=view --serviceaccount=my-namespace:my-sa --namespace=my-namespace

(2)为一个命名空间中的"default" Service Account授权
如果一个应用没有指定serviceAccountName,则会使用"default" Service Account。注意:赋给"default" Service Account的权限会让所有没指定serviceAccountName的Pod都具有这些权限。
例如在"my-namespace"命名空间里为"default" Service Account授予只读权限:
$ kubectl create rolebinding default-view --clusterrole=view --serviceaccount=my-namespace:default --namespace=my-namespace

目前不少Add-Ons在"kube-system"命名空间中用"default" Service Account运行。要让这些Add-Ons能够使用超级用户权限,则可以把cluster-admin权限赋予"kube-system"的"default" Service Account。
注意:这一操作意味着"kube-system"命名空间包含了通向API超级用户的一个捷径!
$ kubectl create clusterrolebinding add-on-cluster-admin --clusterrole=cluster-admin --serviceaccount=kube-system:default

(3)为命名空间内的所有Service Account授予一个角色
例如,为"my-namespace"命名空间中的所有Service Account赋予只读权限:
$ kubectl create rolebinding serviceaccounts-view --clusterrole=view --group=system:serviceaccounts:my-namespace --namespace=my-namespace

(4)为集群范围内的所有Service Account授予一个低权限角色
例如,为所有命名空间中的所有Service Account授予只读权限:
$ kubectl create clusterrolebinding serviceaccounts-view --clusterrole=view --group=system:serviceaccounts

(5)为所有Service Account授予超级用户权限(非常危险)
$ kubectl create clusterrolebinding serviceaccouns-cluster-admin --clusterrole=cluster-admin --group=system:serviceaccounts

8)使用kubectl 命令工具创建资源对象

除了使用yaml配置文件来创建这些资源对象,也可以直接使用kubectl工具对它们进行创建。
下面通过几个例子进行说明。
(1)在命名空间acme内为用户bob授权admin ClusterRole
kubectl create rolebinding bob-admin-binding --clusterrole=admin --user=bob --namespace=acme
(2)在命名空间acme内为名为myapp的Service  Account授予view ClusterRole
kubectl create rolebinding myapp-view-binding --clusterrole=view --serviceaccount=acme:myapp --namespace=acme
(3)在全集群范围内为用户root授权cluster-admin ClusterRole
kubectl create clusterrolebinding root-cluster-admin-binding --clusterrole=cluster-admin --user=root
(4)在全集群范围内为用户kubelet授予system:node ClusterRole
kubectl create clusterrolebinding kubelet-node-binding --clusterrole=system:node --user=kubelet
(5)在全集群范围内为名为myapp的Service Account授予view ClusterRole
kubectl create clusterrolebinding myapp-view-binding --clusterrole=view  --serviceaccount=acme:myapp

9)RBAC的Auto-reconciliation(自动恢复)功能

自动恢复从k8s v1.6版本开始引入,每次启动时,API Server都会更新默认的集群角色的缺失权限,也会刷新默认的角色绑定中缺失的主体,以防止一些破坏性的修改,保证在集群升级的情况下,相关内容也能够及时更新。
如果不希望使用这一功能,则可以将一个默认的集群角色(ClusterRole)或者角色绑定(RoleBinding)的Annotation注解"rbac.authorization.kubernetes.io/autoupdate"值设置为false。

10)从旧版本的授权策略升级到RBAC

在k8s v1.6之前,很多Deployment都使用了比较宽松的ABAC策略,包含为所有Service Account开放完全API访问。
而默认的RBAC策略是为控制台组件、节点和控制器授予了范围受限的权限,且不会为"kube-system"以外的Service Account授予任何权限。

这样一来,可能会对现有的一些工作负载造成影响,有两种办法来解决这一问题:
(1)并行认证。RBAC和ABAC同时进行,并包含传统的ABAC策略:
--authorization-mode=RBAC,ABAC --authorization-policy-file=mypolicy.jsonl
首先会由RBAC尝试对请求进行鉴权,如果未通过,那么就轮到ABAC生效。这样所有的应用只要满足RBAC或ABAC之一即可工作。
通过输出更详细的日志信息,查看以上鉴权过程和结果。直到某一天,再也不输出RBAC鉴权失败的日志了,就可以移除ABAC了。

(2)粗放管理
可以使用RBAC的角色绑定,复制一个粗话的策略。
下面的策略会让所有Service Account都具备了集群管理员权限,所有容器运行的应用都会自动接收到Service Account的认证,能够对任何API做任何事情,包括查看Secret和修改授权。
这一策略无疑是比较凶险的。
$ kubectl create clusterrolebinding permissive-binding --clusterrole=cluster-admin --user=admin --user=kubelet --group=system:serviceaccounts

2.4 Admission Control准入控制

通过认证和鉴权之后,客户端并不能得到API Server的真正响应,这个请求还需通过Admission Control所控制的一个“准入控制链”的层层考验。
Admission Control配备有一个“准入控制器”的插件列表,发送给API Server的任何请求都需要通过列表中每一个准入控制器的检查,检查不通过API Server拒绝此调用请求。
此外,准入控制器还能够修改请求参数以完成一些自动化的任务,比如Service Account这个控制器。

当前可配置的Admission Control准入控制如下:
  • AlwaysAdmit:允许所有请求;
  • AlwaysPullmages:在启动容器之前总去下载镜像,相当于在每个容器的配置项imagePullPolicy=Always
  • AlwaysDeny:禁止所有请求,一般用于测试;
  • DenyExecOnPrivileged:它会拦截所有想在Privileged Container上执行命令的请求,如果你的集群支持Privileged Container,你又希望限制用户在这些Privileged Container上执行命令,强烈推荐你使用它,其功能已经合并到DenyEscalatingExec中。
  • ImagePolicyWebhook:这个插件将允许后端的一个Webhook程序来完成admission controller的功能。ImagePolicyWebhook需要使用一个配置文件(通过kube-apiserver的启动参数--admission-control-config-file设置)定义后端Webhook的参数。目前该插件还处在Alpha版本。
  • Service Account:这个plug-in将ServiceAccount实现了自动化,默认启用,如果你想使用ServiceAccount对象,那么强烈推荐使用它。
  • SecurityContextDeny:这个插件将使用SecurityContext的Pod中的定义全部失效。SecurityContext在Container中定义了操作系统级别的安全设定(uid,gid,capabilityes,SELinux等)。在未启用PodSecurityPolicy的集群中建议启用该插件,以禁用容器设置的非安全访问权限。
  • ResourceQuota:用于资源配额管理目的,作用于namespace上,它会观察所有请求,确保在namespace上的配额不会超标。推荐在Admission Control参数列表中将这个插件安排在最后一个,以免可能被其他插件拒绝的Pod被过早分配资源。
  • LimitRanger:用于资源限制管理,作用于namespace上,确保对Pod进行资源限制。启用该插件还会为未设置资源限制的Pod进行默认设置,例如为namespace "default"中所有的Pod设置0.1CPU的资源请求。
  • InitialResources:是一个实验特性,旨在为未设置资源请求与限制的Pod,根据其镜像的历史资源的使用情况进行初始化的资源请求、限制设置。
  • NamespaceLifecycle:如果尝试在一个不存在的namespace中创建资源对象,则该创建请求将被拒绝。当删除一个namespace时,系统将会删除该namespace中所有对象,保存Pod,Service等。
  • DefaultStorageClass:为了实现共享存储的动态供应,为未指定StorageClass或PV的PVC尝试匹配默认的StorageClass,尽可能减少用户在申请PVC时所需了解的后端存储细节。
  • DefaultTolerationSeconds:这个插件为那些没有设置forgiveness tolerations并具有notready:NoExecute和unreachable:NoExecute两种taints的Pod设置默认的“容忍”时间,为5min。
  • PodSecurityPolicy:这个插件用于在创建或修改Pod时决定是否根据Pod的security context和可用的PodSecurityPolicy对Pod的安全策略进行控制。

在API Server上设置--admission-control参数,即可定制我们需要的准入控制链。
如果启用多种准入控制选项,则建议的设置(含加载顺序)如下(对k8s v1.6及以上版本建议设置):
--admission-control=NamespaceLifecycle,LimitRanger,ServiceAccount,Persistent VolumeLabel, DefaultStorageClass, ResourceQuota,  DefaultTolerationSeconds

2.5 Service Account

Servuce Account是一种账号,但他并不是给Kubernetes的集群的用户(系统管理员、运维人员、租户用户等),而是给运行在Pod里的进程用的,它为Pod里的进程提供必要的身份证明。
Pod中访问Kubernetes API Server服务的时候,是以Service方式访问服务名为kubernetes这个服务的,而kubernetes服务又只在HTTPS安全端口443上提供服务,那么如何进行身份认证呢?
通过查看源码获知这是在用一种类似HTTP Token的新的认证方式--ServiceAccount Auth,Pod中的客户端调用Kubernetes API的时候,在HTTP Header中传递了一个Token字符串,这类似于之前提到的HTTP Token认证方式,存在以下几个不同点:
  • 此处的Token的内容来源于Pod里指定路径下的一个文件(/run/secrets/kubernetes.io/serviceaccount/token),这种token是动态生成的,确切的说,是由KubernetesController进程用API Server的私钥(--service-account-private-key-file指定的私钥)签名生成的一个JWT Secret。
  • 官方提供的客户端REST框架代码里,通过HTTPS方式与API Server建立链接后,会用Pod里指定路径下的一个CA证书(/run/secrets/kubernetes.io/serviceaccount/ca.crt)验证API Server发来的证书,验证是否是被CA证书签名的合法证书。
  • API Server收到这个Token以后,采用自己的私钥(实际是使用参数service-account-key-file指定的私钥,如果此参数没有设置,则默认采用tls-private-key-file指定的参数,即自己的私钥),对token进行合法性验证。
明白原理之后。接下来分析认证过程中涉及的Pod中的三个文件:
  • /run/secrets/kubernetes.io/serviceaccount/token
  • /run/secrets/kubernetes.io/serviceaccount/ca.crt
  • /run/secrets/kubernetes.io/serviceaccount/namespace(客户端采用这里指定的namespace作为参数调用Kubernetes API)
这三个文件由于参与到Pod进程与API Server认证的过程中,起到了类似Secret(私密凭据)的作用,所以他们被称为Kubernetes Secret对象。Secret从属于ServiceAccount资源对象,属于Service Account的一部分,一个ServiceAccount对象里面可以包括多个不同的Secret对象,分别用于不同目的的认证活动。

下面通过命令来直观的加深对ServiceAccount的认识:
查看系统中ServiceAccount对象,可以看到一个名为default的Service Account对象,包含一个名为default-token-xxx的Secret,这个Secret同时是“Mountable secrets”,表明他是需要被Mount到Pod上的。
  • kubectl describe serviceaccounts
  • kubectl describe secrets default-token-xxx
default-token-xxx包括三个数据项:
  • token
  • ca.crt
  • namespace
联想到“Mountable secrets”的标记,以及之前看到的Pod中的三个文件的文件名:每个namespace下有一个名为default的默认的ServiceAccount对象,这个ServiceAccount里有一个名为Tokens的可以作作为Volume一样被Mount到Pod里的Secret,当Pod启动时这个Secret会被自动Mount到Pod的指定目录下,用来协助完成Pod中的进程访问API Server时的身份鉴权过程。

一个ServiceAccount可以包括多个Secrets对象:
  • 名为Tokens的Secret用于访问API Server的Secret,也被称为ServiceAccountSecret;
  • 名为Image Pull secrets的Secret用于下载容器镜像时的认证过程,通常镜像库运行在Insecure模式下,所以这个Secret为空;
  • 用户自定义的其他Secret,用于用户的进程;
如果一个Pod在定义时没有指定spec.service.AccountName属性,则系统会自动为其赋值为“Default”,即使用同一namespace下默认的ServiceAccount,如果某个Pod需要使用非default的ServiceAccount,需要在定义时指定:
apiVersion:v1
kind:Pod
metadata:
    name:mypod
spec:
    containers:
    - name:mycontainer
      image:
    serviceAccountName:myserviceaccount

Kubernetes之所以要创建两套独立的账号系统,原因如下:
  • User账号是给人用的,ServiceAccount是给Pod里的进程使用的,面向对象不同;
  • User账号是全局性的,ServiceAccount则属于某个具体的Namespace;
  • 通常来说,User账号是与后端的用户数据库同步的,创建一个新用户通常要走一套复杂的业务流程才能实现,ServiceAccount的创建则需要极轻量级实现方式,集群管理员可以很容易为某些特定任务组创建一个ServiceAccount。
  • 对于这两种不同的账户,其审计要求通常不同;
  • 对于一个复杂的系统来说,多个组件通常拥有各种账号的配置信息,ServiceAccount是Namespace隔离的,可以针对组件进行一对一的定义,同时具备很好的“便携性”。

下面深入分析Service Account与Secret相关的一些运行机制:
Controller manager创建了ServiceAccountController与Token Controllerl两个安全相关的控制器。
其中ServiceAccountController一直监听Service Account和Namespace的事件,如果一个Namespace中没有default Service Account,那么Service Account Controller就会为该Namespace创建一个默认的(default)的Service Account,这就是我们之前看到的每个namespace下都有一个名为default的ServiceAccount的原因。

如果Controller manager进程在启动时指定了API Server私钥(service-account-private-key-file)参数,那么Controller manager会创建Token Controller。
Token Controller也监听Service Account的事件,如果发现新建的Service Account里没有对应的Service Account Secret,则会用API Server私钥创建一个Token(JWT Token),并用该Token、CA证书Namespace名称等三个信息产生一个新的Secret对象,然后放入刚才的Service Account中;如果监听到的事件是删除Service Account事件,则自动删除与该Service Account相关的所有Secret。
此外,Token Controller对象同时监听Secret的创建、修改和删除事件,并根据事件的不同做不同的处理。

当我们在API Server的鉴权过程中启用了Service Account类型的准入控制器,即在kube-apiserver的启动参数中包括下面的内容时:
--admission_control=ServiceAccount
则针对Pod新增或修改的请求,Service Account准入控制器会验证Pod里Service Account是否合法。

    1) 如果spec.serviceAccount域没有被设置,则Kubernetes默认为其制定名字为default的Serviceaccount;
    2) 如果Pod的spec.serviceAccount域指定了default以外的ServiceAccount,而该ServiceAccount没有事先被创建,则该Pod操作失败;
    3) 如果在Pod中没有指定“ImagePullSecrets”,那么该spec.serviceAccount域指定的ServiceAccount的“ImagePullSecrets”会被加入该Pod;
    4) 给Pod添加一个新的Volume,在该Volume中包含ServiceAccountSecret中的Token,并将Volume挂载到Pod中所有容器的指定目录下(/var/run/secrets/kubernetes.io/serviceaccount);

综上所述,ServiceAccount正常运行需要以下几个控制器:
  • Admission Controller
  • Service Account Controller
  • Token Controller

2.6 Secret私密凭据

Secret主要作用是保管私密数据,比如密码、OAuth Tokens、SSH Keys等信息。将这些私密信息放在Secret对象中比直接放在Pod或Docker Image中要更安全,也便于使用和分发。
下面的例子用于创建一个Secret:
secret.yaml
apiVersion:v1
kind:Secret
metadata:
    name:mysecret
type: Opaque
data:
    password:dmfsdWUtMg0k
    username:dmfsdWUtMg0k

kubectl create -f secret.yaml
在上面的data域中的各子域的值必须为BASE64编码值,其中password域和username域BASE64编码前的值分别为value-1和value-2。

一旦Secret被创建,可以通过以下三个方式使用它:
  • 在创建Pod时,通过为Pod指定ServiceAccount来自动使用该Seret;
  • 通过挂载该Secret到Pod来使用它;
  • Docker镜像下载时使用,通过指定Pod的spec.ImagePullSecrets来引用它;
第一种方式主要用在API Server鉴权方面;
下面的例子展示了 第二种使用方式:将一个Secret通过挂载的方式添加到Pod的Volume中
apiVersion:v1
kind:Pod
metadata:
    name:mypod
    namespace:myns
spec:
    containers:
    - name:mycontainer
      image:redis
      volumeMounts:
      - name:foo
        mountPath:“/etc/foo”
        readOnly:true
volumes:
- name:foo
  secret:
      secretName:mysecret

第三种使用方式的使用流程如下:
(1)执行login命令,登录私有Registry
#docker login localhost:5000(输入账户及密码,如果是第1次登录则会创建新用户,并把相关信息写入~/.dockercfg文件中)
(2)用BASE64编码dockercfg的内容
#cat ~/.dockercfg|base64
(3)将上一步命令的输出结果作为Secret的“data.dockercfg”域的内容,由此来创建一个Secret
image-pull-secret.yaml:
apiVersion:v1
kind:Secret
metadata:
    name:myregistrykey
data
     .dockercfg:oiu09joiu09ujlih8hkjh98...
type:kubernetes.io/dockercfg

# kubectl create -f image-pull-secret.yaml
(4)在创建Pod的时候引用该Secret
pods.yaml
apiVersion:v1
kind:Pod
metadata:
  name:mypod2
spec:
  containers:
    - name:foo
    image:janedoe/awexomeapp:v1
  imagePullSecrets
    - name:myregistrykey

# kubectl create -f pods.yaml

每个单独的Secret大小不能超过1M,Kubernetes不鼓励创建大尺寸的Secret,因为如果使用大尺寸的Secret,则将大量占用API Server和kubelet的内存。
当然创建许多小的Secret也能耗尽API Server和kubelet的内存。

在使用Mount方式挂载Secret时,Container中Secret的“data”域的各个域的key值作为目录中的文件,Value值被BASE64编码后存储在相应的文件中。前面的例子中创建的Secret,被挂载到一个叫做mycontainer的container中。
在该container中可以通过命令查看所生产的文件和文件中的内容:
# ls /etc/foo
username
password
# cat /etc/foo/username
value-1
# cat /etc/foo/password
value-2

关于Secret的一些知识点:
  • 我们可以通过Secret保管其他系统的敏感信息(比如数据库用户名和密码),并以Mount的方式将Secret挂载到Container中,然后通过访问目录中的文件的方式获取该敏感信息。
  • 当Pod被API Server创建时,API Server不会校验该Pod引用的Secret是否存在。
  • 一旦这个Pod被调度,则Kubelet将试着获取Secret的值。如果Secret不存在或暂时无法连接到API Server,则kubelet将按一定的时间间隔定期重试获取该Secret,并发送一个Event来解释Pod没有启动的原因。一旦Secret被Pod获取,则Kubelet将创建并Mount包含Secret的Volume。
  • 只有所有的Volume被Mount后,Pod中的Container才会被启动。
  • 在kubelet启动Pod中container后,Container中和Secret相关的Volume将不会被改变,即使Secret本身被修改了。
  • 为了使用更新后的Secret,必须删除旧的Pod,并重新创建一个新的Pod,因此更新Secret的流程和部署一个新的Image是一样的。


参考资料:
《Kubernetes权威指南——从Docker到Kubernetes实践全接触》第3章

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值