BoundServiceAccountTokenVolumeを有効にしてみる

## BoundServiceAccountTokenVolumeとは

`BoundServiceAccountTokenVolume`はv1.13からalphaの機能として提供されており、FeatureGateで有効にすることができます。これにより、これまでのSecretに書き込まれたトークンをマウントする方式に変わり、宛先や有効期限が指定されたトークンがProjectedVolumeによってPodに渡されるようになります。似たような機能として`TokenRequestProjection`がありますが、`BoundServiceAccountTokenVolume`は`TokenRequestProjection`で明示的に指定する必要のあったパラメータを動的に設定してくれます。
`BoundServiceAccountTokenVolume`は`TokenRequest`、`TokenRequestProjection`や`RootCAConfigMap`の機能に依存していますが、これらはv1.20時点ではBetaまたはGAになっています。

kube-apiserver(k8s v1.20)

    $ kube-apiserver
      - --feature-gates=BoundServiceAccountTokenVolume=true
      - --service-account-issuer=https://kubernetes.default.svc.cluster.local
      - --service-account-key-file=/etc/kubernetes/pki/sa.pub
      - --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
      ...

kube-controller-manager(k8s v1.20)

    $ kube-controller-manager
      - --feature-gates=BoundServiceAccountTokenVolume=true
      ...

しかしトークンが期限付きに切り替わるといっても、一度にすべてを切り替える必要は無さそうです。
v1.20時点においては、`BoundServiceAccountTokenVolume`を有効にしても、SecretベースのレガシーなService Account トークンは引き続き新規でも発行される状態のようですし、kube-apiserverにて`--service-account-extend-token-expiration`を`true`に設定すると、期限付きトークンの期限を１年間に延長することができます。

さらに、k8sでは`serviceaccount_legacy_tokens_total`というメトリックが提供されており、これを利用してレガシーなトークン(期限や宛先が設定されていない)を利用しているクライアントが存在していないか確認することができます。また、トークンの有効期限を延長している場合も同様に`serviceaccount_stale_tokens_total`によってローテーションが正しく行われていないクライアントの存在を確認することができます。
これらを活用することで、確認しながら確実に切り替えを進めていくことができそうです。

## 動作確認

K8s v1.20にてFeatureGateで`BoundServiceAccountTokenVolume`を有効にした状態でトークンの状況を確認してみます。

```yaml
$ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.14.2
        ports:
        - containerPort: 80
EOF
```

Podのマニフェストを確認してみます。

```yaml
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2020-12-17T05:55:31Z"
  generateName: nginx-deployment-66b6c48dd5-
  labels:
    app: nginx
    pod-template-hash: 66b6c48dd5
  managedFields:
    # 省略
  name: nginx-deployment-66b6c48dd5-79vdm
  namespace: default
  ownerReferences:
  - apiVersion: apps/v1
    blockOwnerDeletion: true
    controller: true
    kind: ReplicaSet
    name: nginx-deployment-66b6c48dd5
    uid: f9e5f1ef-5b48-4469-8165-a864422af47c
  resourceVersion: "3518"
  uid: b94c6894-1b49-40de-a3f7-6feabd58ccfa
spec:
  containers:
  - image: nginx:1.14.2
    imagePullPolicy: IfNotPresent
    name: nginx
    ports:
    - containerPort: 80
      protocol: TCP
    resources: {}
    terminationMessagePath: /dev/termination-log
    terminationMessagePolicy: File
    volumeMounts:
    - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      name: kube-api-access-jz2lz
      readOnly: true
  dnsPolicy: ClusterFirst
  enableServiceLinks: true
  nodeName: bound-token-test-worker
  preemptionPolicy: PreemptLowerPriority
  priority: 0
  restartPolicy: Always
  schedulerName: default-scheduler
  securityContext: {}
  serviceAccount: default
  serviceAccountName: default
  terminationGracePeriodSeconds: 30
  tolerations:
  - effect: NoExecute
    key: node.kubernetes.io/not-ready
    operator: Exists
    tolerationSeconds: 300
  - effect: NoExecute
    key: node.kubernetes.io/unreachable
    operator: Exists
    tolerationSeconds: 300
  volumes:
  - name: kube-api-access-jz2lz
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          expirationSeconds: 3607
          path: token
      - configMap:
          items:
          - key: ca.crt
            path: ca.crt
          name: kube-root-ca.crt
      - downwardAPI:
          items:
          - fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
            path: namespace
```
上記のように何も指定していなくてもK8s APIサーバの証明書を検証するためのCA証明書やServiceAccount トークンがProjected Volumeを使ってPodに渡されるように設定されています。(`.spec.volumes[0].projected`あたり参照)

