🍗

チームに知見が残るEKSクラスタバージョンアップ運用

2023/02/13に公開

LAPRAS株式会社でSREをしております yktakaha4 と申します 🐧

https://lapras.com/

今回は、仕事のひとつとして1年くらい取り組んでいたEKSクラスタのバージョンアップの運用改善について一息つけたので、振り返りを兼ねて備忘録を遺したいと思います ✍

先にお断りしておくと、この記事で話すのは 運用ノウハウが{ほぼ無い,失われてしまった}EKSクラスタに対して、手順改善やリファクタリングを通じて継続的なバージョンアップ運用を再開する方法 というあまり胸を張れない内容です

ネットの記事やカンファレンスを見ていると、大規模環境や高トラフィック下における取り組みや、初期構築の段階で充分な運用設計を済ませている素晴らしい事例などが目に留まります

一方で、過去に選定したk8sを破棄して元の技術スタックに戻す意思決定をしたプロジェクトについて見かけることもあります

各社においても様々なコンテキストがあるものと思いますが、とあるプロダクトの一例として参考になる部分があれば幸いです☕

経緯

弊社は2016年に創業して以来、何回かのインフラ刷新を経て2019年からKubernetesを使い始めました
メインプロダクトであるLAPRASLAPRAS SCOUTをはじめ、多くのWebアプリケーションがEKS上で稼働しています

https://www.wantedly.com/companies/lapras/post_articles/191577

規模感としては、現時点で以下のようになっています

  • 4つのEKSクラスタ
    • 社内・社外向けリソースのステージング・本番環境
    • Terraformで管理されており、それぞれ独立したステートファイルを持つ
      • バージョンは tfenv にて管理
  • 約40のDeployment・StatefulSet
    • エンドユーザーに向け提供しているフルスクラッチのWebアプリケーション
    • BIツールやジョブスケジューラ、botなど、開発者や社員向けに提供しているWebアプリケーション
    • クローラーやgRPCサーバなど、インターネットアクセスの無いWebアプリケーション
  • 約400ファイル / 15,000行のマニフェスト
    • 定義の共通化はしておらず、クラスタ毎に別個のyamlファイルをディレクトリを分けて格納
    • 一部リソースの適用にはHelmfileを利用
  • Terraformとk8sマニフェストは別リポジトリで管理
  • 初期構築をした方は退職済

私はLAPRASにソフトウェアエンジニアとして入社して以来、半年ほどプロダクトのアプリケーションレイヤの改善をおこなっていましたが、前任であった創業エンジニア兼SREの退職を受けてロールチェンジをしました

そのタイミングで、プロダクト開発へリソースを投下したいという意思決定があり、私が専任でSREを担当し、2名のエンジニアに兼任としてサポートしてもらいながら進める…という体制になりました

Kubernetesのクラスタアップデートについても引き継いでおこなっていくこととなったのですが、Kubernetesについての実務経験が無い私にとってはかなり荷が重いものでした(はじめからわかっていたことではある⚰️)

スケジュールに追われるがままにバージョンアップを強行したとして、ひとたび本番障害を引き起こせば復旧も容易でなく、どのように不確実性を低減していくかが重要のように思われました

幸いにして、引き継ぎを受けた2021年9月の時点でサポート切れまで1年近く余裕のあるバージョン1.20のEKSアップデートを完了してもらっていたので、
闇雲に次のバージョンアップに取り組むのでなく、 現行の構成や技術スタックを極力変更せずクラスターバージョンアップを安定・安全化する ことをスコープとした改善プロジェクトを立ち上げ、進めていくこととしました…

課題の整理

当時引き継がれたクラスタアップデート手順は、インプレースでのアップグレードとクラスタマイグレーションをバージョンアップ毎に交互にやっていく…というものでした

それぞれの方式についての詳細とメリット・デメリットについては以下の記事が大変参考になるものと思います

https://logmi.jp/tech/articles/323033

また、クラスタマイグレーションについては、以下のような手順が書かれたesaを引き継いだ状態でした

  1. Terraformクラスタに関連するTerraform Resourceを同一のtfファイル内に手作業でコピーし、リソース名などや参照を手作業で修正する
  2. 新クラスタ定義をローカル環境から terraform apply して生成後、マニフェストファイルを kubectl apply -f xxxx.yamlhelmfile sync で適用
  3. kubectl get pod などを使ってリソースがRunningになっていることを確認後、Route53に紐付けるALBを terraform apply で切り替える

