Amazon EKS アップグレードにてこずった話

こんにちは。Enigmoインフラエンジニアの夏目です。

この記事は Enigmo Advent Calendar 2020 の13日目の記事です。

なんだか競馬関連のエントリがいっぱいですが、弊社の主要サービスは競馬予想サイトではありませんので誤解なきよう。僕は競馬のことはさっぱりわからないのですが、先月末のジャパンカップは大変熱いレース展開でしたね。着順自体はまったく面白みがなく収支マイナスになってしまいましたが。

さておき。1年前と同様、今年もKubernetesクラスタ運用に翻弄される日々を過ごしておりまして、今日の記事はそんなKubernetes...というかAmazon EKSクラスタに関するお話です。

Kubernetesのリリースサイクルに乗り遅れるな

皆さんご存知の通りKubernetesのマイナーバージョンはおよそ3ヶ月ごとにリリースされ、各マイナーバージョンは最新バージョンとの差異が3以上になった時点でコミュニティのサポート対象外となります。

https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#release-versioning

つまり、同一バージョンを1年以上利用することはほぼ不可能に近く、どうしても1年のうち最低でも1回はアップグレード作業が必要となります。これはKubernetesを本番環境で利用する上で避けることができない、インフラエンジニアの頭を悩ませる問題のひとつです。

(注:1.19以降は1年間のサポートがアナウンスされていますが、バージョン差異は4つも進んでしまうのでどのみちアップグレードしないわけにはいきません)

エニグモの一部サービスではAmazon EKSクラスタを利用しており、春先に1.15がリリースされたタイミングで1.13からアップグレードを実施しました。

EKSはKubernetes本家のリリースからおよそ半年遅れでリリースが行われ、本家よりも3ヶ月長い1年程度のサポートが保証されています。

そのため、1.15は2021年3月…よりも少し先の5月までがサポート予定となっており、年内はさらなるアップグレードは当面必要ないかな、と高を括っていました。

というのも、EKSは本家への追従に時間がかかりなかなかリリースされないこともあり、サポート期間はさておき年内に1.17や1.18がリリースされるかどうかも疑わしいものだ、と半ばAWS開発チームの対応スピードを侮っていました。

ところが、1.15のリリースから2ヶ月も経たないうちになんと1.16がリリースされました。3ヶ月ごとじゃないじゃん!話が違うよ!と憤慨しながらGithubのAWS Container Roadmapを眺めていると、こんな頼もしいコメントが寄せられているではありませんか。

https://github.com/aws/containers-roadmap/issues/487#issuecomment-597444626

but one of priorities for EKS this year is to reduce the gap between upstream releases and EKS support, which will require a temporary release schedule that is sooner than every 90 days.

ということで、本家リリースから10ヶ月も経ってからようやく公開されたEKS 1.15はたった1ヶ月半で旧バージョン扱いになってしまったのでした。ひどい。

1.16へアップグレードする前に

さて、本家で1.16がリリースされてからもう1年以上経過しているため、GKEなどで常に最新バージョンのKubernetesを利用されている方には遠い昔のことのように思われるかもしれませんが、1.16では一部のAPIが非推奨となりました。

https://github.com/kubernetes/kubernetes/blob/release-1.16/CHANGELOG/CHANGELOG-1.16.md#deprecations-and-removals

非推奨となったものの中でも最も広範かつ影響が大きいのは、Deployment , DaemonSet , ReplicaSet リソースが対象となる apiVersion:extensions/v1beta1 グループです。

これらのリソースを apps/v1 へ変更するために、現在クラスタで稼働しているアプリケーションを確認してmanifestを修正して……といった作業工数を考えると、1.15のサポート期間終了直前に慌てて1.16へアップグレードすることはあまり現実的ではないと判断し、早々に1.16へのアップグレード準備作業を始めることとなりました。

APIバージョン変更対応

対象リソースの洗い出し

1年前の記事でご紹介したように、アプリケーションのmanifestは基本的にGitで管理しているので、apiVersion:extensons/v1beta1 のリソースの有無はGitリポジトリを確認すればよいのですが、アプリケーション以外の一部のモジュールはGithubリポジトリのkustomizationファイルを直接参照してデプロイしているため、 kustomize build コマンドを実行しないとmanifestを確認することができません。

すべてのモジュールを確認して回るのも手間だしどうしたものか、と思っていたところkube-no-trouble:kubentという便利なスクリプトを見つけました。

https://github.com/doitintl/kube-no-trouble

