使用 KeyCloak 对 Kubernetes 进行统一用户管理

众所周知,K8s 的权限管理体系 (不熟悉的盆友可以跳转至《Kubernetes 安全机制解读》) 中,可以将 RoleBinding 绑定到 ServiceAccount、User、Group 上来实现权限分配。

其中 ServiceAccount 我们经常用到,用来限制某个 pod 的权限;对于 User 和 Group,除了一些特殊的系统 group,并没有具体的资源与之对应,这对于传统项目中的用户管理十分不友好。

上一篇文章中讲到了 OIDC 协议在 K8s 集群中如何工作,这篇文章来具体讲讲如何在 K8s 集群中进行统一用户管理。

准备工作

首先我们需要有一个 Identity Provider 来统一管理 K8s 中的用户,并且提供 OIDC 协议服务,本文采用了 KeyCloak 作为 Identity Provider。

KeyCloak 中的配置

要想实现用户管理,我们需要利用 K8s 中 group 的概念,来对一组用户分配权限,这需要利用 OIDC 协议中的 Claim 概念,来实现 K8s 中用户的分组。

Claim 是 ID Token 中携带的信息,指的是客户端请求的信息范围,如用户名、邮箱等,而这些可以进行扩展用来携带一些用户所属 group 的信息等等。

那么第一步,我们需要扩展 KeyCloak 中的 Claim,如下图:

我们在 Client 中添加了一项 “User Attribute”,并将其加入到 ID Token 中;Multivalued 必须设置为 ON,保证 "groups" Claim 的值为一个 String 数组,其中每个值代表 User 所属的一个分组,一个 User 可以同时属于多个分组,每个值之间使用逗号分隔。

第二步就是为用户设置 "groups" 属性:

一切都设置好之后,可以在 “admin” 这个用户的 ID Token 中看到其 “groups” 属性:

{
    "jti":"9259af9c-8a3d-45ff-94f6-6780f2a79580",
    "exp":1564739637,
    "nbf":0,
    "iat":1564739337,
    "iss":"https://172.16.105.1:8082/auth/realms/hdls",
    "aud":"kubernetes",
    "sub":"f846ddb1-4435-429f-9ce5-faba7a791d43",
    "typ":"ID",
    "azp":"kubernetes",
    "auth_time":0,
    "session_state":"37b1a2ca-1b3b-4c61-ae2c-f8c14818ca6e",
    "acr":"1",
    "email_verified":false,
    "groups":[
        "manager"
    ],
    "preferred_username":"admin"
}

ApiServer 的配置

ApiServer 中留了几个可配置的环境变量,用以支持 OIDC 插件,官方链接

oidc-issuer-url:OIDC Server 的 URL,只接受 https 协议。
oidc-client-id:OIDC Server 中配置的 client_id,具有唯一性。
oidc-username-claim:指明 ID Token 中用以标识用户名称的 claim。
oidc-username-prefix:用户名前缀,“-” 表示没有前缀。
oidc-groups-claim:ID Token 中可以表明用户所在 group 的 claim,要求该 claim 必须是数组形式,所以用户可以属于多个 group。
oidc-groups-prefix:group 前缀。
oidc-ca-file:Identity Provider 的 CA 证书所在路径。

本文的配置参数如下:

    - --oidc-issuer-url=https://172.16.105.1:8082/auth/realms/hdls
    - --oidc-client-id=kubernetes
    - --oidc-username-claim=preferred_username
    - --oidc-username-prefix=-
    - --oidc-groups-claim=groups
    - --oidc-ca-file=/etc/kubernetes/pki/ca.crt

KubeConfig 的配置

作为用户,我们需要通过 Client Application 来访问 API Server,kubectl 显然是首选 Client,让 kubectl 以我们创建的用户 "admin" 的身份来访问 Kubernetes,并通过身份认证,而这需要对 KubeConfig 进行配置,来完成以下几个流程:

  1. 创建一个 kubeconfig user:"admin"。
  2. 为 "admin" 配置上 client-id,client credential,id-token,refresh-token,certficaite 等等属性。
  3. 为 "admin" 创建的一个 user context。
  4. 将它设置为当前 context。

下面的命令可以一键生成配置:

kubectl config set-credentials USER_NAME \
   --auth-provider=oidc \
   --auth-provider-arg=idp-issuer-url=( issuer url ) \
   --auth-provider-arg=client-id=( your client id ) \
   --auth-provider-arg=client-secret=( your client secret ) \
   --auth-provider-arg=refresh-token=( your refresh token ) \
   --auth-provider-arg=idp-certificate-authority=( path to your ca certificate ) \
   --auth-provider-arg=id-token=( your id_token )

Token 的获取方式

ID Token 和 Refresh Token 的生成方式有很多种,其中最简单的方式是使用 curl 进行 Password Grant 方式的身份认证,从而获取想要的 ID Token 和 Refresh Token:

$ curl -k 'https://172.16.105.1:8082/auth/realms/hdls/protocol/openid-connect/token' -d "client_id=kubernetes" -d "client_secret=40dc1fef...c3eeec6" -d "response_type=code token" -d "grant_type=password" -d "username=test" -d "password=dangerous" -d "scope=openid"
{
    "access_token":"eyJhbGciOiJSU...0CMPw",
    "expires_in":300,
    "refresh_expires_in":1800,
    "refresh_token":"eyJhbGciOiJ...W1VUA",
    "token_type":"bearer",
    "id_token":"eyJhbGc...z3TaGJGQ",
    "not-before-policy":0,
    "session_state":"2845e...92ff2",
    "scope":"openid profile email"
}

然而这每次都需要手动生成 Token 并填写进 KubeConfig 中,非常麻烦。好消息是,社区已经有很多可以帮你自动将 Token 写入 KubeConfig 中的工具,非常好用,比如:

  1. kubelogin
  2. k8s-auth-client
  3. k8s-keycloak-oidc-helper
  4. kuberos
  5. k8s-oidc-helper

用户管理

在将一切都配置成功后,我们再来看给用户分配权限。这需要考虑到 K8s 的 RBAC 系统。

RBAC

对 group 为 manager 的用户,我们对其赋予系统自带的 "cluster-admin" 角色,即为 cluster 的管理员权限:

kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: keycloak-admin-group
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: Group
  name: manager
  apiGroup: rbac.authorization.k8s.io

我们在 keyCloak 中将 admin 用户 “加入” 到 “manager” 组中:

然后用该用户访问 APIServer :

[root@172-16-105-1 ~]# kubelogin --username=admin --password=dangerous
You got a valid token until 2019-08-03 15:32:42 +0800 CST
[root@172-16-105-1 ~]#
[root@172-16-105-1 ~]# kubectl get cs
NAME                 STATUS    MESSAGE             ERROR
controller-manager   Healthy   ok
scheduler            Healthy   ok
etcd-0               Healthy   {"health":"true"}
[root@172-16-105-1 ~]# kubectl get no
NAME           STATUS   ROLES    AGE   VERSION
172-16-105-1   Ready    master   54d   v1.14.1
172-16-105-2   Ready    <none>   54d   v1.14.1
[root@172-16-105-1 ~]# kubectl get po
No resources found.
[root@172-16-105-1 ~]# kubectl get all
NAME                     TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
service/kubernetes       ClusterIP   10.96.0.1        <none>        443/TCP        54d

可以看到 “admin” 用户有所有资源的权限。

然后,我们再对 group 为 developer 的用户,新建一个角色叫做 "hdls-role",只给他们对 pod 的查看权限:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: hdls-role
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: hdls-rolebinding
roleRef:
  kind: ClusterRole
  name: hdls-role
  apiGroup: rbac.authorization.k8s.io
subjects:
- kind: Group
  name: developer
  apiGroup: rbac.authorization.k8s.io

在 keyCloak 中将 test 用户 “加入” 到 “developer” 组中:

然后用该用户访问 APIServer :

