GKEノードを自動再作成する - Preemptible VM Killer

GCP の Kubernetes サービス (GKE) にて格安ノード Preemptible VM を利用してクラスタを構築しているのですが、Preempbile VM は性質上、24時間で強制終了されてしまいます。強制終了されても自動で再作成されるため、ゆるいサービスレベルであればさほど問題ではないのですが、どうせならもう少し安定的に運用したい思いもありました。その対策に Preemptible Killer というアプリケーションを用いて計画停止をできるようにします。

前提

  • Preemptible VM (プリエンプティブル VM) とは、安い料金設定がされたVMインスタンスです。
  • Preemptible VM と同様の性質を持つ異なるオプションとして Spot VM というものもありますが、この記事での説明対象ではありません。
  • GKE の Wokload Identity 設定が有効になっている必要があります。下記の記事で説明しているので必要に応じて参照してください。
  • Helm を利用しますが、Helm の構成管理を Git 管理しやすくするための補助ツール Helmfile を利用しています。
    • Helmfile は Helm コマンドを操作しやすくする意味からもお勧めです。詳細は過去の記事を参照してください。
[GKE] サービスアカウントとサービスアカウントを連携する - Workload Identity
[GKE] サービスアカウントとサービスアカウントを連携する - Workload Identity
https://fand.jp/technologies/federate-google-service-accounts-with-kubernetes-sa-using-workload-identity/
GCPと Kubernets のサービスアカウントを連携できる IAM関連機能の Workload Identity について使い方を整理しました
Helm 再入門 - Helmfile をインストールして特徴を整理する
Helm 再入門 - Helmfile をインストールして特徴を整理する
https://fand.jp/technologies/make-helm-comfortable-with-helmfile/
一度は Helm の利用をやめましたが Helm v3 と Helmfile で利便性が改善したので再評価してみました。

セットアップ手順

セットアップの大きな流れは以下の通りです。

  • VMを削除するための権限(ROLE)を作成したうえで Preemptible Killer 専用の Google サービスアカウントを作成し、権限を割り当てる
  • Preemptible Killer アプリケーションを Kubernetes 上にデプロイする ( Helm を実行 )
  • デプロイ時に作成された Kubernetes サービスアカウントに対して Google サービスアカウントをマッピングする

以降で詳細を説明します。

1. GCP IAM関連設定

GCP の Webコンソールから同様の操作ができますが、手順の再利用のためにコマンドで作成します。

作成した内容は、Webコンソールメニュー「IAMと管理」内の「IAM」「サービスアカウント」「ロール」の画面でそれぞれ確認できるので、コマンド投入都度確認すると理解が深まります。

# Google サービスアカウントの作成
$ gcloud iam --project=$project_id service-accounts create preemptible-killer \
    --display-name preemptible-killer

# ロールの作成
$ gcloud iam --project=$project_id roles create preemptibleKillerRole \
    --project $project_id \
    --title "Preemptible VM Killer" \
    --description "Delete compute instances for GKE Preemptible VMs" \
    --permissions compute.instances.delete

# 次の手順で利用するサービスアカウントの識別子(メールアドレス形式)を取得
$ service_account_email=$(gcloud iam --project=$project_id service-accounts list --filter preemptible-killer --format 'value([email])'); echo $service_account_email

# ロールの識別子を取得
$ preemptible_role=$(gcloud iam --project=$project_id roles list --filter preemptibleKillerRole --format 'value([name])'); echo $preemptible_role

# Google サービスアカウントとロールを関連付ける
$ gcloud projects add-iam-policy-binding $project_id \
    --member=serviceAccount:${service_account_email} \
    --role=$preemptible_role

(参考)Workload Identity を使わずに、従来型のシークレットキーを用いたサービス関連系をしたい場合は下記のコマンドでシークレットキーを作成します。キーの扱いには注意しましょう。

# Workload Identity を使いたくない場合はシークレットキーを作成して進めることもできる
$ gcloud iam --project=$project_id service-accounts keys create \
    --iam-account $service_account_email \
    google_service_account.json

2. Preemptible Killer をデプロイする

ここでは Helmfile を利用します。事前に helmfile version で応答があることを確認してください。

コンフィグを2つ作成します。