まず、上記の手順およびアップデート運用について再考し、以下の方針を取ることに決めました

手順をクラスタマイグレーションに一本化する

どちらのアップグレード方式を選択すべきかについて、前述の記事では プロダクトとしてダウンタイムを許容できるかどうか という判断軸を上げていましたが、対応者にとって未知の作業が多い場合、 作業の切り戻しが可能か新旧の環境を並行稼働して比較できるか は重要なファクターになるのでないかと思います

前任の方々は優秀かつ手も早く、突発的なトラブルが発生しても即時原因を突き止めて解決することができましたが、同じ対応品質を自らに課すことは無謀であったため、いつでも作業を中断して、起きていることに焦らず対処できるような手順をモットーにしたいと考えました

そこで、インプレースアップデートは当面おこなわないこととし、全てのバージョンアップをクラスタマイグレーション方式でおこなうこととしました

方法が2種類あれば、維持すべき手順書や覚えるべき事柄はふたつに増えます

現時点でクラスタマイグレーションに多くの時間がかかってしまうとしても、継続するうちに全体感が掴めれば改善のポイントを見つけることができるだろうと考えて、 より包含的な手順にリソースを集中し、効率化によりコストを下げていく というアプローチを取ることに決めました

当日の作業を最小化するための自動化とリファクタリングをおこなう

手法とクラスタマイグレーションを選択する場合、現実的にネックとなるのは作業時間の増加です

前述したように当時のSREは専任1名 + 兼任2名で、アップグレード作業日はダブルチェックに協力してもらっていたため、彼らがプロダクト開発に使えたはずの時間を無計画に拘束してしまうような状況は避けたい状況でした

そこで、 マイグレーション当日の作業を最小化するために、準備に多くの時間を使う というポリシーを定めました

SREのプラクティスとしてトイルを自動化するというものがありますが、クラスタアップグレードの価値は新機能の活用や安定性の向上にあり、クラスタにリソースを手動適用したり、Podの稼働状態を目視確認したりすることは労苦のため、最小化していきたいです

https://cloud.google.com/blog/ja/products/gcp/identifying-and-tracking-toil-using-sre-principles

リファクタリングやリアーキテクトの検討時にやってしまいがちなこととして、 {モダンでない,イケてない}から現行のものを捨てて新しくて良さげなものを建て直したい という誘惑が襲ってきますが、今回は 当日の作業時間の最小化に寄与するか という観点に絞って、やる・やらないをトリアージしていきました

課題への対処

課題に対して大まかな方針が立てられたので、個別撃破していきます

CIの導入・強化

TerraformとKubernetesでリポジトリが分かれているため、それぞれについてご紹介します

Terraform

まず、早い段階でやったこととして、TerraformおよびKubernetesを管理している各リポジトリに対してのCI導入と強化をおこないました

プロダクトのCI/CDには既にCircleCIGitHub Actionsを活用していましたが、インフラ関連のリポジトリについては引き継いだ段階ではそれらがほとんど無い状態だったため、今後新たに変更した箇所にこれ以上不具合を埋め込まないためにも対処したい部分です

Terraformについては、ベースブランチと比較して差分があったディレクトリについて terraform fmtterraform validate を実行するようにしました

すべてのtfファイルに対して検証をおこなわないようにした理由としては、急にCIを導入したことでKubernetesと関係の無い箇所で発生するエラーに逐一対処するのを後回しにしたかったからです(Terraformの状況についても課題が色々とあるため、こちらもいずれ記事にしたいと思います)

また、TerraformのCIツールとしてAtlantisが有名と思いますが、こちらを導入する場合はホスティングする環境を別途用意する必要がありますし、何よりも前述した 当日の作業を最小化する という視点で考えた時に、GitOpsやレビューのワークフローの改善に取り組むことは現時点では解決方法として大きすぎると思い、見送ることとしました

https://www.runatlantis.io/

スクリプトは以下のようなイメージのもので、GitHub Actions上で動作します

scripts/validate.bash
#!/usr/bin/bash

set -euo pipefail