[root@172-16-105-1 ~]# kubelogin --username=test --password=dangerous
You got a valid token until 2019-08-03 15:40:21 +0800 CST
[root@172-16-105-1 ~]#
[root@172-16-105-1 ~]# kubectl get po
No resources found.
[root@172-16-105-1 ~]# kubectl get no
Error from server (Forbidden): nodes is forbidden: User "test" cannot list resource "nodes" in API group "" at the cluster scope
[root@172-16-105-1 ~]#
[root@172-16-105-1 ~]# kubectl get cs
Error from server (Forbidden): componentstatuses is forbidden: User "test" cannot list resource "componentstatuses" in API group "" at the cluster scope
[root@172-16-105-1 ~]#
[root@172-16-105-1 ~]# kubectl get all
Error from server (Forbidden): replicationcontrollers is forbidden: User "test" cannot list resource "replicationcontrollers" in API group "" in the namespace "default"
Error from server (Forbidden): services is forbidden: User "test" cannot list resource "services" in API group "" in the namespace "default"
Error from server (Forbidden): daemonsets.apps is forbidden: User "test" cannot list resource "daemonsets" in API group "apps" in the namespace "default"
Error from server (Forbidden): deployments.apps is forbidden: User "test" cannot list resource "deployments" in API group "apps" in the namespace "default"
Error from server (Forbidden): replicasets.apps is forbidden: User "test" cannot list resource "replicasets" in API group "apps" in the namespace "default"
Error from server (Forbidden): statefulsets.apps is forbidden: User "test" cannot list resource "statefulsets" in API group "apps" in the namespace "default"
Error from server (Forbidden): horizontalpodautoscalers.autoscaling is forbidden: User "test" cannot list resource "horizontalpodautoscalers" in API group "autoscaling" in the namespace "default"
Error from server (Forbidden): jobs.batch is forbidden: User "test" cannot list resource "jobs" in API group "batch" in the namespace "default"
Error from server (Forbidden): cronjobs.batch is forbidden: User "test" cannot list resource "cronjobs" in API group "batch" in the namespace "default"

test 用户除了能够获取 pod 信息,对于其他资源处处受限。

总结

本文仅仅通过 KeyCloak 和 kubectl 向大家介绍了 K8s 中如何进行用户管理,相应地,如果自己的用户中心实现 OIDC 协议,并且客户端通过 ID Token 以 "bearer token" 的方式来访问 APIServer,就能真正的将 K8s 的权限系统与上层建筑打通。

2019/8/2 posted in  Kubernetes

Kubernetes 服务发现之 coreDNS

服务发现是 K8s 的一项很重要的功能。K8s 的服务发现有两种方式,一种是将 svc 的 ClusterIP 以环境变量的方式注入到 pod 中;一种就是 DNS,从 1.13 版本开始,coreDNS 就取代了 kube dns 成为了内置的 DNS 服务器。这篇文章就来简单分析一下 coreDNS。

K8s DNS 策略

Kubernetes 中 Pod 的 DNS 策略有四种类型。

  1. Default:Pod 继承所在主机上的 DNS 配置;

  2. ClusterFirst:K8s 的默认设置;先在 K8s 集群配置的 coreDNS 中查询,查不到的再去继承自主机的上游 nameserver 中查询;

  3. ClusterFirstWithHostNet:对于网络配置为 hostNetwork 的 Pod 而言,其 DNS 配置规则与 ClusterFirst 一致;

  4. None:忽略 K8s 环境的 DNS 配置,只认 Pod 的 dnsConfig 设置。

下面主要来了解一下 coreDNS 解析域名的过程。

resolv.conf 文件分析

在部署 pod 的时候,如果用的是 K8s 集群的 DNS,那么 kubelet 在起 pause 容器的时候,会将其 DNS 解析配置初始化成集群内的配置。

比如我创建了一个叫 my-nginx 的 deployment,其 pod 中的 resolv.conf 文件如下:

[root@localhost ~]# kubectl exec -it my-nginx-b67c7f44-hsnpv cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

在集群中 pod 之间互相用 svc name 访问的时候,会根据 resolv.conf 文件的 DNS 配置来解析域名,下面来分析具体的过程。

域名解析的过程

pod 的 resolv.conf 文件主要有三个部分,分别为 nameserver、search 和 option。而这三个部分可以由 K8s 指定,也可以通过 pod.spec.dnsConfig 字段自定义。

nameserver

resolv.conf 文件的第一行 nameserver 指定的是 DNS 服务的 IP,这里就是 coreDNS 的 clusterIP:

[root@localhost ~]# kubectl -n kube-system get svc |grep dns
kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   32d

也就是说所有域名的解析,都要经过 coreDNS 的虚拟 IP 10.96.0.10 进行解析,不论是 Kubernetes 内部域名还是外部的域名。

search 域

resolv.conf 文件的第二行指定的是 DNS search 域。解析域名的时候,将要访问的域名依次带入 search 域,进行 DNS 查询。

比如我要在刚才那个 pod 中访问一个域名为 your-nginx 的服务,其进行的 DNS 域名查询的顺序是:

your-nginx.default.svc.cluster.local. -> your-nginx.svc.cluster.local. -> your-nginx.cluster.local.    

直到查到为止。

options

resolv.conf 文件的第三行指定的是其他项,最常见的是 dnotsdnots 指的是如果查询的域名包含的点 “.” 小于 5,则先走 search 域,再用绝对域名;如果查询的域名包含点数大于或等于 5,则先用绝对域名,再走 search 域。K8s 中默认的配置是 5。

也就是说,如果我访问的是 a.b.c.e.f.g ,那么域名查找的顺序如下:

a.b.c.e.f.g. -> a.b.c.e.f.g.default.svc.cluster.local. -> a.b.c.e.f.g.svc.cluster.local. -> a.b.c.e.f.g.cluster.local.

如果我访问的是 a.b.c.e,那么域名查找的顺序如下:

a.b.c.e.default.svc.cluster.local. -> a.b.c.e.svc.cluster.local. -> a.b.c.e.cluster.local. -> a.b.c.e.

pod 之间的通信

在了解完了域名解析的过程后,再来了解一下 pod 之间的通信。

通过 svc 访问

众所周知,在 K8s 中,Pod 之间通过 svc 访问的时候,会经过 DNS 域名解析,再拿到 ip 通信。而 K8s 的域名全称为 "<service-name>.<namespace>.svc.cluster.local",而我们通常只需将 svc name 当成域名就能访问到 pod,这一点通过上面的域名解析过程并不难理解。

我们来看个例子。有两个 deployment,一个叫 busybox,在 default 这个 namespace 下;一个叫 your-nginx,在 hdls 这个 namespace 下,svc 同名。我们在 busybox 中尝试访问 your-nginx

[root@localhost ~]# kubectl get po
NAME                           READY   STATUS    RESTARTS   AGE
busybox-5bbb5d7ff7-dh68j       1/1     Running   0          8m35s
[root@localhost ~]#
[root@localhost ~]# kubectl exec -it busybox-5bbb5d7ff7-dh68j sh
/ # wget your-nginx
wget: bad address 'your-nginx'
/ #
/ # wget your-nginx.hdls
Connecting to your-nginx.hdls (10.100.3.148:80)
saving to 'index.html'
index.html           100% |*****************************************************|   612  0:00:00 ETA
'index.html' saved
/ #
[root@localhost ~]# kubectl -n hdls get svc
NAME         TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
your-nginx   ClusterIP   10.100.3.148   <none>        80/TCP    14m

可以看到,当直接用 your-nginx 去访问的时候,提示 bad address,说明域名错了,因为在不同的 namespace 下,所有的 search 域都找过了还是找不到;当用 your-nginx.hdls 去访问的时候,会解析到 10.100.3.148 这个 IP,而这个 IP 正是 your-nginx 的 ClusterIP。

所以,在不同的 namespace 下的 pod 通过 svc 访问的时候,需要在 svc name 后面加上 .<namespace>

pod 的 hostname 与 subdomain

在 K8s 中,如果不指定 pod 的 hostname,其默认为 pod.metadata.name,通过 spec.hostname 字段可以自定义;另外还可以给 pod 设置 subdomain,通过 spec.subdomain 字段。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    name: nginx
spec:
  hostname: domain-test
  subdomain: subdomain-test
  containers:
  - image: nginx
    name: nginx
---
apiVersion: v1
kind: Service
metadata:
  name: subdomain-test
spec:
  selector:
    name: nginx
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP

可以查看这个 pod 的 hostname 和 hosts 文件:

[root@localhost ~]# kubectl get po -owide
NAME                           READY   STATUS    RESTARTS   AGE     IP             NODE           NOMINATED NODE   READINESS GATES
busybox-5bbb5d7ff7-dh68j       1/1     Running   0          112m    10.244.1.246   172-16-105-2   <none>           <none>
nginx                          1/1     Running   0          2m      10.244.1.253   172-16-105-2   <none>           <none>
[root@localhost ~]# kubectl exec -it nginx bash
root@domain-test:/# cat /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1   localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.244.1.253    domain-test.subdomain-test.default.svc.cluster.local    domain-test
root@domain-test:/#