helmfile.yaml

repositories:
 - name: estafette
   url: https://helm.estafette.io

releases:
  - name: gke-preemptible-killer
    namespace: gke-vm-killer
    chart: estafette/estafette-gke-preemptible-killer
    values:
      - ./values.yaml

values.yaml

serviceAccount:
  create: true
  name: gke-preemptible-killer

secret:
  # GCP SA を事前に作成しておく
  workloadIdentityServiceAccount: preemptible-killer@${PROJECT_ID}.iam.gserviceaccount.com

nodeSelector:
  # Workload Identity が有効な Node を明示
  iam.gke.io/gke-metadata-server-enabled: "true"

一例ですが、ディレクトリ構成は以下の通りです。

.
├── helmfile.yaml
└── values.yaml

そして最後、 helmfile apply コマンドでデプロイします。(実行結果の一例を記事の文末に記載しておきます)

# 適用されるマニフェストを確認する
helmfile diff

# 適用する
helmfile apply

3. Kubernetes Service Account への Google サービスアカウントマッピング

最後の手順です。Workload Identity の機能にて Google サービスアカウントと Kubernetes サービスアカウントを関連付けするための Role Binding を設定します。

# $service_account_email: preemptible-killer@PROJECT_ID.iam.gserviceaccount.com 形式

$ namespace_sa=gke-vm-killer/gke-preemptible-killer
gcloud iam service-accounts add-iam-policy-binding $service_account_email \
    --role roles/iam.workloadIdentityUser \
    --member "serviceAccount:${project_id}.svc.id.goog[${namespace_sa}]"

ここでやっていることは、 Google Service Account として作成した preemptible-killer@project_id.iam.gserviceaccount.com と、特定のネームスペース (ここでは gke-vm-killer ) に作成した Kubernetes Service Account ( gke-vm-killer/gke-preemptible-killer ) をマッピングし、結果として Google Service Account ( preemptible-killer ) が有する VM 削除権限を間接的に Kubernetes Service Account へ与えています。

補足説明

さて先に手順を説明していますので、以降ではいくつかの補足説明をしていきます。

自動削除の問題点

なぜ Preemptible Killer が必要なのか、なぜ GKE ワーカーノードの自動削除では問題なのかというと、

When creating a cluster, all the node are created at the same time and should be deleted after 24h of activity. To prevent large disruption, the estafette-gke-preemptible-killer can be used to kill instances during a random period of time between 12 and 24h. It makes use of the node annotation to store the time to kill value.

Preemptible VM は同時に作成され、(そして一般的には)24時間後に一律で削除されるため、一斉削除による混乱を回避することが第一の目的に挙がります。実際の挙動ではタイミングの問題により 24 時間でダウンせずにそれ以上の時間を稼働し続ける可能背もありますが(そして実際にそのケースに度々遭遇します)、それらは偶然の産物なので考慮しないこととします。

そして以下の通り、

When the time to kill time is passed, the Kubernetes node is marked as unschedulable, drained and the instance deleted on GCloud.

Kubernets の機能における UnschedulableDrained をマークしてくれるため、より安全にノードを削除できるようになります。ただし、いくら安全にノードが削除されるとはいっても Pod の Antiaffinity などの可用性設定を適切に実施していない場合は効果が得られないので注意してください。

関連情報: Safely Drain a Node | Kubernetes

シークレットキーの管理から開放されたい

gke-preemptible-killer に関する補足説明です。デプロイ方法を確認していたところ、VM削除権限の与え方に悩みましたが、大きくは「シークレットキーを与える方法」 と「Workload Identity にて権限を与える(シークレットキーの管理は不要とする)」の選択肢があります。

具体的には、 secret.workloadIdentityServiceAccount の設定値 (values) により挙動が変化します。根拠となるソースは以下の通り。

helm/estafette-gke-preemptible-killer/templates/serviceaccount.yaml

{{- if .Values.secret.workloadIdentityServiceAccount }}
annotations:
  iam.gke.io/gcp-service-account: {{ .Values.secret.workloadIdentityServiceAccount }}
{{- end }}

逆に workloadIdentityServiceAccount がない場合は別途配置するキーファイルを読み込む動作になるようですね。