target_branch_name="${1:-"master"}"
base_dir="$(cd "$(dirname "$0")/.." && pwd)"

echo "target_branch_name: $target_branch_name"

if [ $(git diff --diff-filter=d --name-only "$target_branch_name" | wc -l) -eq 0 ]
then
  echo "diff not found."
  exit 0
fi

git diff --diff-filter=d --name-only "$target_branch_name" |
xargs dirname |
sort -u |
while read -r d
do
  target_dir="$base_dir/$d"
  if grep -qrE '^terraform\s+\{' "$target_dir"
  then
    echo "target_dir: $target_dir"
    (
      cd "$target_dir"
      echo "- init -"
      tfenv install
      terraform init -no-color -reconfigure >/dev/null
      echo "- fmt -"
      terraform fmt -check -no-color -diff
      echo "- validate -"
      terraform validate -no-color
    )
  else
    echo "ignore: $target_dir"
  fi

done
[[ $? -eq 0 ]] || exit 1

echo "complete."

ワークフローは以下です

validate.yaml
on: pull_request

name: Validate

jobs:
  validate:
    runs-on: ubuntu-22.04
    permissions:
      id-token: write
      contents: read
    steps:
    - uses: actions/checkout@v2
      with:
        fetch-depth: 0

    - run: |
        git clone https://github.com/tfutils/tfenv.git ~/.tfenv
        echo ~/.tfenv/bin >> $GITHUB_PATH

    - uses: aws-actions/configure-aws-credentials@v1
      with:
        role-to-assume: arn:aws:iam::${{ secrets.ORG_AWS_ACCOUNT_ID }}:role/tfstate-readonly-role
        aws-region: ap-northeast-1

    - run: ./scripts/validate.bash "origin/${{ github.base_ref }}"

Kubernetes

k8s側のCIについては、Terraformと違ってアップデートのタイミングで全てのyamlファイルを適用する必要があったため、適用時にエラーになりうる箇所を網羅的にチェックしたいと考え、以下の検証をおこなうこととしました

  • Kubeconform によるマニフェストのバリデーション
  • Pluto による互換性チェック
  • KustomizeHelmfile にてテンプレートが出力可能かチェック

これらを、各クラスタ毎にmatrixの機能を使って並列実行します
加えて、KubeconformやPlutoなどk8sバージョンを指定できるものについては、現在と次バージョンでの実行をおこなうようにして、DeprecatedやRemovedになるものを早期に発見できるようにしました

https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs

ただ、こちらも今までCIが不十分だったところに追加したため、当初は多くがfailしている状態でした

これについては、passできたものから順次Requiredにしていくことで、半年くらいかけて段階的に改善をおこなっていきました
現在は全てのチェックをpassできており、定義の変更がしやすい状況に近づけたかと思います


現在のCI

yamlは以下です
最近バイナリのダウンロードなど非効率な部分が気になるようになったので、よきところで Docker container action へ作り変えたいと思います

https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action

validate.yaml
on: pull_request

name: Validate

env:
  KUBERNETES_VERSION_CURRENT: "1.20.0"
  KUBERNETES_VERSION_PLUS_1: "1.21.0"