在 busybox 容器中访问这个 pod:

[root@localhost ~]# kubectl exec -it busybox-5bbb5d7ff7-dh68j sh
/ # wget domain-test.subdomain-test
Connecting to domain-test.subdomain-test (10.244.1.253:80)
saving to 'index.html'
index.html           100% |*****************************************************|   612  0:00:00 ETA
'index.html' saved
/ #
/ # wget subdomain-test
Connecting to subdomain-test (10.108.213.70:80)
wget: can't open 'index.html': File exists
/ #

可以看到,当访问 domain-test.subdomain-test 解析出来的是 10.244.1.253,这个是 nginx 的 pod ip,而不是 clusterIP;而访问 subdomain-test 时,解析出来的是 10.108.213.70,这是 clusterIP,属于正常的 svc name 途径。

coreDNS Corefile 文件

CoreDNS 实现了应用的插件化,用户可以选择所需的插件编译到可执行文件中;CoreDNS 的配置文件是 Corefile 形式的,下面是个 coreDNS 的 configMap 例子。

[root@localhost ~]# kubectl -n kube-system get cm coredns -oyaml
apiVersion: v1
data:
  Corefile: |
    .:53 {
        errors
        health
        kubernetes cluster.local in-addr.arpa ip6.arpa {
           pods insecure
           upstream
           fallthrough in-addr.arpa ip6.arpa
        }
        prometheus :9153
        forward . /etc/resolv.conf
        cache 30
        loop
        reload
        loadbalance
    }
kind: ConfigMap
metadata:
  creationTimestamp: "2019-06-10T03:19:01Z"
  name: coredns
  namespace: kube-system
  resourceVersion: "3380134"
  selfLink: /api/v1/namespaces/kube-system/configmaps/coredns
  uid: 7e845ca2-8b2e-11e9-b4eb-005056b40224

Corefile 文件分析

第一部分:

kubernetes cluster.local in-addr.arpa ip6.arpa {
   pods insecure
   upstream
   fallthrough in-addr.arpa ip6.arpa
}

指明 cluster.local 后缀的域名,都是 kubernetes 内部域名,coredns 会监听 service 的变化来维护域名关系,所以cluster.local 相关域名都在这里解析。

第二部分:

proxy . /etc/resolv.conf

proxy 指 coredns 中没有找到记录,则去 /etc/resolv.conf 中的 nameserver 请求解析,而 coredns 容器中的 /etc/resolv.conf 是继承自宿主机的。
实际效果是如果不是 k8s 内部域名,就会去默认的 dns 服务器请求解析,并返回给 coredns 的请求者。

第三部分:

prometheus:CoreDNS 的监控地址为: http://localhost:9153/metrics ,满足 Prometheus 的格式。

cache:允许缓存

loop:如果找到循环,则检测简单的转发循环并停止 CoreDNS 进程。

reload:允许 Corefile 的配置自动更新。在更改 ConfigMap 后两分钟,修改生效

loadbalance:这是一个循环 DNS 负载均衡器,可以在答案中随机化 A,AAAA 和 MX 记录的顺序。

指定 hosts

有的时候,某个域名的服务在集群外,而我希望在集群内访问到,我们可以在 corefile 中指定 hosts 的方法实现。具体方式是将域名及对应的 ip 以 hosts 插件的方式加入到 corefile 中,如下:

hosts {
    10.244.1.245 other-company.com
    fallthrough
}

其中,10.244.1.245your-nginx 的 pod ip。然后再在上面的 busybox pod 中访问 other-company.com 这个服务,情况如下:

[root@localhost ~]# kubectl exec -it busybox-5bbb5d7ff7-dh68j sh
/ # wget other-company.com
Connecting to other-company.com (10.244.1.245:80)
saving to 'index.html'
index.html           100% |*****************************************************|   612  0:00:00 ETA
'index.html' saved
/ #
2019/7/11 posted in  Kubernetes

OIDC 协议及其在 Kubernetes 中的运用

K8s 中的认证机制大多都是用 ServiceAccount 来做的,虽然 K8s 有 User 的概念,但没有一种资源与“人”对应,所以在 K8s 里做用户管理还是很困难的。好在 K8s 对于用户管理提供了另一种方式,即对接 OIDC 协议。本篇文章我们就来了解一下什么是 OIDC 协议,及其在 K8s 中的运用。

OIDC 协议

OpenID Connect 协议(OIDC 协议)是基于 OAuth 2.0 协议的身份认证标准协议,在 OAuth 2.0 上构建了一个身份层。在介绍 OIDC 协议之前,我们先来看看 OAuth 2.0 协议。

OAuth 2.0 协议

OAuth 2.0 协议主要适用的场景是第三方应用登录,比如我们新登录一个网站,就可以不注册账号,而使用 Github 账号登录。

OAuth 2.0 协议在客户端和服务器之间定义了一个授权层,客户端只需要根据获取的 token 来认证登录即可,不需要存储密码信息。具体流程如下:

这一套流程不难理解,重点在于相比普通的账号密码登录,多了一层以 code 换取 token 的步骤。

这一步看似十分多余,然而保证了安全性,因为从 OAuth Server 跳转到 Client 这一步,code 需要放置在 URL 参数中,若直接将 token 放在 URL 参数中传给 Client,相当于把钥匙公开给整个互联网。所以在 OAuth 2.0 协议中,将 token 传输放在了后面一步,即将 token 放置在 Response body 中。

OIDC 协议的流程

总的来说,OAuth 2.0 协议只提供了授权认证,并没有身份认证的功能,而这一缺陷就由 OIDC 协议补上了。OIDC 协议又构建了一个身份层,具体流程如下:

可以看到,当 Client A 要求登录时,OIDC Server 会检测到该用户没有登录,从而启动类似 OAuth 流程的登录过程;而当同一个用户在 Client B 要求登录时,OIDC Server 会检测到该用户已经登录了,继而开始后面的流程。

而 OIDC 的登录过程与 OAuth 相比,最主要的扩展就是提供了 ID Token,下面我们进一步来熟悉一下 ID Token。

ID Token

ID Token 是一个安全令牌,其数据格式满足 JWT 格式,在 JWT 的 Payload 中由服务器提供一组用户信息。其主要信息包括:

  1. iss(Issuer Identifier):必须。提供认证信息者的唯一标识。一般是一个 https 的 url(不包含q uerystring 和 fragment 部分);
  2. sub(Subject Identifier):必须。iss 提供的用户标识,在 iss 范围内唯一,它有时也会被客户端用来标识唯一的用户。最长为 255 个 ASCII 字符;
  3. aud(Audiences):必须。标识 ID Token 的受众。必须包含 OAuth2 的 client_id;
  4. exp(Expiration time):必须。过期时间,超过此时间的 ID Token 会作废;
  5. iat(Issued At Time):必须。JWT 的构建时间;
  6. auth_time(AuthenticationTime):用户完成认证的时间;
  7. nonce:客户端发送请求的时候提供的随机字符串,用来减缓重放攻击,也可以来关联 ID Token 和客户端本身的 Session 信息;
  8. acr(Authentication Context Class Reference):可选。表示一个认证上下文引用值,可以用来标识认证上下文类;
  9. amr(Authentication Methods References):可选。表示一组认证方法;
  10. azp(Authorized party):可选。结合 aud 使用。只有在被认证的一方和受众(aud)不一致时才使用此值,一般情况下很少使用。

除了上述这些,ID Token 的用户信息还可以包含其他信息,由服务器端配置。

另外 ID Token 必须进行 JWS 签名和 JWE 加密,从而保证认证的完整性、不可否认性以及可选的保密性。

K8s 中使用 OIDC 的原理

在了解了 OIDC 协议的流程及原理后,我们再来看 K8s 中使用 OIDC 的原理。

首先先来看看 K8s 中使用 OIDC 的流程:

可以看到,APIServer 本身与 OIDC Server(即 Identity Provider)并没有太多交互,需要我们自己获取到 ID Token 后,将其写入 Kubectl 的配置,由 Kubectl 使用 ID Token 来与 APIServer 交互。