レガシートークンの場合は以下のようになります。

```yaml
  volumes:
  - name: default-token-2dq9k
    secret:
      defaultMode: 420
      secretName: default-token-2dq9k
```

Podに渡されたトークンの中身を見てみると以下のような値が設定されています。k8s v1.20ではデフォルトで`--service-account-extend-token-expiration=true`に設定されているため、有効期限が１年後になっており、さらに`kubernetes.io.warnafter`というkubernetes固有のclaimが設定されています。これはおおよそ1時間後の本来の有効期限が設定されています。この値を超えてもトークンを使っているクライアントが存在する場合には先ほど説明した`serviceaccount_stale_tokens_total`がカウントされます。
Token自体の有効期限(exp)が１年後になっていても、Podにマウントされているトークンは本来の期限内(１時間)でローテーションされるようです。また、多くのクライアントライブラリでは短時間で定期的にローテーションしているように見えました。

    $ export TOKEN=$(kubectl exec nginx-deployment-66b6c48dd5-79vdm -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
    
    $ irb
    irb(main):002:0> t = ENV['TOKEN'].split('.')
    irb(main):003:0> puts Base64.urlsafe_decode64(t[1])
    {"aud":["https://kubernetes.default.svc.cluster.local"],"exp":1639720531,"iat":1608184531,"iss":"https://kubernetes.default.svc.cluster.local","kubernetes.io":{"namespace":"default","pod":{"name":"nginx-deployment-66b6c48dd5-79vdm","uid":"b94c6894-1b49-40de-a3f7-6feabd58ccfa"},"serviceaccount":{"name":"default","uid":"7cd6f2df-e503-4509-be21-53a2e9edd40a"},"warnafter":1608188138},"nbf":1608184531,"sub":"system:serviceaccount:default:default"}

```json
{
	"aud": ["https://kubernetes.default.svc.cluster.local"],
	"exp": 1639720531,
	"iat": 1608184531,
	"iss": "https://kubernetes.default.svc.cluster.local",
	"kubernetes.io": {
		"namespace": "default",
		"pod": {
			"name": "nginx-deployment-66b6c48dd5-79vdm",
			"uid": "b94c6894-1b49-40de-a3f7-6feabd58ccfa"
		},
		"serviceaccount": {
			"name": "default",
			"uid": "7cd6f2df-e503-4509-be21-53a2e9edd40a"
		},
		"warnafter": 1608188138
	},
	"nbf": 1608184531,
	"sub": "system:serviceaccount:default:default"
}
```

| Subject                 | system:serviceaccount:default:default        |
| ----------------------- | -------------------------------------------- |
| Audience                | https://kubernetes.default.svc.cluster.local |
| IssuedAt                | 1608184531 =  2020-12-17 14:55:31            |
| Expiration Time         | 1639720531 = 2021-12-17 14:55:31             |
| kubernetes.io.warnafter | 1608188138 = 2020-12-17 15:55:38             |

APIサーバの`/metrics`を参照してみると以下のようになっていました。

    # TYPE serviceaccount_legacy_tokens_total counter
    serviceaccount_legacy_tokens_total 0
    # HELP serviceaccount_stale_tokens_total [ALPHA] Cumulative stale projected service account tokens used
    # TYPE serviceaccount_stale_tokens_total counter
    serviceaccount_stale_tokens_total 0
    # HELP serviceaccount_valid_tokens_total [ALPHA] Cumulative valid projected service account tokens used
    # TYPE serviceaccount_valid_tokens_total counter
    serviceaccount_valid_tokens_total 2264

このとき、Authorizationヘッダにレガシーなトークン(Secretに書き込まれているもの)をセットしてみると、 `serviceaccount_legacy_tokens_total`が増えることが確認できます。

    $ curl  https://kubernetes.default.svc/metrics -H "Authorization: Bearer ${LEGACY_TOKEN}" --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt |grep "serviceaccount_"
    
    # TYPE serviceaccount_legacy_tokens_total counter
    serviceaccount_legacy_tokens_total 1
    # HELP serviceaccount_stale_tokens_total [ALPHA] Cumulative stale projected service account tokens used
    # TYPE serviceaccount_stale_tokens_total counter
    serviceaccount_stale_tokens_total 0
    # HELP serviceaccount_valid_tokens_total [ALPHA] Cumulative valid projected service account tokens used
    # TYPE serviceaccount_valid_tokens_total counter
    serviceaccount_valid_tokens_total 2309

ちなみに`service-account-extend-token-expiration=false`の状態では以下のようにトークンの有効期限は1時間に設定されます。

    irb(main):002:0> t = ENV['TOKEN'].split('.')
    => ["eyJhbGciOiJSUzI1NiIsImtpZCI6Ik54Tm55TjlWeXhqWXJzRi04c05KNjktMDJRWDdaaHptOHRHX2NnUW9LeVEifQ", "eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNjA4MTkyNzE5LCJpYXQiOjE2MDgxODkxMTIsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0IiwicG9kIjp7Im5hbWUiOiJuZ2lueC1kZXBsb3ltZW50LTY2YjZjNDhkZDUtanFuOWciLCJ1aWQiOiJmMDcyNWI1Ni1iNDg5LTQ1NTItOGYxZi00NjI2OTJmOGFlYWYifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlZGRiNTNmZi1iZGVkLTRiOWUtOGUzZS1jYWIzNTYyMWQ3NjYifX0sIm5iZiI6MTYwODE4OTExMiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9", "PD0zRGev0XThGfd2kQqinxHpTKareo0BU5qXBQztK4J2J-zXyXqU3hzEnCpeVeTpVtAzFrisYnbBnF1cm29OwJpefvjbb1Y3q1zPboMz-5G3RYnLvkd8hi6SPtNQ5B8QRAhXGNrKlb6fChUnJtsfdt_9bOjtPDdJKi5UP6Yf2ho23bqvHd95epvGQKb8iRaGSGPyyeboCSFpzRaKEJBuJ_BGyH_-VsbILAJjs5q8QY-aZQelGX4YZAFcUSa7COerDBWfSDiiv1vs9SuwYmviJzgpGoEBWRZFf2OWCOAKc5GIaOKwK7I26np4SyN8Zpa71GNowvsQRTXvfPYx_hkUrg"]
    irb(main):003:0> puts Base64.urlsafe_decode64(t[1])
    {"aud":["https://kubernetes.default.svc.cluster.local"],"exp":1608192719,"iat":1608189112,"iss":"https://kubernetes.default.svc.cluster.local","kubernetes.io":{"namespace":"default","pod":{"name":"nginx-deployment-66b6c48dd5-jqn9g","uid":"f0725b56-b489-4552-8f1f-462692f8aeaf"},"serviceaccount":{"name":"default","uid":"eddb53ff-bded-4b9e-8e3e-cab35621d766"}},"nbf":1608189112,"sub":"system:serviceaccount:default:default"}
    => nil
| Subject                 | system:serviceaccount:default:default        |
| ----------------------- | -------------------------------------------- |
| Audience                | https://kubernetes.default.svc.cluster.local |
| IssuedAt                | 1608189112 =  2020-12-17 16:11:52            |
| Expiration Time         | 1608192719 = 2020-12-17 17:11:59             |
| kubernetes.io.warnafter | N/A                                          |

## まとめ

`BoundServiceAccountTokenVolume`は現在ベータリリースに向けた準備が行われており、v1.21でベータ、v1.22でGAのスケジュールで進められているようです。
https://github.com/kubernetes/enhancements/blob/master/keps/sig-auth/1205-bound-service-account-tokens/README.md#graduation-criteria

トークンの切り替えには各種ライブラリの対応を含めて、多くのコードで変更が必要になることが予想されるため、早い段階からやるべきことを認識して備えておくのが良さそうです。