jobs:
  kubeconform:
    runs-on: ubuntu-22.04
    timeout-minutes: 5
    strategy:
      fail-fast: false
      matrix:
        kubernetes_version_suffix:
          - "CURRENT"
          - "PLUS_1"
        target_directory:
          - "external-prd"
          - "external-stg"
          - "internal-prd"
          - "internal-stg"
    steps:
    - uses: actions/checkout@v2

    - run: |
        wget -q -O kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/v0.4.14/kubeconform-linux-amd64.tar.gz"
        tar xf kubeconform.tgz
        KUBERNETES_VERSION="$(printenv "KUBERNETES_VERSION_${KUBERNETES_VERSION_SUFFIX}")"
        echo "KUBERNETES_VERSION: $KUBERNETES_VERSION"
        ./kubeconform \
          -summary \
          -strict \
          -kubernetes-version "$KUBERNETES_VERSION" \
          -ignore-filename-pattern "/helmfile(-values)?\.ya?ml$" \
          -skip Kustomization,IstioOperator \
          "$TARGET_DIRECTORY"
      env:
        KUBERNETES_VERSION_SUFFIX: ${{ matrix.kubernetes_version_suffix }}
        TARGET_DIRECTORY: ${{ matrix.target_directory }}
  kustomize:
    runs-on: ubuntu-22.04
    timeout-minutes: 5
    strategy:
      fail-fast: false
      matrix:
        target_directory:
          - "external-prd"
          - "external-stg"
          - "internal-prd"
          - "internal-stg"
    steps:
    - uses: actions/checkout@v2

    - run: |
        wget -q -O kustomize.tgz "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv4.5.7/kustomize_v4.5.7_linux_amd64.tar.gz"
        tar xf kustomize.tgz
        ./kustomize build "$TARGET_DIRECTORY" > /dev/null
      env:
        TARGET_DIRECTORY: ${{ matrix.target_directory }}
  helmfile:
    runs-on: ubuntu-22.04
    timeout-minutes: 5
    strategy:
      fail-fast: false
      matrix:
        target_directory:
          - "external-prd"
          - "external-stg"
          - "internal-prd"
          - "internal-stg"
    steps:
    - uses: actions/checkout@v2

    - run: |
        cd "$TARGET_DIRECTORY"
        wget -q -O helmfile "https://github.com/helmfile/helmfile/releases/download/v0.144.0/helmfile_linux_amd64"
        chmod 0755 ./helmfile
        ./helmfile template > /dev/null
      env:
        TARGET_DIRECTORY: ${{ matrix.target_directory }}
  pluto:
    runs-on: ubuntu-22.04
    timeout-minutes: 5
    strategy:
      fail-fast: false
      matrix:
        kubernetes_version_suffix:
          - "CURRENT"
          - "PLUS_1"
        target_directory:
          - "external-prd"
          - "external-stg"
          - "internal-prd"
          - "internal-stg"
    steps:
    - uses: actions/checkout@v2

    - run: |
        wget -q -O pluto.tgz "https://github.com/FairwindsOps/pluto/releases/download/v5.10.3/pluto_5.10.3_linux_amd64.tar.gz"
        tar xf pluto.tgz
        KUBERNETES_VERSION="$(printenv "KUBERNETES_VERSION_${KUBERNETES_VERSION_SUFFIX}")"
        echo "KUBERNETES_VERSION: $KUBERNETES_VERSION"
        ./pluto detect-files \
          -t k8s="v$KUBERNETES_VERSION" \
          -o wide \
          --ignore-deprecations \
          -d "$TARGET_DIRECTORY"
      env:
        KUBERNETES_VERSION_SUFFIX: ${{ matrix.kubernetes_version_suffix }}
        TARGET_DIRECTORY: ${{ matrix.target_directory }}

ワンライナーによる定義の複製

CIによって今ある定義について一定の検証ができるようになったため、次はクラスタ移行の手順についても検討していきます

元々の手順では、Terraform定義は手でコピー & ペーストして参照を書き換える…と先程説明しましたが、作業者のスキルが不十分だと誤ったコピペをしてしまうなどのケアレスミスを誘発しやすく、何かしらの改善が必要と考えました


当時の手順書イメージ

こちらについては、クラスタ定義のリファクタリングをおこない、従来手動でおこなっていた作業をいくつかのワンライナーの実行で完了できるようにしました


現在の手順書イメージ

Terraform構成を複製するための有効な手法としてModulesの利用が考えられましたが、こちらは同一の構成を複数展開する場合には有効であるものの、クラスタアップデートにおいては、追加でEKSアドオンをインストールしたり、マイグレーションのタイミングで構成を変更したいケースがあったため、今回のユースケースには適切でないと判断しました

https://developer.hashicorp.com/terraform/language/modules

あるいは、より複製しやすい構成に定義全体をリファクタリングすることも検討しましたが、VPCやセキュリティグループなどの複製したくないリソースを別のステートに分離する必要があったりなど、段階を踏んだ修正が必要なことがわかったため、先程の 当日の作業を最小化する という観点には寄与しないと結論付け、今回は諦めることにしました

ある問題について考える時、ついつい抜本的な方法を適用しようとしてしまいますが、課題に対して最小の変更を蓄積することが長期的には解決への最短距離であるということを忘れないようにしたいです