此外,除了 ID Token,我们还需要将 refresh Token 提供给 Kubectl,而 refresh Token 则是用来在 ID Token 过期后自动重新获取 ID Token 的。

APIServer 拿到 ID Token 后,有以下几个步骤:

  1. 检查其是否 JWT 格式;
  2. 判断 Token 是否过期(根据 iat 和 exp);
  3. 该用户对此操作是否有权限。

而 APIServer 如何根据 ID Token 知道是哪个用户的呢?这也需要我们在 APIServer 的配置中指定 ID Token 中的对应字段。

另外需要注意的是,虽然在 OIDC Server 中可以做到用户的权限管理,但由上述过程我们也可以发现,K8s 并不认 OIDC Server 的权限管理。因为 K8s 已经有一套非常完善的 RBAC 体系,我们将权限控制管理的步骤留在 K8s 集群内就可以了。

2019/7/9 posted in  Kubernetes

[译]深入剖析 Kubernetes MutatingAdmissionWebhook

翻译自 《Diving into Kubernetes MutatingAdmissionWebhook》
原文链接:
https://medium.com/ibm-cloud/diving-into-kubernetes-mutatingadmissionwebhook-6ef3c5695f74

对于在数据持久化之前,拦截到 Kubernetes API server 的请求,Admission controllers 是非常有用的工具。然而,由于其需要由集群管理员在 kube-apiserver 中编译成二进制文件,所以使用起来不是很灵活。从 Kubernetes 1.7 起,引入了 InitializersExternal Admission Webhooks,用以解决这个问题。在 Kubernetes 1.9 中,Initializers 依然停留在 alpha 版本,然而 External Admission Webhooks 被提升到了 beta 版,且被分成了 MutatingAdmissionWebhookValidatingAdmissionWebhook

MutatingAdmissionWebhookValidatingAdmissionWebhook 二者合起来就是一个特殊类型的 admission controllers,一个处理资源更改,一个处理验证。验证是通过匹配到 MutatingWebhookConfiguration 中定义的规则完成的。

在这篇文章中,我会深入剖析 MutatingAdmissionWebhook 的细节,并一步步实现一个可用的 webhook admission server

Webhooks 的好处

Kubernetes 集群管理员可以使用 webhooks 来创建额外的资源更改及验证准入插件,这些准入插件可以通过 apiserver 的准入链来工作,而不需要重新编译 apiserver。这使得开发者可以对于很多动作都可以自定义准入逻辑,比如对任何资源的创建、更新、删除,给开发者提供了很大的自由和灵活度。可以使用的应用数量巨大。一些常见的使用常见包括:

  1. 在创建资源之前做些更改。Istio 是个非常典型的例子,在目标 pods 中注入 Envoy sidecar 容器,来实现流量管理和规则执行。
  2. 自动配置 StorageClass。监听 PersistentVolumeClaim 资源,并按照事先定好的规则自动的为之增添对应的 StorageClass。使用者无需关心 StorageClass 的创建。
  3. 验证复杂的自定义资源。确保只有其被定义后且所有的依赖项都创建好并可用,自定义资源才可以创建。
  4. namespace 的限制。在多租户系统中,避免资源在预先保留的 namespace 中被创建。

除了以上列出来的使用场景,基于 webhooks 还可以创建更多应用。

Webhooks 和 Initializers

基于社区的反馈,以及对 External Admission WebhooksInitializers 的 alpha 版本的使用案例,Kubernetes 社区决定将 webhooks 升级到 beta 版,并且将其分成两种 webhooks(MutatingAdmissionWebhookValidatingAdmissionWebhook)。这些更新使得 webhooks 与其他 admission controllers 保持一致,并且强制 mutate-before-validateInitializers 可以在 Kubernetes 资源创建前更改,从而实现动态准入控制。如果你对 Initializers 不熟悉,可以参考这篇文章

所以,到底 WebhooksInitializers 之间的区别是什么呢?

  1. Webhooks 可以应用于更多操作,包括对于资源 "增删改" 的 "mutate" 和 "admit";然而 Initializers 不可以对 "删" 资源进行 "admit"。
  2. Webhooks 在创建资源前不允许查询;然而 Initializers 可以监听未初始化的资源,通过参数 ?includeUninitialized=true 来实现。
  3. 由于 Initializers 会把 "预创建" 状态也持久化到 etcd,因此会引入高延迟且给 etcd 带来负担,尤其在 apiserver 升级或失败时;然而 Webhooks 消耗的内存和计算资源更少。
  4. WebhooksInitializers 对失败的保障更强大。Webhooks 的配置中可以配置失败策略,用以避免资源在创建的时候被 hang 住。然而 Initializers 在尝试创建资源的时候可能会 block 住所有的资源。

除了上面列举的不同点,Initializer 在较长一段开发时间内还存在很多已知问题,包括配额补充错误等。Webhooks 升级为 beta 版也就预示着在未来 Webhooks 会是开发目标。如果你需要更稳定的操作,我推荐使用 Webhooks

MutatingAdmissionWebhook 如何工作

MutatingAdmissionWebhook 在资源被持久化到 etcd 前,根据规则将其请求拦截,拦截规则定义在 MutatingWebhookConfiguration 中。MutatingAdmissionWebhook 通过对 webhook server 发送准入请求来实现对资源的更改。而 webhook server 只是一个简单的 http 服务器。

下面这幅图详细描述了 MutatingAdmissionWebhook 如何工作:

MutatingAdmissionWebhook 需要三个对象才能运行:

MutatingWebhookConfiguration

MutatingAdmissionWebhook 需要根据 MutatingWebhookConfiguration 向 apiserver 注册。在注册过程中,MutatingAdmissionWebhook 需要说明:

  1. 如何连接 webhook admission server
  2. 如何验证 webhook admission server
  3. webhook admission server 的 URL path;
  4. webhook 需要操作对象满足的规则;
  5. webhook admission server 处理时遇到错误时如何处理。

MutatingAdmissionWebhook 本身

MutatingAdmissionWebhook 是一种插件形式的 admission controller ,且可以配置到 apiserver 中。MutatingAdmissionWebhook 插件可以从 MutatingWebhookConfiguration 中获取所有感兴趣的 admission webhooks

然后 MutatingAdmissionWebhook 监听 apiserver 的请求,拦截满足条件的请求,并并行执行。

Webhook Admission Server

Webhook Admission Server 只是一个附着到 k8s apiserver 的 http server。对于每一个 apiserver 的请求,MutatingAdmissionWebhook 都会发送一个 admissionReview 到相关的 webhook admission serverwebhook admission server 再决定如何更改资源。

MutatingAdmissionWebhook 教程

编写一个完整的 Webhook Admission Server 可能令人生畏。为了方便起见,我们编写一个简单的 Webhook Admission Server 来实现注入 nginx sidecar 容器以及挂载 volume。完整代码在 kube-mutating-webhook-tutorial。这个项目参考了 Kubernetes webhook 示例Istio sidecar 注入实现

在接下来的段落里,我会向你展示如何编写可工作的容器化 webhook admission server,并将其部署到 Kubernetes 集群中。

前置条件

MutatingAdmissionWebhook 要求 Kubernetes 版本为 1.9.0 及以上,其 admissionregistration.k8s.io/v1beta1 API 可用。确保下面的命令:

kubectl api-versions | grep admissionregistration.k8s.io/v1beta1

其输出为:

admissionregistration.k8s.io/v1beta1

另外,MutatingAdmissionWebhookValidatingAdmissionWebhook 准入控制器需要以正确的顺序加入到 kube-apiserveradmission-control 标签中。

编写 Webhook Server

Webhook Admission Server 是一个简单的 http 服务器,遵循 Kubernetes API。我粘贴部分伪代码来描述主逻辑:

sidecarConfig, err := loadConfig(parameters.sidecarCfgFile)
pair, err := tls.LoadX509KeyPair(parameters.certFile, parameters.keyFile)

whsvr := &WebhookServer {
    sidecarConfig:    sidecarConfig,
    server:           &http.Server {
        Addr:        fmt.Sprintf(":%v", 443),
        TLSConfig:   &tls.Config{Certificates: []tls.Certificate{pair}},
    },
}
    
// define http server and server handler
mux := http.NewServeMux()
mux.HandleFunc("/mutate", whsvr.serve)
whsvr.server.Handler = mux