{{- if not .Values.secret.workloadIdentityServiceAccount }}
- name: GOOGLE_APPLICATION_CREDENTIALS
  value: /gcp-service-account/service-account-key.json
{{- end }}

対象ノードの選択

Preemptible VM としての判定は、以下の通り cloud.google.com/gke-preemptible: true にて行われています。

GetPreemptibleNodes function

https://github.com/estafette/estafette-gke-preemptible-killer/blob/5b027e408d08e1c1ff036fac8b3503d8cf507bb6/kubernetes_client.go#L62-L64

ところが Spot VM では、 Issue#101 の通り "cloud.google.com/gke-spot": "true" としてラベルが付与されているようですので、結果としては version 1.3.1 時点の Preemptible Killer では動作しないと思われますが、ラベルを変えれば動く可能性も高いので、どうしてもという場合は自身で修正してビルドする手段もありますね。

まとめ

この記事では GKE のクラスタ運用コストを削減するひとつの手段として Preemptible VM を利用している場合の、計画的なノード削除方法 (gke-preemptible-killer アプリケーション) のデプロイ方法について記載しました。

Estafette-gke-preemptible-killer v1.3.1 時点では REDME に記載されている設定方法が部分的であったため、補足情報として参考してください。

(参考)実行結果のログ一例

helmfile apply 実行後の結果は以下の通り。参考まで。

Adding repo estafette https://helm.estafette.io
"estafette" has been added to your repositories

Comparing release=gke-preemptible-killer, chart=estafette/estafette-gke-preemptible-killer
********************

	Release was not present in Helm.  Diff will show entire contents as new.