https://atmarkit.itmedia.co.jp/ait/articles/1706/01/news166.html

マニフェストの一括適用

Kustomize

EKSクラスタの複製方法は改善できたので、次はマニフェストの適用方法について考えていきます

マニフェストの適用作業について従来課題だったこととして、 kubectl apply -f xxxx.yaml でひとつひとつファイルを当てていくと適用もれが生じやすく、また作業時間も長くなりがちでした

それでは kubectl apply -f ./xxxx のようにディレクトリ指定して適用できるかというと、Jobなどの移行時にapplyすべきでないyamlファイルが同一ディレクトリに格納されていて、かつそれらの既存ファイルの格納階層を変更するのが容易でない別の事情(今回は説明しません)があり、現状のディレクトリ構成を維持したままマニフェスト一式を適用する方法を必要としていました

これについては、今回新たにKustomizeを導入して対処することとしました
Kustomizeは、本来マニフェストの定義をテンプレート化した上で、環境毎の差分を適用して出力するためのツールですが、resources のみを指定することで適用対象のyamlファイルをプロダクト毎に明記することができます

https://kustomize.io/

ディレクトリ構成が以下のようなものだった場合、(説明のために実際のものから簡略化しています)

$ tree -L 4 *-*
external-prd
├── helmfile.yaml
├── kustomization.yaml
├── namespace.yaml
├── crawler
├── kube-system
│   ├── aws-load-balancer-controller
│   │   └── helmfile.yaml
│   ├── datadog
│   │   ├── helmfile.yaml
│   │   ├── kustomization.yaml
│   │   └── service.yaml
│   ├── helmfile.yaml
│   └── kustomization.yaml
├── lapras
│   ├── configmap.yaml
│   ├── deployment.yaml
│   ├── ingress.yaml
│   ├── job.yaml
│   ├── kustomization.yaml
│   └── service.yaml
└── scout
external-stg
├── helmfile.yaml
├── kustomization.yaml
├── crawler

以下の kustomization.yaml を用意した上で、 kustomize build . | kubectl apply -f - を実行すればリソースの一括適用や差分比較が実現できます

external-prd/lapras/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap.yaml
- deployment.yaml
- ingress.yaml
- service.yaml

更に resouces にはディレクトリを指定することもできるため、クラスタ全体に対して一括操作することも可能です

external-prd/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- kube-system/
- crawler/
- lapras/
- scout/

また、これは副次的な効果ですが、 kustomize build を実行するとその過程で読み取ったyamlのパースがおこなわれるため、先程のCIでも書式チェックとして実行しています

今までは適用作業中にエラーが起きて都度直す…という状況だったため、デフォルトブランチの内容が常にapply可能な状態を維持しているというのはだいぶ精神衛生によいです

Helmfile

いくつかのOSSについてはHelmfile を使って適用していることは既に書きましたが、こちらについてもKustomizeと同様に複数の定義を一括処理する方法があります

https://github.com/helmfile/helmfile

先程紹介したディレクトリ構成の場合であれば、以下のように helmfiles のみを定義したyamlを用意して helmfile sync すれば、1回のコマンド実行で一括適用が行えます
こちらもCIで helmfile template を実行するとyamlの記述ミスを発見できるのでオススメです

external-prd/kube-system/helmfile.yaml
helmfiles:
  - aws-load-balancer-controller/helmfile.yaml
  - datadog/helmfile.yaml

自動テストの導入

EKSクラスタ、マニフェスト共に適用方法の改善ができたので、最後は適用結果の検証です

テストについては、どの範囲をどの粒度で(加えて、どの技術で)検証するかが悩みどころでしたが、私が元々アプリケーションエンジニアであったことと、弊社ではDjangoを用いてWebアプリケーションを構築していたことから、Pythonの標準テストフレームワークであるunittestを用いて複数の観点のテスト群を用意し、作業の途中で実行していくことにしました

https://docs.python.org/ja/3/library/unittest.html

unittestおよびPythonを選定した理由としては、主要プロダクトの単体テストで利用されており社内の全エンジニアが一定の知見を持っていたことと、実際に運用をはじめないとどのような検証観点が必要となるかわからなかったため、どの方向に転んでも汎用的に対処できそうなフレームワークや言語を使いたいと考えたためでした