// start webhook server in new rountine
go func() {
    if err := whsvr.server.ListenAndServeTLS("", ""); err != nil {
        glog.Errorf("Filed to listen and serve webhook server: %v", err)
    }
}()

以上代码的详解:

  1. sidecarCfgFile 包含了 sidecar 注入器模板,其在下面的 ConfigMap 中定义;
  2. certFilekeyFile 是秘钥对,会在 webhook server 和 apiserver 之间的 TLS 通信中用到;
  3. 19 行开启了 https server,以监听 443 端口路径为 '/mutate'

接下来我们关注处理函数 serve 的主要逻辑:

// Serve method for webhook server
func (whsvr *WebhookServer) serve(w http.ResponseWriter, r *http.Request) {
    var body []byte
    if r.Body != nil {
        if data, err := ioutil.ReadAll(r.Body); err == nil {
            body = data
        }
    }

    var reviewResponse *v1beta1.AdmissionResponse
    ar := v1beta1.AdmissionReview{}
    deserializer := codecs.UniversalDeserializer()
    if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
        glog.Error(err)
        reviewResponse = toAdmissionResponse(err)
    } else {
        reviewResponse = mutate(ar)
    }

    response := v1beta1.AdmissionReview{}
    if reviewResponse != nil {
        response.Response = reviewResponse
        response.Response.UID = ar.Request.UID
    }
    // reset the Object and OldObject, they are not needed in a response.
    ar.Request.Object = runtime.RawExtension{}
    ar.Request.OldObject = runtime.RawExtension{}

    resp, err := json.Marshal(response)
    if err != nil {
        glog.Error(err)
    }
    if _, err := w.Write(resp); err != nil {
        glog.Error(err)
    }
}

函数 serve 是一个简单的 http 处理器,参数为 http requestresponse writer

  1. 首先将请求组装为 AdmissionReview,其中包括 objectoldobjectuserInfo ...
  2. 然后触发 Webhook 主函数 mutate 来创建 patch,以实现注入 sidecar 容器及挂载 volume。
  3. 最后,将 admission decision 和额外 patch 组装成响应,并发送回给 apiserver。

对于函数 mutate 的实现,你可以随意发挥。我就以我的实现方式做个例子:

// main mutation process
func (whsvr *WebhookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionResponse {
    req := ar.Request
    var pod corev1.Pod
    if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
        glog.Errorf("Could not unmarshal raw object: %v", err)
        return &v1beta1.AdmissionResponse {
            Result: &metav1.Status {
                Message: err.Error(),
            },
        }
    }
    
    // determine whether to perform mutation
    if !mutationRequired(ignoredNamespaces, &pod.ObjectMeta) {
        glog.Infof("Skipping mutation for %s/%s due to policy check", pod.Namespace, pod.Name)
        return &v1beta1.AdmissionResponse {
            Allowed: true, 
        }
    }

    annotations := map[string]string{admissionWebhookAnnotationStatusKey: "injected"}
    patchBytes, err := createPatch(&pod, whsvr.sidecarConfig, annotations)
    
    return &v1beta1.AdmissionResponse {
        Allowed: true,
        Patch:   patchBytes,
        PatchType: func() *v1beta1.PatchType {
            pt := v1beta1.PatchTypeJSONPatch
            return &pt
        }(),
    }
}

从上述代码中可以看出,函数 mutate 请求了 mutationRequired 来决定这个改动是否被允许。对于被允许的请求,函数 mutate 从另一个函数 createPatch 中获取到修改体 'patch'。注意这里函数 mutationRequired 的诡计,我们跳过了带有注解 sidecar-injector-webhook.morven.me/inject: true。这里稍后会在部署 deployment 的时候提到。完整代码请参考这里

编写 Dockerfile 并构建

创建构建脚本:

dep ensure
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o kube-mutating-webhook-tutorial .
docker build --no-cache -t morvencao/sidecar-injector:v1 .
rm -rf kube-mutating-webhook-tutorial

docker push morvencao/sidecar-injector:v1

以下面为依赖编写 Dockerfile 文件:

FROM alpine:latest

ADD kube-mutating-webhook-tutorial /kube-mutating-webhook-tutorial
ENTRYPOINT ["./kube-mutating-webhook-tutorial"]

在手动构建容器前,你需要 Docker ID 账号并将 image name 和 tag (Dockerfile 和 deployment.yaml 文件中)修改成你自己的,然后执行以下命令:

[root@mstnode kube-mutating-webhook-tutorial]# ./build
Sending build context to Docker daemon  44.89MB
Step 1/3 : FROM alpine:latest
 ---> 3fd9065eaf02
Step 2/3 : ADD kube-mutating-webhook-tutorial /kube-mutating-webhook-tutorial
 ---> 432de60c2b3f
Step 3/3 : ENTRYPOINT ["./kube-mutating-webhook-tutorial"]
 ---> Running in da6e956d1755
Removing intermediate container da6e956d1755
 ---> 619faa936145
Successfully built 619faa936145
Successfully tagged morvencao/sidecar-injector:v1
The push refers to repository [docker.io/morvencao/sidecar-injector]
efd05fe119bb: Pushed
cd7100a72410: Layer already exists
v1: digest: sha256:7a4889928ec5a8bcfb91b610dab812e5228d8dfbd2b540cd7a341c11f24729bf size: 739

编写 Sidecar 注入配置

现在我们来创建一个 Kubernetes ConfigMap,包含需要注入到目标 pod 中的容器和 volume 信息:

apiVersion: v1
kind: ConfigMap
metadata:
  name: sidecar-injector-webhook-configmap
data:
  sidecarconfig.yaml: |
    containers:
      - name: sidecar-nginx
        image: nginx:1.12.2
        imagePullPolicy: IfNotPresent
        ports:
          - containerPort: 80
        volumeMounts:
          - name: nginx-conf
            mountPath: /etc/nginx
    volumes:
      - name: nginx-conf
        configMap:
          name: nginx-configmap

从上面的清单中看,这里需要另一个包含 nginx confConfigMap。完整 yaml 参考 nginxconfigmap.yaml

然后将这两个 ConfigMap 部署到集群中:

[root@mstnode kube-mutating-webhook-tutorial]# kubectl create -f ./deployment/nginxconfigmap.yaml
configmap "nginx-configmap" created
[root@mstnode kube-mutating-webhook-tutorial]# kubectl create -f ./deployment/configmap.yaml
configmap "sidecar-injector-webhook-configmap" created

创建包含秘钥对的 Secret

由于准入控制是一个高安全性操作,所以对外在的 webhook server 提供 TLS 是必须的。作为流程的一部分,我们需要创建由 Kubernetes CA 签名的 TLS 证书,以确保 webhook server 和 apiserver 之间通信的安全性。对于 CSR 创建和批准的完整步骤,请参考 这里

简单起见,我们参考了 Istio 的脚本并创建了一个类似的名为 webhook-create-signed-cert.sh 的脚本,来自动生成证书及秘钥对并将其加入到 secret 中。

#!/bin/bash
while [[ $# -gt 0 ]]; do
    case ${1} in
        --service)
            service="$2"
            shift
            ;;
        --secret)
            secret="$2"
            shift
            ;;
        --namespace)
            namespace="$2"
            shift
            ;;
    esac
    shift
done

[ -z ${service} ] && service=sidecar-injector-webhook-svc
[ -z ${secret} ] && secret=sidecar-injector-webhook-certs
[ -z ${namespace} ] && namespace=default

csrName=${service}.${namespace}
tmpdir=$(mktemp -d)
echo "creating certs in tmpdir ${tmpdir} "

cat <<EOF >> ${tmpdir}/csr.conf
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${service}
DNS.2 = ${service}.${namespace}
DNS.3 = ${service}.${namespace}.svc
EOF

openssl genrsa -out ${tmpdir}/server-key.pem 2048
openssl req -new -key ${tmpdir}/server-key.pem -subj "/CN=${service}.${namespace}.svc" -out ${tmpdir}/server.csr -config ${tmpdir}/csr.conf

# clean-up any previously created CSR for our service. Ignore errors if not present.
kubectl delete csr ${csrName} 2>/dev/null || true

# create  server cert/key CSR and  send to k8s API
cat <<EOF | kubectl create -f -
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
  name: ${csrName}
spec:
  groups:
  - system:authenticated
  request: $(cat ${tmpdir}/server.csr | base64 | tr -d '\n')
  usages:
  - digital signature
  - key encipherment
  - server auth
EOF