********************
gke-vm-killer, gke-preemptible-killer, ServiceAccount (v1) has been added:
- 
+ # Source: estafette-gke-preemptible-killer/templates/serviceaccount.yaml
+ apiVersion: v1
+ kind: ServiceAccount
+ metadata:
+   name: gke-preemptible-killer
+   namespace: gke-vm-killer
+   labels:
+     app.kubernetes.io/name: estafette-gke-preemptible-killer
+     helm.sh/chart: estafette-gke-preemptible-killer-1.3.1
+     app.kubernetes.io/instance: gke-preemptible-killer
+     app.kubernetes.io/version: "1.3.1"
+     app.kubernetes.io/managed-by: Helm
+   annotations:
+     iam.gke.io/gcp-service-account: preemptible-killer@project_id.iam.gserviceaccount.com
gke-vm-killer, gke-preemptible-killer-estafette-gke-preemptible-killer, ClusterRole (rbac.authorization.k8s.io) has been added:
- 
+ # Source: estafette-gke-preemptible-killer/templates/clusterrole.yaml
+ apiVersion: rbac.authorization.k8s.io/v1
+ kind: ClusterRole
+ metadata:
+   name: gke-preemptible-killer-estafette-gke-preemptible-killer
+   labels:
+     app.kubernetes.io/name: estafette-gke-preemptible-killer
+     helm.sh/chart: estafette-gke-preemptible-killer-1.3.1
+     app.kubernetes.io/instance: gke-preemptible-killer
+     app.kubernetes.io/version: "1.3.1"
+     app.kubernetes.io/managed-by: Helm
+ rules:
+ - apiGroups: [""] # "" indicates the core API group
+   resources:
+   - nodes
+   verbs:
+   - get
+   - list
+   - patch
+   - update
+   - delete
+ - apiGroups: [""] # "" indicates the core API group
+   resources:
+   - pods
+   verbs:
+   - get
+   - list
+ - apiGroups: [""] # "" indicates the core API group
+   resources:
+   - pods/eviction
+   verbs:
+   - create
gke-vm-killer, gke-preemptible-killer-estafette-gke-preemptible-killer, ClusterRoleBinding (rbac.authorization.k8s.io) has been added:
- 
+ # Source: estafette-gke-preemptible-killer/templates/clusterrolebinding.yaml
+ apiVersion: rbac.authorization.k8s.io/v1
+ kind: ClusterRoleBinding
+ metadata:
+   name: gke-preemptible-killer-estafette-gke-preemptible-killer
+   labels:
+     app.kubernetes.io/name: estafette-gke-preemptible-killer
+     helm.sh/chart: estafette-gke-preemptible-killer-1.3.1
+     app.kubernetes.io/instance: gke-preemptible-killer
+     app.kubernetes.io/version: "1.3.1"
+     app.kubernetes.io/managed-by: Helm
+ roleRef:
+   apiGroup: rbac.authorization.k8s.io
+   kind: ClusterRole
+   name: gke-preemptible-killer-estafette-gke-preemptible-killer
+ subjects:
+ - kind: ServiceAccount
+   name: gke-preemptible-killer
+   namespace: gke-vm-killer
gke-vm-killer, gke-preemptible-killer-estafette-gke-preemptible-killer, Deployment (apps) has been added:
- 
+ # Source: estafette-gke-preemptible-killer/templates/deployment.yaml
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: gke-preemptible-killer-estafette-gke-preemptible-killer
+   namespace: gke-vm-killer
+   labels:
+     app.kubernetes.io/name: estafette-gke-preemptible-killer
+     helm.sh/chart: estafette-gke-preemptible-killer-1.3.1
+     app.kubernetes.io/instance: gke-preemptible-killer
+     app.kubernetes.io/version: "1.3.1"
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   replicas: 1
+   strategy:
+     type: Recreate
+   selector:
+     matchLabels:
+       app.kubernetes.io/name: estafette-gke-preemptible-killer
+       app.kubernetes.io/instance: gke-preemptible-killer
+   template:
+     metadata:
+       labels:
+         app.kubernetes.io/name: estafette-gke-preemptible-killer
+         app.kubernetes.io/instance: gke-preemptible-killer
+         app.kubernetes.io/version: "1.3.1"
+       annotations:
+         prometheus.io/scrape: "true"
+         prometheus.io/port: "9101"
+         checksum/secrets: 1234567890012345678901a7c05124b64eeece964e09c058ef8f9805daca546b
+     spec:
+       serviceAccountName: gke-preemptible-killer
+       securityContext:
+         {}
+       containers:
+         - name: estafette-gke-preemptible-killer
+           securityContext:
+             {}
+           image: "estafette/estafette-gke-preemptible-killer:1.3.1"
+           imagePullPolicy: IfNotPresent
+           env:
+             - name: "ESTAFETTE_LOG_FORMAT"
+               value: "plaintext"
+             - name: DRAIN_TIMEOUT
+               value: "300"
+             - name: INTERVAL
+               value: "300"
+           ports:
+             - name: metrics
+               containerPort: 9101
+               protocol: TCP
+           livenessProbe:
+             httpGet:
+               path: /liveness
+               port: 5000
+             initialDelaySeconds: 30
+             timeoutSeconds: 5
+           resources:
+             limits:
+               cpu: 25m
+               memory: 50Mi
+             requests:
+               cpu: 10m
+               memory: 15Mi
+       terminationGracePeriodSeconds: 300
+       nodeSelector:
+         iam.gke.io/gke-metadata-server-enabled: "true"
+       affinity:
+         nodeAffinity:
+           preferredDuringSchedulingIgnoredDuringExecution:
+           - preference:
+               matchExpressions:
+               - key: cloud.google.com/gke-preemptible
+                 operator: In
+                 values:
+                 - "true"
+             weight: 10

Upgrading release=gke-preemptible-killer, chart=estafette/estafette-gke-preemptible-killer
Release "gke-preemptible-killer" does not exist. Installing it now.
NAME: gke-preemptible-killer
LAST DEPLOYED: Fri Nov 18 00:18:53 2022
NAMESPACE: gke-vm-killer
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
1. Get the application logs by running this command:
kubectl logs -f -l app.kubernetes.io/name=estafette-gke-preemptible-killer,app.kubernetes.io/instance=gke-preemptible-killer -n gke-vm-killer

Listing releases matching ^gke-preemptible-killer$
gke-preemptible-killer	gke-vm-killer	1       	2022-11-18 00:18:53.959859 +0900 JST	deployed	estafette-gke-preemptible-killer-1.3.1	1.3.1      


UPDATED RELEASES:
NAME                     CHART                                        VERSION
gke-preemptible-killer   estafette/estafette-gke-preemptible-killer     1.3.1