本音を言うと、テストをGoで書きたい気持ちもかなりあったのですが、それは 当日の作業を最小化する ことには寄与しないと考えやめました
もっともPython自体も柔軟で使いよく好きな言語だったので、今となってはやめて正解だったなと思います

具体的な検証観点としては例えば以下のようなものを実装しました

  • 旧クラスタの(一部)機能停止
    • ジョブスケジューラ等、新旧クラスタで平行稼働してはいけないリソースが停止されていること
  • 新クラスタの作成と機能検証
    • 新クラスタに必要なリソースが適用され、Runningであること
    • Deploymentをロールアウト中でもトラフィックがエラーとならないこと
    • 主要なWebアプリケーションにおいて、新旧クラスタの応答が同一であること
    • クローラーなどの一部の重要機能において、新クラスタで適切に稼働していること
  • 新クラスタへの稼働系切り替え
    • 全てのALBにおいてDNS(Route53)レコードの変更が実施済であること
    • 新クラスタ側でジョブスケジューラ等が稼働していること
  • 旧クラスタの削除
    • クラスタ削除前の必要手順を実施済であること

いくつかのテストについて見ていきます

例えば、事業上重要度の高いWebアプリケーションについては、新旧クラスタのエンドポイントをALB経由で叩いて結果を比べる…といったE2Eテストを用意しました

単純なものですが、このレベルの検証をするだけで プロダクトが実はまともに動いていないのに切り替えをしてしまった という最悪の自体を確実に抑止できるものと思います

実装時に意識したこととしては、多くの機能を検証しようとするとそれだけ維持が難しくなるため、例えばログインセッションについてはテスト前に稼働環境からCookieを取ってきてそれを利用する…といった割り切りをした上で、なるべく多くのコンポーネントが動作するエンドポイントを叩くようにしました

test_lapras.py
from unittest import TestCase

import requests

from tests import settings


class LaprasTest(TestCase):
    """
    新旧クラスタのLAPRASを比較した際に同一の挙動をしていること
    """

    def test_lp_content(self):
        """
        LPのコンテンツが新旧で一致する
        """
        assert_test = "唯一無二のポートフォリオ"

        resp = requests.get(
            f"https://{settings.lapras_domain}",
        )
        self.assertEqual(resp.status_code, 200)
        self.assertTrue(assert_test in resp.text)

        resp_standby = requests.get(
            f"https://{settings.lapras_domain_standby}",
            headers={"Host": settings.lapras_domain},
            verify=False,
        )
        self.assertEqual(resp_standby.status_code, 200)
        self.assertTrue(assert_test in resp_standby.text)

        self.assertEqual(resp.text, resp_standby.text)

    def test_api_content(self):
        """
        APIのコンテンツが新旧で一致する
        """
        target_path = f"api/user/experiences"

        resp = requests.get(
            f"https://{settings.lapras_domain}/{target_path}",
            headers={"Accept": "application/json"},
            cookies={"sessionid": settings.lapras_sessionid},
        )
        self.assertEqual(resp.status_code, 200)
        self.assertGreater(len(resp.json()["experience_list"]), 0)

        resp_standby = requests.get(
            f"https://{settings.lapras_domain_standby}/{target_path}",
            headers={"Host": settings.lapras_domain, "Accept": "application/json"},
            cookies={"sessionid": settings.lapras_sessionid},
            verify=False,
        )
        self.assertEqual(resp_standby.status_code, 200)
        self.assertGreater(len(resp_standby.json()["experience_list"]), 0)

        self.assertDictEqual(resp.json(), resp_standby.json())

上記はアプリケーションの稼働確認のケースでしたが、Kubernetesのリソースの稼働状態が適切であるかどうかもunittestで検証できます

以下のようなテストを実行すると、Deploymentの稼働状態を検証できます

test_external.py
from unittest import TestCase

from tests.util import create_api_client, get_kubernetes_apps_api