# verify CSR has been created
while true; do
    kubectl get csr ${csrName}
    if [ "$?" -eq 0 ]; then
        break
    fi
done

# approve and fetch the signed certificate
kubectl certificate approve ${csrName}
# verify certificate has been signed
for x in $(seq 10); do
    serverCert=$(kubectl get csr ${csrName} -o jsonpath='{.status.certificate}')
    if [[ ${serverCert} != '' ]]; then
        break
    fi
    sleep 1
done
if [[ ${serverCert} == '' ]]; then
    echo "ERROR: After approving csr ${csrName}, the signed certificate did not appear on the resource. Giving up after 10 attempts." >&2
    exit 1
fi
echo ${serverCert} | openssl base64 -d -A -out ${tmpdir}/server-cert.pem


# create the secret with CA cert and server cert/key
kubectl create secret generic ${secret} \
        --from-file=key.pem=${tmpdir}/server-key.pem \
        --from-file=cert.pem=${tmpdir}/server-cert.pem \
        --dry-run -o yaml |
    kubectl -n ${namespace} apply -f -

运行脚本后,包含证书和秘钥对的 secret 就被创建出来了:

[root@mstnode kube-mutating-webhook-tutorial]# ./deployment/webhook-create-signed-cert.sh
creating certs in tmpdir /tmp/tmp.wXZywp0wAF
Generating RSA private key, 2048 bit long modulus
...........................................+++
..........+++
e is 65537 (0x10001)
certificatesigningrequest "sidecar-injector-webhook-svc.default" created
NAME                                   AGE       REQUESTOR                                           CONDITION
sidecar-injector-webhook-svc.default   0s        https://mycluster.icp:9443/oidc/endpoint/OP#admin   Pending
certificatesigningrequest "sidecar-injector-webhook-svc.default" approved
secret "sidecar-injector-webhook-certs" created

创建 Sidecar 注入器的 Deployment 和 Service

deployment 带有一个 pod,其中运行的就是 sidecar-injector 容器。该容器以特殊参数运行:

  1. sidecarCfgFile 指的是 sidecar 注入器的配置文件,挂载自上面创建的 ConfigMap sidecar-injector-webhook-configmap
  2. tlsCertFiletlsKeyFile 是秘钥对,挂载自 Secret injector-webhook-certs
  3. alsologtostderrv=42>&1 是日志参数。
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: sidecar-injector-webhook-deployment
  labels:
    app: sidecar-injector
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: sidecar-injector
    spec:
      containers:
        - name: sidecar-injector
          image: morvencao/sidecar-injector:v1
          imagePullPolicy: IfNotPresent
          args:
            - -sidecarCfgFile=/etc/webhook/config/sidecarconfig.yaml
            - -tlsCertFile=/etc/webhook/certs/cert.pem
            - -tlsKeyFile=/etc/webhook/certs/key.pem
            - -alsologtostderr
            - -v=4
            - 2>&1
          volumeMounts:
            - name: webhook-certs
              mountPath: /etc/webhook/certs
              readOnly: true
            - name: webhook-config
              mountPath: /etc/webhook/config
      volumes:
        - name: webhook-certs
          secret:
            secretName: sidecar-injector-webhook-certs
        - name: webhook-config
          configMap:
            name: sidecar-injector-webhook-configmap

Service 暴露带有 app=sidecar-injector label 的 pod,使之在集群中可访问。这个 Service 会被 MutatingWebhookConfiguration 中定义的 clientConfig 部分访问,默认的端口 spec.ports.port 需要设置为 443。

apiVersion: v1
kind: Service
metadata:
  name: sidecar-injector-webhook-svc
  labels:
    app: sidecar-injector
spec:
  ports:
  - port: 443
    targetPort: 443
  selector:
    app: sidecar-injector

然后将上述 Deployment 和 Service 部署到集群中,并且验证 sidecar 注入器的 webhook server 是否 running:

[root@mstnode kube-mutating-webhook-tutorial]# kubectl create -f ./deployment/deployment.yaml
deployment "sidecar-injector-webhook-deployment" created
[root@mstnode kube-mutating-webhook-tutorial]# kubectl create -f ./deployment/service.yaml
service "sidecar-injector-webhook-svc" created
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get deployment
NAME                                  DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
sidecar-injector-webhook-deployment   1         1         1            1           2m
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get pod
NAME                                                  READY     STATUS    RESTARTS   AGE
sidecar-injector-webhook-deployment-bbb689d69-fdbgj   1/1       Running   0          3m

动态配置 webhook 准入控制器

MutatingWebhookConfiguration 中具体说明了哪个 webhook admission server 是被使用的并且哪些资源受准入服务器的控制。建议你在创建 MutatingWebhookConfiguration 之前先部署 webhook admission server,并确保其正常工作。否则,请求会被无条件接收或根据失败规则被拒。

现在,我们根据下面的内容创建 MutatingWebhookConfiguration

apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
  name: sidecar-injector-webhook-cfg
  labels:
    app: sidecar-injector
webhooks:
  - name: sidecar-injector.morven.me
    clientConfig:
      service:
        name: sidecar-injector-webhook-svc
        namespace: default
        path: "/mutate"
      caBundle: ${CA_BUNDLE}
    rules:
      - operations: [ "CREATE" ]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
    namespaceSelector:
      matchLabels:
        sidecar-injector: enabled

第 8 行:name - webhook 的名字,必须指定。多个 webhook 会以提供的顺序排序;
第 9 行:clientConfig - 描述了如何连接到 webhook admission server 以及 TLS 证书;
第 15 行:rules - 描述了 webhook server 处理的资源和操作。在我们的例子中,只拦截创建 pods 的请求;
第 20 行:namespaceSelector - namespaceSelector 根据资源对象是否匹配 selector 决定了是否针对该资源向 webhook server 发送准入请求。

在部署 MutatingWebhookConfiguration 前,我们需要将 ${CA_BUNDLE} 替换成 apiserver 的默认 caBundle。我们写个脚本来自动匹配:

#!/bin/bash
set -o errexit
set -o nounset
set -o pipefail

ROOT=$(cd $(dirname $0)/../../; pwd)

export CA_BUNDLE=$(kubectl get configmap -n kube-system extension-apiserver-authentication -o=jsonpath='{.data.client-ca-file}' | base64 | tr -d '\n')

if command -v envsubst >/dev/null 2>&1; then
    envsubst
else
    sed -e "s|\${CA_BUNDLE}|${CA_BUNDLE}|g"
fi

然后执行:

[root@mstnode kube-mutating-webhook-tutorial]# cat ./deployment/mutatingwebhook.yaml |\
>   ./deployment/webhook-patch-ca-bundle.sh >\
>   ./deployment/mutatingwebhook-ca-bundle.yaml

我们看不到任何日志描述 webhook server 接收到准入请求,似乎该请求并没有发送到 webhook server 一样。所以有一种可能性是这是被 MutatingWebhookConfiguration 中的配置触发的。再确认一下 MutatingWebhookConfiguration 我们会发现下面的内容:

namespaceSelector:
      matchLabels:
        sidecar-injector: enabled

通过 namespaceSelector 控制 sidecar 注入器

我们在 MutatingWebhookConfiguration 中配置了 namespaceSelector,也就意味着只有在满足条件的 namespace 下的资源能够被发送到  webhook server。于是我们将 default 这个 namespace 打上标签 sidecar-injector=enabled

[root@mstnode kube-mutating-webhook-tutorial]# kubectl label namespace default sidecar-injector=enabled
namespace "default" labeled
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get namespace -L sidecar-injector
NAME          STATUS    AGE       sidecar-injector
default       Active    1d        enabled
kube-public   Active    1d
kube-system   Active    1d

现在我们配置的 MutatingWebhookConfiguration 会在 pod 创建的时候就注入 sidecar 容器。将运行中的 pod 删除并确认是否创建了新的带 sidecar 容器的 pod:

[root@mstnode kube-mutating-webhook-tutorial]# kubectl delete pod sleep-6d79d8dc54-r66vz
pod "sleep-6d79d8dc54-r66vz" deleted
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get pods
NAME                                                  READY     STATUS              RESTARTS   AGE
sidecar-injector-webhook-deployment-bbb689d69-fdbgj   1/1       Running             0          29m
sleep-6d79d8dc54-b8ztx                                0/2       ContainerCreating   0          3s
sleep-6d79d8dc54-r66vz                                1/1       Terminating         0          11m
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get pod sleep-6d79d8dc54-b8ztx -o yaml
apiVersion: v1
kind: Pod
metadata:
  annotations:
    kubernetes.io/psp: default
    sidecar-injector-webhook.morven.me/inject: "true"
    sidecar-injector-webhook.morven.me/status: injected
  labels:
    app: sleep
    pod-template-hash: "2835848710"
  name: sleep-6d79d8dc54-b8ztx
  namespace: default
