Kubernetesクラスタへのアクセス権限をブートストラップする
2021年12月17日 金曜日
CONTENTS
【IIJ 2021 TECHアドベントカレンダー 12/19(日)の記事です】
SRE推進部の李です。
Kubernetes便利ですね。慣れてくると状態を持つ情報は何でもCRDにしてKubernetesに入れたくなります。一方でたまにどうしてもKubernetesクラスタに取り込めないものを使いたくなることがあります。このようなKubernetesの外のものからKubernetesに入っている情報を利用するための方法について考えてみます。
背景
様々な場所で参照する情報をKubernetesに入れてしまえば共通したインターフェースで管理できるようになるので運用が楽になります。
例えばKubernetesの管理下に置かない仮想マシンがあったときのことを考えてみます。Kubernetesの管理下に置かないというのはつまり仮想マシン上でkubeletをおいてKubernetesのノードとして扱わないということです。一方でこの仮想マシンの一部の状態を制御するのに必要な情報をKubernetesに置いて管理することで管理の集約をしたいと考えます。
この時、仮想マシンからどのようにしてKubernetesにある情報を取得するのかという課題が生じます。
例えばKubernetesにアクセスするための認証情報を人間がsshなどを使って直接仮想マシンに置くことが考えられます。このようにすることで、仮想マシン側からKubernetesにアクセスするという素直な実装ができます。この方法では配置するための手作業が発生します。また認証情報の有効期限が切れると再度配置するための仕組みが別途必要になります。
上記のような課題が生じるのは、ノードがKubernetesクラスタに加入していない状態において信頼を確立することには障壁があるためです。クラウドプロバイダを利用していればより上位の権限システムが存在するために信頼の確立を仲介することで、システムを自律化させることができます。
今回のPoCではその助けがない場合のことを考えてみます。
PoCの前提要件
上記のことを鑑みて以下の前提を考えます。
- 仮想マシンは初期からKubernetesクラスタへアクセスするための認証情報を持たない
- Kubernetesは仮想マシンへログインするための認証情報を持たない
- Kubernetesが仮想マシンを許可するかどうかは人間の承認を伴うようにする
上記の条件を満たすようにカスタムコントローラを実装してみます。
設計
前提を満たすためにkubeletと同じ要領で、仮想マシンの制御は仮想マシンからのpush型のリクエストを使うことにします。これにより、仮想マシンの方からいつでも制御を切り離せるようにします。また、初期状態では仮想マシンはKubermetesにアクセスする認証情報を持たないため、仮想サーバがKubernetesに認証無しにアクセスし、その後公開鍵暗号方式を使って認証情報を交換するようにします。
するとブートストラップのためにKubernetes側にanonymousでアクセスするためのリソースが必要になります。仮にこれをAccessRequestリソースとします。
仮想マシンの方からAccessRequestを作成してKubernetesクラスタにアクセス権限を要求します。この要求に、仮想マシンの公開鍵を含めておきます。
一方でKubernetes側もAccessRequestを作成されたら直ちに権限を与えるのは心もとないです。AccessRequestリソースが作成されると一旦保留をしておきます。これに対して運用者がKubernetesクラスタに承認の指示を出せるようにStatusを持ちます。運用者が許可を出すとKubernetes側のコントローラが動作して、必要な権限リソースを作成して、アクセスするための認証情報を暗号化してAccessRequestに登録します。登録されている公開鍵によって暗号化されているためリクエストを送った仮想マシン以外では認証情報を入手することはできません。
最後に仮想マシン側からAccessRequestをポーリングして、認証情報が与えられたらそれを自身に設定してKubernetesクラスタへのアクセスが実現されます。
この設計ではKubernetes側のコントローラと仮想マシン側のコントローラを実装すれば、仮想マシンをデプロイするだけで、あとは人間がKubernetesへのアクセスを許可すればKubernetesクラスタと仮想マシンを結合できるようになります。
以後設計通り実装するだけです。
kubebuilderを使ってAPIを生成する
クライアント証明書なしにKubernetesのAPIサーバにアクセスすると、system:anonymousのユーザとして認識されます。そのためCRDを作成して権限を許可します。
kubebuilder init --domain requests.test --repo mkgrei/requests-poc
kubebuilder create api --group cr --version v1beta1 --kind AccessRequest --namespaced False
これにより以下のコードが自動生成されます。
api/v1beta1/accessrequest_types.go |
ユーザが触れないようにStatusのサブリソースを使います。また、コントローラの処理が完了したことを知らせるフラグを見やすくするためにprintcolumnを追加します。
//+kubebuilder:resource:path=tenants,scope=Cluster
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.ready"
ユーザが作成するリソースには公開鍵だけ登録してもらいます。
// AccessRequestSpec defines the desired state of AccessRequest
type AccessRequestSpec struct {
PubKey string `json:"pubKey,omitempty"`
}
AccessRequestリソースのStatusにはCAとTokenを暗号化したデータを置くためのキーとそれを暗号化するための共通鍵を置くためのキーを追加します。
// AccessRequestStatus defines the observed state of AccessRequest
type AccessRequestStatus struct {
Accepted bool `json:"accepted,omitempty"`
Ready bool `json:"ready,omitempty"`
Key string `json:"key,omitempty"`
Token string `json:"token,omitempty"`
CA string `json:"ca,omitempty"`
}
以上で最低限必要のリソースができました。
Kubernetesのコントローラを実装する
次にコントローラを実装します。コントローラの雛形は以下のファイルです。
controllers/accessrequest_controller.go |
まずコントローラに必要な権限を追加します。AccessRequestリソースを監視するための権限の他に、ServiceAccountの作成権限やRoleBindingの編集権限を追加しています。
//+kubebuilder:rbac:groups=cr.requests.test,resources=accessrequests,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cr.requests.test,resources=accessrequests/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=cr.requests.test,resources=accessrequests/finalizers,verbs=update
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=role,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebinding,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=,resources=serviceaccount,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=,resources=secret,verbs=get;list;watch;create;update;patch;delete
AccessRequestリソースの承認を受けてコントローラがServiceAccountを作成させるため、Ownsで指定する。
func (r *AccessRequestReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&crv1beta1.AccessRequest{}).
Owns(&corev1.ServiceAccount{}).
Complete(r)
}
AccessRequestリソースが承認させるとServiceAccountが作成されて、既存のRoleBinding: pod-viewerに紐づくようにします。また、すべての許可はNamespace: defaultに限定しています。
Roleについては事前に作成済みであることを想定しているので、特に作成も変更も行いません。
const (
defaultNamespace = "default"
defaultRoleBinding = "pod-viewer"
controllerKey = "controller"
controllerVal = "AccessRequest"
)
var (
defaultVerb = [...]string{"get", "list", "watch"}
letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
)
AccessRequestリソースの作成、変更と削除の際に呼ばれるReconcileの実装です。
削除される場合は特に何もしません。
削除される場合以外ならば、.status.acceptedがtrueであるかどうかを見ます。trueでなければまだ承認されていないことを意味しますので特に何もしません。また、trueにする際にAccessRequestリソースの更新が呼ばれるのでrequeueもしません。
.status.accepted: trueの場合にのみその後のコードが実行されます。ここではまずServiceAccountが存在しなければ新規に作成するためのreconcileServiceAccountを呼びます。ServiceAccountが作成されるとServiceAccount用のTokenがSecretリソースとして作成されますので、それが終わるまで待ちます。待つ方法としては一定時間後にrequeueしています。
Secretが作成されているとAccessRequestリソースのStatusに書き込むための情報が全て揃います。Statusに書き込む際にまずランダムな共通鍵を生成して.spec.pubKeyとして登録されている公開鍵で暗号化します。更に生成された共通鍵を使ってCAとTokenを暗号化します。最後に暗号化された共通鍵、CAとTokenをAccessRequestリソースのStatusに書き込んで更新をします。
func (r *AccessRequestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var (
err error
ca string
token string
obj crv1beta1.AccessRequest
sa *corev1.ServiceAccount
sec corev1.Secret
)
if err = r.Get(ctx, req.NamespacedName, &obj); err != nil {
if apiErrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
l.Info(err.Error())
return ctrl.Result{}, err
}
if !obj.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
if a := obj.Status.Accepted; !a {
fmt.Println("not accepteed")
return ctrl.Result{}, nil
}
sa, _, err = r.reconcileServiceAccount(ctx, &obj)
if len(sa.Secrets) == 0 {
return ctrl.Result{RequeueAfter: time.Second * 2}, nil
}
sec = corev1.Secret{}
sec.Name = sa.Secrets[0].Name
sec.Namespace = sa.Namespace
if err = r.Get(ctx, client.ObjectKeyFromObject(&sec), &sec); err != nil {
return ctrl.Result{}, err
}
key := generateKey(32)
ekey, err := encryptRSA(obj.Spec.PubKey, []byte(key))
ca, err = encryptAES(key, sec.Data["ca.crt"])
if err != nil {
return ctrl.Result{}, err
}
token, err = encryptAES(key, sec.Data["token"])
if err != nil {
return ctrl.Result{}, err
}
obj.Status.CA = ca
obj.Status.Key = ekey
obj.Status.Token = token
obj.Status.Ready = true
err = r.Status().Update(ctx, &obj)
if err != nil {
return ctrl.Result{}, err
}
if err = r.reconcileRoleBinding(ctx, &obj); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
ServiceAccountを作成するreconcileServiceAccountの実装です。ここでは簡単に既存のServiceAccountを奪ってしまわないようにコントローラによって管理されているServiceAccountにAnnotationをつけています。これによりコントローラによって作成されるServiceAccountだけに対してTokenが渡されることになります。例えば既存のより強い権限を持つServiceAccountのTokenを抜くようなAccessRequestが作成されることを防ぐことができます。
func (r *AccessRequestReconciler) reconcileServiceAccount(ctx context.Context, req *crv1beta1.AccessRequest) (*corev1.ServiceAccount, bool, error) {
var (
err error
)
sa := corev1.ServiceAccount{}
sa.Name = req.Name
sa.Namespace = defaultNamespace
if err = r.Get(ctx, client.ObjectKeyFromObject(&sa), &sa); err != nil {
if client.IgnoreNotFound(err) != nil {
return nil, false, err
}
sa.Annotations = map[string]string{controllerKey: controllerVal}
if err = r.Create(ctx, &sa); err != nil {
return nil, false, err
}
return &sa, true, nil
}
cn, found := sa.Annotations[controllerKey]
if !found {
return nil, false, errors.New("ServiceAccount is not under control")
}
if cn != controllerVal {
return nil, false, errors.New("ServiceAccount has other controller")
}
return &sa, false, nil
}
作成されたServiceAccountに対して既存のRoleBindingに追記するためのreconcileRoleBindingの関数です。
func (r *AccessRequestReconciler) reconcileRoleBinding(ctx context.Context, req *crv1beta1.AccessRequest) error {
var err error
rb := rbacv1.RoleBinding{}
rb.Name = defaultRoleBinding
rb.Namespace = defaultNamespace
if err = r.Get(ctx, client.ObjectKeyFromObject(&rb), &rb); err != nil {
return err
}
s := rbacv1.Subject{
Kind: "ServiceAccount",
Namespace: defaultNamespace,
Name: req.Name,
}
if !contains(rb.Subjects, s) {
rb.Subjects = append(rb.Subjects, s)
}
if err = r.Update(ctx, &rb); err != nil {
return err
}
return nil
}
func contains(ss []rbacv1.Subject, s rbacv1.Subject) bool {
for _, cs := range ss {
if cs.Kind == s.Kind &&
cs.Name == s.Name &&
cs.Namespace == s.Namespace {
return true
}
}
return false
}
以下はランダムな共通鍵を生成するための関数や、公開鍵や共通鍵で暗号化するための関数です。
func encryptRSA(k string, s []byte) (string, error) {
pkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
if err != nil {
return "", err
}
pckey := pkey.(ssh.CryptoPublicKey)
prkey := pckey.CryptoPublicKey().(*rsa.PublicKey)
enc, err := rsa.EncryptOAEP(sha512.New(), crand.Reader, prkey, s, nil)
if err != nil {
return "", err
}
emsg := base64.StdEncoding.EncodeToString(enc)
return emsg, nil
}
func encryptAES(k string, s []byte) (string, error) {
c, err := aes.NewCipher([]byte(k))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(crand.Reader, nonce); err != nil {
return "", err
}
enc := gcm.Seal(nonce, nonce, s, nil)
emsg := base64.StdEncoding.EncodeToString(enc)
return emsg, nil
}
func generateKey(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[mrand.Intn(len(letters))]
}
return string(b)
}
以上でコントローラの実装は完了です。
このコントローラを動かすとAccessRequestリソースが作成されると、まず.status.acceptedを見て、当然これは存在しないので何もせずにスルーします。何かの拍子に承認されて.status.accepted: trueが書き込まれることを検知すると認証情報を渡せるように処理をしてくれます。
.statusを編集するのにテスト環境ではkubectl-edit-status(ulucinar/kubectl-edit-status: A kubectl plugin for editing /status subresource (github.com))を利用しました。
クライアントのコントローラを実装する
次にクライアント側の実装です。特に理由はありませんが簡単にPythonで実装します。
Goのライブラリで暗号化された内容をPythonのライブラリを使って復号します。
KubernetesのAPIのURLはなにかの方法で通知する必要があります。ローカルにDNSが引ければそこで抽象化することができます。通知する方法は決めの問題としてクライアント側の実装ではコマンドラインの引数で渡せるようにしています。
AccessRequestリソースを作成する際に公開鍵が必要であるため、クライアント側では鍵ペアを生成します。ライブラリを使っても良かったのですが、潔くsubprocessでshellを起動してssh-keygenを叩いて生成しています。生成時のファイル名はコマンドラインの引数で与えることが出来るようになっています。
鍵を生成するとAccessRequestリソースを作成します。ホスト名+ランダム文字列で作成することで万が一のホスト名の衝突を回避します。AccessRequestリソースの作成方法としてはKubernetesのAPIの命名規則に従って、次の /apis/cr.requests.test/v1beta1/accessrequests というPathに対してマニフェストファイルをJSON形式でPOSTします。この際はまだCAも持っていないためinsecureな接続にしています。作成後にAccessRequestリソースの.status.readyがtrueになるまでポーリングして待ち続けます。
最後に.status.readyになると、前章のコントローラによって暗号化されたCAとTokenがStatusに書き込まれているはずです。暗号化の際の手順と逆に、まず手持ちの秘密鍵を使って公開鍵によって暗号化された共通鍵を復号します。次に復号された共通鍵を使ってCAとTokenを復号します。これで認証されたKubeconfigを作成するための情報が揃いました。
最後にKubeconfigを生成してあとは好きな操作を実行します。サンプル例ではpod-viewerとしての権限が与えられるので、定期的にポーリングしてNamespace: defaultにあるPodの情報(PodIP)を表示し続けます。
import time
import socket
import subprocess
import string
import random
import binascii
import base64
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Hash import SHA512, SHA1
from Crypto.Signature import pss
import requests
import click
from kubernetes import client, config
run = subprocess.getoutput
def generate_sshkey(keyname):
cmd = f'ssh-keygen -f {keyname} -N "" <<<y >/dev/null 2>&1'
run(cmd)
with open(f"{keyname}", 'r') as f:
data = f.readlines()
rkey = RSA.import_key(''.join(data))
with open(f"{keyname}.pub", 'r') as f:
data = f.readlines()
pkey = RSA.import_key(''.join(data))
return rkey, pkey
def create_access_request(url, path, pkey):
cr = {
'apiVersion': 'cr.requests.test/v1beta1',
'kind': 'AccessRequest',
'metadata': {},
'spec': {},
}
cr['metadata']['name'] = socket.gethostname() + "-" + ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(5))
cr['spec']['pubKey'] = pkey.exportKey(format="OpenSSH").decode()
try:
req = requests.post(url+path, json=cr, verify=False)
except:
pass
while True:
req = requests.get(url+path+f"/{cr['metadata']['name']}", verify=False)
ready = req.json().get('status', {}).get('ready', False)
if ready:
data = req.json()
ca = data['status']['ca']
token = data['status']['token']
key = data['status']['key']
print('access request accepted')
break
print('condition not met, waiting...')
time.sleep(5)
return ca, token, key
def decrypt_oaep(private_key, aes_ekey):
d = PKCS1_OAEP.new(private_key, hashAlgo=SHA512, mgfunc=lambda x,y: pss.MGF1(x,y,SHA512))
data = base64.b64decode(aes_ekey)
m = d.decrypt(data)
return m
def decrypt_aes(aes_key, edata):
data = base64.b64decode(edata)
nonce, emsg, tag = data[:12], data[12:-16], data[-16:]
d = AES.new(aes_key, AES.MODE_GCM, nonce=nonce)
m = d.decrypt_and_verify(emsg, tag)
return m
def generate_kubeconfig(url, ca, token):
template = '''apiVersion: v1
kind: Config
contexts:
- context:
cluster: cluster
user: pod-viewer
name: pod-viewer
current-context: pod-viewer
clusters:
- cluster:
certificate-authority-data: {ca}
server: {server}
name: cluster
users:
- name: pod-viewer
user:
token: {token}
'''
kubeconfig = template.format(
token=token.decode(),
ca=base64.b64encode(ca).decode(),
server=url
)
fkubeconfig = "gen.kubeconfig"
with open(fkubeconfig, 'w') as f:
f.write(kubeconfig)
return fkubeconfig
def watch_pods(fkubeconfig):
config.load_kube_config(fkubeconfig)
v1 = client.CoreV1Api()
while True:
ret = v1.list_namespaced_pod("default")
print('----')
for po in ret.items:
print(f"{po.metadata.namespace} {po.metadata.name} {po.status.pod_ip}")
time.sleep(5)
@click.command()
@click.option("--url", type=str, help="kubernetes cluster api server url", default="https://localhost:6443")
@click.option("--keyname", type=str, help="ssh key name", default="test")
def main(url, keyname):
path = "/apis/cr.requests.test/v1beta1/accessrequests"
rkey, pkey = generate_sshkey(keyname)
eca, etoken, ekey = create_access_request(url, path, pkey)
aes_key = decrypt_oaep(rkey, ekey)
token = decrypt_aes(aes_key, etoken)
ca = decrypt_aes(aes_key, eca)
fkubeconfig = generate_kubeconfig(url, ca, token)
watch_pods(fkubeconfig)
if __name__ == "__main__":
main()
動作確認の一例
試しに手元でkindでKubernetesクラスタを建てて、コントローラを動かしてみます。クライアントを動かすとAccessRequestリソースを作成して、Ready待ち状態になります。
人が承認としてAccessRequestリソースの.status.accepted: trueを記入すると、コントローラによって認証情報がAccessRequestリソースに置かれて、クライアント側で検知します。
あとはひたすらPodの情報を表示し続けます。サンプル例では途中でレプリカ数を増やして、その際のPodの追加やPodIPの割当が進んでいる状況が見れています。
$ python main.py --url https://127.0.0.1:42529
condition not met, waiting... <------AccessRequestを作成して承認待ち状態に
condition not met, waiting...
condition not met, waiting...
condition not met, waiting...
condition not met, waiting...
access request accepted <------AccessRequestがReadyになったのでkubeconfigを生成する
----
default curl-7bb98d65f4-gsj2f 10.244.1.8 <-------Kubernetesの情報が取得できるようになる
----
default curl-7bb98d65f4-gsj2f 10.244.1.8
----
default curl-7bb98d65f4-gsj2f 10.244.1.8
----
default curl-7bb98d65f4-6qcbz 10.244.2.8 <-------Deploymentのreplica数を変えて見ているのが観測できる
default curl-7bb98d65f4-ggnjc 10.244.1.9
default curl-7bb98d65f4-gsj2f 10.244.1.8
default curl-7bb98d65f4-xf7sf None
----
default curl-7bb98d65f4-6qcbz 10.244.2.8
default curl-7bb98d65f4-ggnjc 10.244.1.9
default curl-7bb98d65f4-gsj2f 10.244.1.8
default curl-7bb98d65f4-xf7sf 10.244.2.9
^C
Aborted!
まとめ
上記のようにコントローラを作成すると、kubeletが動いていないような場所でもKubernetes内の特定のリソースを見るように実装することができます。
応用としてVMをデプロイするとcloud-initでTokenを要請するようにして、Kubernetes側で承認するとkubeadm joinするためのTokenが受け渡されてNodeを追加するような仕組みも作れます。
また発展編としてAdmission Webhookを利用して、例えば同一L2セグメント内のAccessRequestsだけを許すようにすればよりセキュリティを高めることも可能です。
// 所感、言語が異なるものを使うと暗号化ライブラリ周りが結構ハマったのが辛かったです。