class ExternalTest(TestCase):
    """
    新クラスタ(external)が稼働状態であること
    """

    def test_deployment_ready(self):
        """
        新クラスタ側のDeploymentの稼働状態が適切である
        """
        api_client = create_api_client(cluster_category="external")
        api = get_kubernetes_apps_api(api_client=api_client)

        deployments = api.list_deployment_for_all_namespaces()
        for deployment in deployments.items:
            namespace = deployment.metadata.namespace
            name = deployment.metadata.name
            available_replicas = deployment.status.available_replicas
            replicas = deployment.status.replicas

            # Deploymentの availableReplicas と replicas の件数が違ったらエラー
            self.assertEqual(
                available_replicas,
                replicas,
                msg=f"not ready: {namespace=}, {name=}, {available_replicas=}, {replicas=}",
            )

PythonにはKubernetesの公式クライアントがあるため、こちらを使えば kubectl コマンドで目視確認していた様々なオペレーションを自動化できます

https://github.com/kubernetes-client/python

kubectl execkubectl rollout restart のような動作が複雑なコマンドはそれ単体では用意されていないのですが、公式でexamplesが豊富に用意されているため、悩まず実装を進められるものと思います

https://github.com/kubernetes-client/python/tree/master/examples

util.py
import warnings

from kubernetes.client import ApiClient, AppsV1Api


def get_kubernetes_apps_api(api_client: ApiClient):
    # バージョンアップ等で名前が変わるので生成関数を作っておくとのちのち楽
    api = AppsV1Api(api_client=api_client)

    return api


def create_api_client(cluster_category: str):
    # Kubernetesのコンフィグファイルへのパスを返却
    config_file_path = _get_config_file_path(cluster_category=cluster_category)

    api_client = new_client_from_config(config_file=config_file_path)

    # https://github.com/kubernetes-client/python/issues/309
    warnings.filterwarnings(
        "ignore", category=ResourceWarning, message="unclosed.*<ssl.SSLSocket.*>"
    )

    return api_client

クラスタ内のIngressを走査し、そこで設定されているホスト名に対して名前解決をおこなった際に、Ingressと同一のIPアドレスが返却される(≒Aレコードの向き先が変更されている)ことを検証するテストは以下のように書けます

test_route53.py
from logging import getLogger
from unittest import TestCase

from tests.util import (create_api_client, dig_ip_addresses,
                        get_kubernetes_networking_api)

logger = getLogger(__name__)


class Route53Test(TestCase):
    """
    Route53についてのテスト
    """

    def test_route53(self):
        """
        カスタムドメイン名とALBのドメイン名にアクセスした際に同じロードバランサのIPが返却されること
        """
        api_client = create_api_client(cluster_category="external")
        api = get_kubernetes_networking_api(api_client=api_client)

        ingresses = api.list_ingress_for_all_namespaces()
        self.assertGreater(len(ingresses.items), 0)

        ignore_domain_list = [
            "ignore-domain.lapras.com",
        ]

        for ingress in ingresses.items:
            with self.subTest(ingress.metadata.name):
                self.assertEqual(len(ingress.status.load_balancer.ingress), 1)

                lb_host = ingress.status.load_balancer.ingress[0].hostname
                lb_ip_list, lb_ttl = dig_ip_addresses(lb_host)

                for rule in ingress.spec.rules:
                    ingress_host = rule.host
                    if ingress_host in ignore_domain_list:
                        logger.info(f"skip: ingress_host={ingress_host}")
                        continue

                    logger.info(
                        f"check: ingress_host={ingress_host}, lb_host={lb_host}, lb_ip_list={lb_ip_list}"
                    )

                    ingress_ip_list, ingress_ttl = dig_ip_addresses(ingress_host)

                    self.assertListEqual(
                        lb_ip_list,
                        ingress_ip_list,
                        msg=f"DNS({ingress_host})とALB({lb_host})に登録されているIPが異なります。"
                        f"{max(lb_ttl, ingress_ttl)}秒後に再度テストしてください",
                    )

PythonにおいてDNSの名前解決をおこなうには、dnspythonが便利です

https://www.dnspython.org/

util.py
from dns.resolver import Answer, Resolver
from kubernetes.client import ApiClient, NetworkingV1Api


def get_kubernetes_networking_api(api_client: ApiClient):
    api = NetworkingV1Api(api_client=api_client)

    return api


def dig_ip_addresses(qname: str) -> Tuple[List[str], int]:
    resolver = Resolver()
    answer: Answer = resolver.resolve(qname=qname)

    return (
        list(sorted(list(set([r.to_text() for r in answer.rrset])))),
        answer.rrset.ttl,
    )