spec:
  containers:
  - command:
    - /bin/sleep
    - infinity
    image: tutum/curl
    imagePullPolicy: IfNotPresent
    name: sleep
    resources: {}
    volumeMounts:
    - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      name: default-token-d7t2r
      readOnly: true
  - image: nginx:1.12.2
    imagePullPolicy: IfNotPresent
    name: sidecar-nginx
    ports:
    - containerPort: 80
      protocol: TCP
    resources: {}
    terminationMessagePath: /dev/termination-log
    terminationMessagePolicy: File
    volumeMounts:
    - mountPath: /etc/nginx
      name: nginx-conf
  volumes:
  - name: default-token-d7t2r
    secret:
      defaultMode: 420
      secretName: default-token-d7t2r
  - configMap:
      defaultMode: 420
      name: nginx-configmap
    name: nginx-conf
...

可以看到,sidecar 容器和 volume 被成功注入到应用中。至此,我们成功创建了可运行的带 MutatingAdmissionWebhook 的 sidecar 注入器。通过 namespaceSelector,我们可以轻易的控制在特定的 namespace 中的 pods 是否需要被注入 sidecar 容器。

但这里有个问题,根据以上的配置,在 default 这个 namespace 下的所有 pods 都会被注入 sidecar 容器,无一例外。

通过注解控制 sidecar 注入器

多亏了 MutatingAdmissionWebhook 的灵活性,我们可以轻易的自定义变更逻辑来筛选带有特定注解的资源。还记得上面提到的注解 sidecar-injector-webhook.morven.me/inject: "true" 吗?在 sidecar 注入器中这可以当成另一种控制方式。在 webhook server 中我写了一段逻辑来跳过那行不带这个注解的 pod。

我们来尝试一下。在这种情况下,我们创建另一个 sleep 应用,其 podTemplateSpec 中不带注解 sidecar-injector-webhook.morven.me/inject: "true"

[root@mstnode kube-mutating-webhook-tutorial]# kubectl delete deployment sleep
deployment "sleep" deleted
[root@mstnode kube-mutating-webhook-tutorial]# cat <<EOF | kubectl create -f -
apiVersion: extensions/v1beta1
> kind: Deployment
> metadata:
>   name: sleep
> spec:
>   replicas: 1
>   template:
>     metadata:
>       labels:
>         app: sleep
>     spec:
>       containers:
>       - name: sleep
>         image: tutum/curl
>         command: ["/bin/sleep","infinity"]
>         imagePullPolicy: IfNotPresent
> EOF
deployment "sleep" created

然后确认 sidecar 注入器是否跳过了这个 pod:

[root@mstnode kube-mutating-webhook-tutorial]# kubectl get deployment
NAME                                  DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
sidecar-injector-webhook-deployment   1         1         1            1           45m
sleep                                 1         1         1            1           17s
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get pod
NAME                                                  READY     STATUS        RESTARTS   AGE
sidecar-injector-webhook-deployment-bbb689d69-fdbgj   1/1       Running       0          45m
sleep-776b7bcdcd-4bz58                                1/1       Running       0          21s

结果显示,这个 sleep 应用只包含一个容器,没有额外的容器和 volume 注入。然后我们将这个 deployment 增加注解,并确认其重建后是否被注入 sidecar:

[root@mstnode kube-mutating-webhook-tutorial]# kubectl patch deployment sleep -p '{"spec":{"template":{"metadata":{"annotations":{"sidecar-injector-webhook.morven.me/inject": "true"}}}}}'
deployment "sleep" patched
[root@mstnode kube-mutating-webhook-tutorial]# kubectl delete pod sleep-776b7bcdcd-4bz58
pod "sleep-776b7bcdcd-4bz58" deleted
[root@mstnode kube-mutating-webhook-tutorial]# kubectl get pods
NAME                                                  READY     STATUS              RESTARTS   AGE
sidecar-injector-webhook-deployment-bbb689d69-fdbgj   1/1       Running             0          49m
sleep-3e42ff9e6c-6f87b                                0/2       ContainerCreating   0          18s
sleep-776b7bcdcd-4bz58                                1/1       Terminating         0          3m

与预期一致,pod 被注入了额外的 sidecar 容器。至此,我们就获得了一个可工作的 sidecar 注入器,可由 namespaceSelector 或更细粒度的由注解控制。

总结

MutatingAdmissionWebhook 是 Kubernetes 扩展功能中最简单的方法之一,工作方式是通过全新规则控制、资源更改。

此功能使得更多的工作模式变成了可能,并且支持了更多生态系统,包括服务网格平台 Istio。从 Istio 0.5.0 开始,Istio 的自动注入部分的代码被重构,实现方式从 initializers 变更为 MutatingAdmissionWebhook

参考文献

2019/4/28 posted in  Kubernetes

Kubernetes Job 与 CronJob

如果说 Deployment、DaemonSet 等资源为 Kubernetes 承担了长时间、在线计算的能力,那么定时、短期、甚至一次性的离线计算能力,便是 Job 和 CronJob 所承担的事情。

Job

Job 其实就是根据定义起一个或多个 pod 来执行任务,pod 执行完退出后,这个 Job 就完成了。所以 Job 又称为 Batch Job ,即计算业务或离线业务。

Job 使用方法

Job 的 YAML 定义与 Deployment 十分相似。与 Deployment 不同的是,Job 不需要定义 spec.selector 来指定需要控制的 pod,看个例子:

apiVersion: batch/v1
kind: Job
metadata:
  name: date
spec:
  template:
    spec:
      containers:
      - name: date
        image: ubuntu:16.04
        command: ["sh", "-c", "date > /date/date.txt"]
        volumeMounts:
        - mountPath: /date
          name: date-volume
      restartPolicy: Never
      volumes:
      - name: date-volume
        hostPath:
          path: /date

在这个 Job 中,我们定义了一个 Ubuntu 镜像的容器,用于将当前时间输出至宿主机的 /date/date.txt 文件中。将此 Job 创建好后,我们可以查看该 Job 对象:

可以看到,Job 在创建后被加上了 controller-uid=***** 的 Label,和与之对应的 Label Selector,从而保证了 Job 与它所管理的 Pod 之间的匹配关系。查看 pod 可以看到相同的 Label:

pod 在执行完毕后,状态会变成 Completed,我们可以去 pod 被调度的 node 上查看我们挂载进去的 date.txt 文件:

[root@rancher-node3 ~]# cat /date/date.txt
Sat Dec 22 16:09:48 UTC 2018

pod 重启策略

在 Job 中,pod 的重启策略 restartPolicy 不允许被设置成 Always,只允许被设置为 Never 或 OnFailure。这是因为 Job 的 pod 执行完毕后直接退出,如果 restartPolicy=Always,pod 将不断执行计算作业,这可不是我们期望的。

Job 可以设置 pod 的最长运行时间 spec.activeDeadlineSeconds,一旦超过了这个时间,这个 Job 的所有 pod 都会被终止。

那么,如果 pod 的计算作业失败了,在不同的重启策略下会怎么办?

restartPolicy=Never

如果设置了 restartPolicy=Never,那么 Job Controller 会不断的尝试创建一个新的 pod 出来,默认尝试 6 次。当然这个值可以设置,即 Job 对象的 spec.backoffLimit 字段。

需要注意的是,重新创建 Pod 的间隔是呈指数增加的。

restartPolicy=OnFailure

如果设置了 restartPolicy=Never,那么 Job Controller 会不断的重启这个 pod。

Job 工作原理

通过观察 Job 的创建过程,不难看出 Job 维护了两个值 DESIRED 和 SUCCESSFUL,分别表示 spec.completions 和 成功退出的 pod 数。

而在 Job 对象中有两个参数意义重大,它们控制着 Job 的并行任务:
spec.parallelism :定义一个 Job 在任意时间最多可以启动同时运行的 Pod 数;
spec.completions :定义 Job 至少要完成的 Pod 数目,即 Job 的最小完成数。