$./kubent
6:25PM INF >>> Kube No Trouble `kubent` <<<
6:25PM INF Initializing collectors and retrieving data
6:25PM INF Retrieved 103 resources from collector name=Cluster
6:25PM INF Retrieved 132 resources from collector name="Helm v2"
6:25PM INF Retrieved 0 resources from collector name="Helm v3"
6:25PM INF Loaded ruleset name=deprecated-1-16.rego
6:25PM INF Loaded ruleset name=deprecated-1-20.rego
__________________________________________________________________________________________
>>> 1.16 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND         NAMESPACE     NAME                    API_VERSION
Deployment   default       nginx-deployment-old    apps/v1beta1
Deployment   kube-system   event-exporter-v0.2.5   apps/v1beta1
Deployment   kube-system   k8s-snapshots           extensions/v1beta1
Deployment   kube-system   kube-dns                extensions/v1beta1
__________________________________________________________________________________________
>>> 1.20 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND      NAMESPACE   NAME           API_VERSION
Ingress   default     test-ingress   extensions/v1beta1

このスクリプトapiVerison:extensions/v1beta1 のリソースを洗い出して順次APIバージョンの変更を実施しました。

安全にリソースAPIバージョンを変更するには

リソースのAPIバージョン変更と言っても、単純に apiVersion:apps/v1 へ変更するだけではありません。 spec.selector フィールドの追加も必要なのですが、このフィールドはimmutableとして定義されているため、既存のリソースに追加しようとしても以下のようなエラーが出力されてしまいます。

The Deployment "sample-application" is invalid: spec.selector: Invalid value: v1.LabelSelector{MatchLabels:map[string]string{"app":"sample-application", "app.kubernetes.io/name":"sample-application"}, MatchExpressions:[]v1.LabelSelectorRequirement(nil)}: field is immutable

このエラーを無視して kubectl apply --force コマンドで強制的にmanifestの変更を適用することはできるものの、整合性が取れないため既存のリソースは一度完全に削除されてから、 apps/v1 のリソースが新たに作成される形になります。つまり、稼働Pod数が一時的に完全に0になってしまうのです。

Pod数が0になってしまえば当然サービスに影響が生じてしまうため、以下のようなフローでAPIバージョンを変更しました。

  1. 既存のリソースと同一構成のmanifestを作成し、 metadata.name のみ変更してクラスタへデプロイ
    • 例) Deployment: sample-app を複製し、 Deployment: sample-app-temp を作成
  2. 複製したリソースが Service に紐づき、トラフィックが振り分けられていることを確認
    • kubectl get endpoints コマンドで、ServiceとDeploymentの紐付けを確認できます
  3. 既存のリソースに対してAPIバージョン変更及び spec.selector を追加するmanifestを強制的に適用
  4. 既存リソースのDeploymentが削除され、 apps/v1 で再作成されてアプリケーションが正常に動作していることを確認
  5. 一時的に作成したリソースを削除

少々泥臭いですが、ブルーグリーンデプロイメントの亜種のようなやりかたですね。サービスに影響なく安全にリリースできる安心感はありますが、同様の要件が発生した際に都度こういった対応をするのも手間なので、今後はArgo Rolloutなどのモジュールも試してみたいところです。

ノードグループのマネージド化

非推奨APIバージョンのリソースは一新できたため、あとはコントロールプレーンとノードグループをそれぞれ1.16へアップグレードするだけです。

ただ、これまで利用していたノードグループはEKS 1.13の頃にAWSドキュメントに従ってEC2 LaunchTemplate と AutoScalingGroupで作成していたため、実際にアップグレードしようとすると、以下のように複数フェーズで対応をする必要がありました。

  1. 新規バージョンのAMIを利用するLaunchTemplate, AutoScalingGroupリソースを作成し、コントロールプレーンへ紐付け
  2. 既存のノードグループにTaintを付与し、Drainを実行してPodを新規ノードグループへ退避させる
  3. 既存のノードグループを削除

さきほどのAPIバージョン変更時と同じような作業ですが、これをCloudFormationで対応しようとすると、3度もスタックの変更セットを適用するはめになり大変面倒です。

このため、EKS 1.15へアップグレードしたタイミングでマネージドノードグループへ移行しようとしたのですが、リリース当初のマネージドノードグループはSecurityGroupの割当てをすることができず、RDSやElastiCache, ALBなどのAWSリソースとPod間の疎通設定ができないため踏み切ることができませんでした。

ノード単位でSecurityGroupを割り当てなくてもSecurityGroup for Podを利用すれば良いでしょ?と思っても、対応クラスタバージョンはEKS 1.17以降のため使うこともできない、といった具合で数カ月間スタック状態でした。

それがこの夏ようやくLaunchTemplateに対応し、SecurityGroupを自由に割り当てられるようになったため、1.16へのアップグレード前にマネージドノードグループへ移行することにしました。

移行作業は前述のアップグレードフローとほぼ同様で、マネージドノードグループを新規作成して旧ノードグループからPodを退避させたうえで旧ノードグループを削除する、という流れですんなり終わりました。

これでアップグレード作業もスムーズに……と思いきや、マネージドノードグループに移行したタイミングでちょっとしたトラブルが発生しました。