全てをunittestで定義するメリットとして、あるタイミングで一連の検証をおこなってほしい時に、作業手順書に poetry run python -m unittest discover tests.stop_old_cluster を全てpassするまで実行する…と書くだけでよくなり、作業もれの抑止が期待できます

また、アップグレード中に新たに検証が必要な観点が発見された場合も、その場でテストコードを増やしてコミットしておくだけで次のバージョンアップ対応時にも利用できるため、資産性が非常に高いです

今回はこれ以上紹介しませんが、もっと込み入った検証がしたくなったとしても、Pythonエコシステムには様々なライブラリが存在するため、実装に困ることはないでしょう

もしもの時のための予防策

EKSクラスタの事故時に効果がありそうな死活監視の強化やSorryページの作成といったいくつかの対応についても、並行して取り組んでいきました

これらはクラスタアップグレードとは直接的には関係ないですが、リスクの低減と言った観点ではいずれも重要なものであり、作業に対する心理的な負荷を下げてくれました

https://zenn.dev/yktakaha4/articles/synthetic_monitoring_with_ur_and_tf

https://zenn.dev/yktakaha4/articles/how_to_make_sorry_page

結果

改めて書いてみると些細な内容ばかりでしたが、当時は専任のSREは私しかおらず、新機能実装のためのインフラ構築の支援や、既存インフラの不具合対処、SLO運用やプロダクションフリーズ対応、オンコール対応等々を並行しておこないながらの改善活動だったため、実施には10か月ほどかかってしまいました

しかしながら、最終的には用意した手順でバージョン1.21のアップグレードをつつがなくおこなうことができ、当時すごくホッとしたのを覚えています


完了したIssue

また、2022年7月より2人目の専任SREとしてnappaさんが入社されたので、オンボーディング期間終了後の最初のタスクとして早速クラスタアップグレードに取り組んで頂くことにしました

入社して半年くらいでお願いするタスクとしてはかなり挑戦的なものをお渡ししてしまったかと思ったのですが、nappaさんの優秀さも相まって先日1.22へのアップグレードができました🍺


完了したIssueその2

作業実施にあたっては、私が色々と盛り込みすぎて長大になっていた作業手順書を分割し、各タイミングにチェックリストを設けるといった改善をしてもらい、大変ありがたかったです

特定の個人で運用を続けているとどうしても属人化する部分が出てきてしまうので、今後はSREで主担当者をローテーションしながら作業していきたいと思っています


すごいぞ

ということで、弊社の本日時点でのEKSバージョンは1.22で、AWSにおけるセキュリティサポート終了まで残り3か月を切っており、引き継いだ当時よりも期間としては切迫している状況にあります

ですが、この1年間で運用ノウハウを自動テストやスクリプト、作業手順書に蓄積できたおかげで、 まあなんとかなるだろう という温度感で対応を進められています

作業量の見積もりも格段にしやすくなったため、EKSへの対応にもう少し工数を割いてより新しいバージョンに進めるか、他にすべきことがあるので一旦アップグレードのペースを落とすか…といった意思決定もやりやすくなっていい感じです


https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/kubernetes-versions.html#kubernetes-release-calendar より引用

また、去年の時点ではアップグレードはどちらかというと憂鬱なタスクだったのですが、不確実性や心的不安を低減できたことで、バージョンアップでどのような新機能を利用できるかについても検討する余力ができたのも嬉しい変化でした

直近のアップグレードだと、弊社ではサービス間通信にgRPCを用いているため、gRPC container probes はぜひプロダクションコードで試してみたいです

https://kubernetes.io/blog/2022/05/13/grpc-probes-now-in-beta/

おわりに

アップグレード対応のベースラインができたことで、本記事でスコープから外していたTerraform定義の再構成やGitOpsの導入といった内容についても手が届くようになってきたので、地道にやっていきたいと思います🍷

弊社ではSREを募集中ですので、興味を持っていただけた方はぜひどうぞ

https://corp.lapras.com/recruit-engineer/

カジュアル面談をご希望の方はこちら

https://timerex.net/s/yuuki.takahashi/b1e44642

https://herp.careers/v1/laprasinc/640n2WllqS21

Discussion