弄清楚了这两个参数,我们再来看 Job 的工作原理。

首先,Job Controller 控制的直接就是 pod;
在整个 Job 的作业过程中,Job Controller 根据实际在 Running 的 pod 数、已成功退出的 pod 数、parallelism 值、completions 值,计算出当前需要创建或删除的 pod 数,去调用 APIServer 来执行具体操作。

就拿上面的例子说明,比如将 YAML 改成:

apiVersion: batch/v1
kind: Job
metadata:
  name: date
spec:
  parallelism: 2
  completions: 3
  template:
    spec:
      containers:
      - name: date
        image: ubuntu:16.04
        command: ["sh", "-c", "date >> /date/date.txt"]
        volumeMounts:
        - mountPath: /date
          name: date-volume
      restartPolicy: Never
      volumes:
      - name: date-volume
        hostPath:
          path: /date

第一步:判断当前没有 pod 在 Running,且成功退出 pod 数为 0,当前最多允许 2 个 pod 并行。向 APIServer 发起创建 2 个 pod 的请求。此时 2 个 pod Running,当这 2 个 pod 完成任务并成功退出后,进入第二步;

第二步:当前 Running pod 数为 0,成功退出数为 2,当前最多允许 2 个 pod 并行,Job 最小完成数为 3。则向 APIServer 发起创建 1 个 pod 的请求。此时 1 个 pod Running,当这个 pod 完成任务并成功退出后,进入第三步;

第三步:当前成功退出 pod 数为 3,Job 最小完成数为 3。判断 Job 完成作业。

批处理调度

根据 Job 的这些特性,我们就可以用以实现批处理调度,也就是并行启动多个计算进程去处理一批工作项。根据并行处理的特性,往往将 Job 分为三种类型,即 Job 模板拓展、固定 completions 数的 Job、固定 parallelism 数的 Job。

Job 模板拓展

这种模式最简单粗暴,即将 Job 的 YAML 定义成外界可使用的模板,再由外部控制器使用这些模板来生成单一无并行任务的 Job。比如,我们将上面的例子改写成模板:

apiVersion: batch/v1
kind: Job
metadata:
  name: date-$ITEM
spec:
  template:
    spec:
      containers:
      - name: date
        image: ubuntu:16.04
        command: ["sh", "-c", "echo item number $ITEM; date >> /date/date.txt; sleep 5s"]
        volumeMounts:
        - mountPath: /date
          name: date-volume
      restartPolicy: Never
      volumes:
      - name: date-volume
        hostPath:
          path: /date

而在使用的时候,只需将 $ITEM 替换掉即可:

cat job.yml | sed "s/\$ITEM/1/" > ./job-test.yaml

除了上面这张简单的基础模板使用,Kubernetes 官网还提供了一种以 jinja2 模板语言实现的多模板参数的模式:

{%- set params = [{ "name": "apple", "url": "http://www.orangepippin.com/apples", },
                  { "name": "banana", "url": "https://en.wikipedia.org/wiki/Banana", },
                  { "name": "raspberry", "url": "https://www.raspberrypi.org/" }]
%}
{%- for p in params %}
{%- set name = p["name"] %}
{%- set url = p["url"] %}
apiVersion: batch/v1
kind: Job
metadata:
  name: jobexample-{{ name }}
  labels:
    jobgroup: jobexample
spec:
  template:
    metadata:
      name: jobexample
      labels:
        jobgroup: jobexample
    spec:
      containers:
      - name: c
        image: busybox
        command: ["sh", "-c", "echo Processing URL {{ url }} && sleep 5"]
      restartPolicy: Never
---
{%- endfor %}

在使用这种模式需要确保已经安装了 jinja2 的包:pip install --user jinja2

再执行一条 Python 命令即可替换:

alias render_template='python -c "from jinja2 import Template; import sys; print(Template(sys.stdin.read()).render());"'
cat job.yaml.jinja2 | render_template > jobs.yaml

或者直接进行 kubectl create:

alias render_template='python -c "from jinja2 import Template; import sys; print(Template(sys.stdin.read()).render());"'
cat job.yaml.jinja2 | render_template | kubectl create -f -

固定 completions 数的 Job

这种模式就真正实现了并行工作模式,且 Job 的完成数是固定的。

在这种模式下,需要一个存放 work item 的队列,比如 RabbitMQ,我们需要先将要处理的任务变成 work item 放入任务队列。每个 pod 创建时,去队列里获取一个 task,完成后将其从队列里删除,直到完成了定义的 completions 数。

上图描述了一个 completions=6,parallelism=2 的 Job 的示意图。选择 RabbitMQ 来充当这里的工作队列;外部生产者产生 6 个 task ,放入工作队列中;在 pod 模板中定义 BROKER_URL,来作为消费者。一旦创建了这个 Job,就会以并发度为 2 的方式,去消费这些 task,直到任务全部完成。其 yaml 文件如下:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-1
spec:
  completions: 6
  parallelism: 2
  template:
    metadata:
      name: job-wq-1
    spec:
      containers:
      - name: c
        image: myrepo/job-wq-1
        env:
        - name: BROKER_URL
          value: amqp://guest:guest@rabbitmq-service:5672
        - name: QUEUE
          value: job1
  restartPolicy: OnFailure

固定 parallelism 数的 Job

最后一种模式是指定并行度(parallelism),但不设置固定的 completions 的值。

每个 pod 去队列里拿任务执行,完成后继续去队列里拿任务,直到队列里没有任务,pod 才退出。这种情况下,只要有一个 pod 成功退出,就意味着整个 Job 结束。这种模式对应的是任务总数不固定的场景。

上图描述的是一个并行度为 2 的 Job。RabbitMQ 不能让客户端知道是否没有数据,因此这里采用 Redis 队列;每个 pod 去队列里消费一个又一个任务,直到队列为空后退出。其对应的 yaml 文件如下:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-2
spec:
  parallelism: 2
  template:
    metadata:
      name: job-wq-2
    spec:
      containers:
      - name: c
        image: myrepo/job-wq-2
  restartPolicy: OnFailure

CronJob

Kubernetes 在 v1.5 开始引入了 CronJob 对象,顾名思义,就是定时任务,类似 Linux Cron。先看个例子:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: cron-date
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: date
            image: ubuntu:16.04
            command: ["sh", "-c", "date >> /date/date.txt"]
            volumeMounts:
            - mountPath: /date
              name: date-volume
          nodeSelector:
            kubernetes.io/hostname: rancher-node3
          volumes:
          - name: date-volume
            hostPath:
              path: /date
          restartPolicy: OnFailure

CronJob 其实就是一个 Job 对象的控制器,需要定义一个 Job 的模板,即 jobTemplate 字段;另外,其定时表达式 schedule 基本上照搬了 Linux Cron 的表达式:

# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of the month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12)
# │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday;
# │ │ │ │ │                                   7 is also Sunday on some systems)
# │ │ │ │ │
# │ │ │ │ │
# * * * * * 

创建出该 CronJob 对象后,CronJob 会记录下最近一次 Job 的执行时间:

[root@rancher-node1 jobs]# kubectl get cronjob cron-date
NAME        SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cron-date   */1 * * * *   False     0        22s             15m
[root@rancher-node1 jobs]# kubectl get job
NAME                   DESIRED   SUCCESSFUL   AGE
cron-date-1545584220   1         1            2m
cron-date-1545584280   1         1            1m
cron-date-1545584340   1         1            23s
[root@rancher-node1 jobs]# kubectl get po
NAME                         READY   STATUS      RESTARTS   AGE
cron-date-1545584220-gzmzw   0/1     Completed   0          2m
cron-date-1545584280-bq9nx   0/1     Completed   0          1m
cron-date-1545584340-84tf2   0/1     Completed   0          27s

如果某些定时任务比较特殊,某个 Job 还没有执行完,下一个新的 Job 就产生了。这种情况可以通过设置 spec.concurrencyPolicy 字段来定义具体策略:

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;
  2. concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;
  3. concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。

Kubernetes 所能容忍的 Job 创建失败数为 100,但是其失败时间窗口可以自定义。即通过字段 spec.startingDeadlineSeconds 可以用来设定这个时间窗口,单位为秒,也就是说在这个时间窗口内最大容忍数为 100,如果超过了 100,这个 Job 就不会再被执行。

2018/12/22 posted in  Kubernetes