Podが停止できない!

マネージドノードグループを作成し既存のノードグループからPodを移行してから2,3日経ったところで、唐突にPodが再起動を繰り返したり、Podが正常に停止できずにいつまでも残り続けたりと不安定な状態になり、ノードのCPUやメモリが高負荷となってクラスタ上からもノードが利用できなくなってしまいました。

厄介なことにPodの移行が済んだことで既存のノードグループは削除済み、という状況のためPodを切り戻すこともできず、騙し騙しPodを動かしていたところ以下のバグを踏んでいたことが判明しました。

Pods stuck in terminating state after AMI amazon-eks-node-1.16.15-20201112

つまるところバグが含まれるAMIでマネージドノードグループを作成したことが原因だったため、慌ててCloudFormationで旧バージョンのAMIに変更しようとしたところ、今度は以下のようなエラーが。

Requested Nodegroup release version 1.15.11-20201007 is invalid. Allowed release version is 1.15.12-20201112 (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: ----; Proxy: null)

なんとマネージドノードグループは最新バージョンのAMIしか利用できないという仕様のため、最新バージョンのAMIにバグがあるとどうすることもできないのです。詰んだ。

結局そうこうしているうちに修正バージョンのAMIがリリースされたため、マネージドノードグループのAMIバージョンを変更して一件落着……と思いきや、そうは問屋がおろしません。

前述したとおり、Podが正常に停止できない ということは、すなわちマネージドノードグループの更新処理における Tainの付与Drainの実行PodのEviction の流れで、最終的にPodのEvictionに失敗してしまい、ノードグループの更新処理も失敗してしまうのです。そんな。ひどい。ひどすぎる。

二重に詰んだ状況になってしまったため、最終的に取った手段は

  1. CloudFormationでマネージドノードグループの更新を実行
  2. kubectl get pods --all-namespaces でPodの稼働状況を注視
  3. Terminating のまま一定時間変化がないPodを見つけたら kubectl delete pods <pod name> --grace-period=0 --force コマンドで殺して回る

という、いったいこれのどこが マネージド なんですか?と聞きたくなるような対応をする羽目になりました。

いたずらに過去バージョンのAMIを使うことで不要なトラブルが発生することを防ぐ、という目的であれば最新バージョンのAMIしか利用できないという方針もわからないでもないですが、じゃあちゃんと動くものをリリースしてくれ……という気持ちでいっぱいです。

バージョンスキップができない!

前記した問題が解消し、ではKubernetesnのバージョンアップをしましょうということでコントロールプレーンをEKS 1.16へアップグレードしました。

ここでマネージドノードグループも1.16へアップグレードすると、最新バージョンであるEKS 1.18まで3回も更新処理が必要となり、都度PodのEvictionが発生します。サービスに直接影響が出ないようReplicaSetで冗長化はしているものの、そう何度も実行したいタイプの作業ではありません。

幸い、コントロールプレーンとノードグループ間のバージョン差異は2バージョンまで許容されるため、コントロールプレーンをEKS 1.17へアップグレードし、ノードグループは1.15から1.17へスキップさせようと考えました。

さきほど1.16へアップグレードしたばかりのコントロールプレーンにCloudFormationで再度アップグレード処理を実行しようとしたところ、以下のようなエラーが発生しました。

Update failed because of Nodegroups EKSNodeGroup-,EKSNodeGroup-,EKSNodeGroup- must be updated to match cluster version 1.16 before updating cluster version (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: -----; Proxy: null)

なるほどなるほど、コントロールプレーンとマネージドノードグループのバージョンがね、一致してないからクラスタのアップグレードはできませんと。なるほどなるほど。えっ、なんで?????非マネージドノードグループを使っているときは1.13から1.15へアップグレードできたのに?なんで????

といった具合で理屈はわかるものの、ドキュメントのどこにも書いていない制約にひっかかり、エラーの内容からするとマネージドノードを使っている以上は避けられないようなので渋々マネージドノードもアップグレードをすることになりました。仕方がないこととはいえ、やはり少々納得がいっていません。

アップグレードをイベント化しないために

こんな調子で、ほぼマネージドなはずのAmazon EKSのアップグレード作業にずいぶんと工数をかける形になってしまいました。

ただ、Amazon EKSないしKubernetesを利用している限り、クラスタ本体はもちろんモジュール類(Ingress Controllerなど)のアップグレードは常に意識し続けなければなりません。安定稼働しているからといってしばらく放置していると、唐突に破壊的変更を含むリリースがアナウンスされることも珍しくありません。

四半期ごとや月次のタイミングなどで各種モジュールの最新リリースをチェックし、定期的にアップグレード作業を実施することで、サービスへの影響を最小限にして運用作業を行う方法を模索していけたら良いなと考えています。


明日の記事の担当は ハイアマチュアトレイルランナー の 嘉松 さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co