チームに知見が残るEKSクラスタバージョンアップ運用
LAPRAS株式会社でSREをしております yktakaha4 と申します 🐧
今回は、仕事のひとつとして1年くらい取り組んでいたEKSクラスタのバージョンアップの運用改善について一息つけたので、振り返りを兼ねて備忘録を遺したいと思います ✍
先にお断りしておくと、この記事で話すのは 運用ノウハウが{ほぼ無い,失われてしまった}EKSクラスタに対して、手順改善やリファクタリングを通じて継続的なバージョンアップ運用を再開する方法 というあまり胸を張れない内容です
ネットの記事やカンファレンスを見ていると、大規模環境や高トラフィック下における取り組みや、初期構築の段階で充分な運用設計を済ませている素晴らしい事例などが目に留まります
一方で、過去に選定したk8sを破棄して元の技術スタックに戻す意思決定をしたプロジェクトについて見かけることもあります
各社においても様々なコンテキストがあるものと思いますが、とあるプロダクトの一例として参考になる部分があれば幸いです☕
経緯
弊社は2016年に創業して以来、何回かのインフラ刷新を経て2019年からKubernetesを使い始めました
メインプロダクトであるLAPRASやLAPRAS SCOUTをはじめ、多くのWebアプリケーションがEKS上で稼働しています
規模感としては、現時点で以下のようになっています
- 4つのEKSクラスタ
- 社内・社外向けリソースのステージング・本番環境
- Terraformで管理されており、それぞれ独立したステートファイルを持つ
- バージョンは tfenv にて管理
- 約40のDeployment・StatefulSet
- エンドユーザーに向け提供しているフルスクラッチのWebアプリケーション
- Ingressの構築にはAWS Load Balancer Controllerを利用
- BIツールやジョブスケジューラ、botなど、開発者や社員向けに提供しているWebアプリケーション
- クローラーやgRPCサーバなど、インターネットアクセスの無いWebアプリケーション
- エンドユーザーに向け提供しているフルスクラッチのWebアプリケーション
- 約400ファイル / 15,000行のマニフェスト
- 定義の共通化はしておらず、クラスタ毎に別個のyamlファイルをディレクトリを分けて格納
- 一部リソースの適用にはHelmfileを利用
- Terraformとk8sマニフェストは別リポジトリで管理
- 初期構築をした方は退職済
私はLAPRASにソフトウェアエンジニアとして入社して以来、半年ほどプロダクトのアプリケーションレイヤの改善をおこなっていましたが、前任であった創業エンジニア兼SREの退職を受けてロールチェンジをしました
そのタイミングで、プロダクト開発へリソースを投下したいという意思決定があり、私が専任でSREを担当し、2名のエンジニアに兼任としてサポートしてもらいながら進める…という体制になりました
Kubernetesのクラスタアップデートについても引き継いでおこなっていくこととなったのですが、Kubernetesについての実務経験が無い私にとってはかなり荷が重いものでした(はじめからわかっていたことではある⚰️)
スケジュールに追われるがままにバージョンアップを強行したとして、ひとたび本番障害を引き起こせば復旧も容易でなく、どのように不確実性を低減していくかが重要のように思われました
幸いにして、引き継ぎを受けた2021年9月の時点でサポート切れまで1年近く余裕のあるバージョン1.20のEKSアップデートを完了してもらっていたので、
闇雲に次のバージョンアップに取り組むのでなく、 現行の構成や技術スタックを極力変更せずクラスターバージョンアップを安定・安全化する ことをスコープとした改善プロジェクトを立ち上げ、進めていくこととしました…
課題の整理
当時引き継がれたクラスタアップデート手順は、インプレースでのアップグレードとクラスタマイグレーションをバージョンアップ毎に交互にやっていく…というものでした
それぞれの方式についての詳細とメリット・デメリットについては以下の記事が大変参考になるものと思います
また、クラスタマイグレーションについては、以下のような手順が書かれたesaを引き継いだ状態でした
- Terraformクラスタに関連するTerraform Resourceを同一のtfファイル内に手作業でコピーし、リソース名などや参照を手作業で修正する
- 新クラスタ定義をローカル環境から
terraform apply
して生成後、マニフェストファイルをkubectl apply -f xxxx.yaml
やhelmfile sync
で適用 -
kubectl get pod
などを使ってリソースがRunningになっていることを確認後、Route53に紐付けるALBをterraform apply
で切り替える
まず、上記の手順およびアップデート運用について再考し、以下の方針を取ることに決めました
手順をクラスタマイグレーションに一本化する
どちらのアップグレード方式を選択すべきかについて、前述の記事では プロダクトとしてダウンタイムを許容できるかどうか
という判断軸を上げていましたが、対応者にとって未知の作業が多い場合、 作業の切り戻しが可能か
や 新旧の環境を並行稼働して比較できるか
は重要なファクターになるのでないかと思います
前任の方々は優秀かつ手も早く、突発的なトラブルが発生しても即時原因を突き止めて解決することができましたが、同じ対応品質を自らに課すことは無謀であったため、いつでも作業を中断して、起きていることに焦らず対処できるような手順をモットーにしたいと考えました
そこで、インプレースアップデートは当面おこなわないこととし、全てのバージョンアップをクラスタマイグレーション方式でおこなうこととしました
方法が2種類あれば、維持すべき手順書や覚えるべき事柄はふたつに増えます
現時点でクラスタマイグレーションに多くの時間がかかってしまうとしても、継続するうちに全体感が掴めれば改善のポイントを見つけることができるだろうと考えて、 より包含的な手順にリソースを集中し、効率化によりコストを下げていく
というアプローチを取ることに決めました
当日の作業を最小化するための自動化とリファクタリングをおこなう
手法とクラスタマイグレーションを選択する場合、現実的にネックとなるのは作業時間の増加です
前述したように当時のSREは専任1名 + 兼任2名で、アップグレード作業日はダブルチェックに協力してもらっていたため、彼らがプロダクト開発に使えたはずの時間を無計画に拘束してしまうような状況は避けたい状況でした
そこで、 マイグレーション当日の作業を最小化するために、準備に多くの時間を使う
というポリシーを定めました
SREのプラクティスとしてトイルを自動化するというものがありますが、クラスタアップグレードの価値は新機能の活用や安定性の向上にあり、クラスタにリソースを手動適用したり、Podの稼働状態を目視確認したりすることは労苦のため、最小化していきたいです
リファクタリングやリアーキテクトの検討時にやってしまいがちなこととして、 {モダンでない,イケてない}から現行のものを捨てて新しくて良さげなものを建て直したい
という誘惑が襲ってきますが、今回は 当日の作業時間の最小化に寄与するか
という観点に絞って、やる・やらないをトリアージしていきました
課題への対処
課題に対して大まかな方針が立てられたので、個別撃破していきます
CIの導入・強化
TerraformとKubernetesでリポジトリが分かれているため、それぞれについてご紹介します
Terraform
まず、早い段階でやったこととして、TerraformおよびKubernetesを管理している各リポジトリに対してのCI導入と強化をおこないました
プロダクトのCI/CDには既にCircleCIやGitHub Actionsを活用していましたが、インフラ関連のリポジトリについては引き継いだ段階ではそれらがほとんど無い状態だったため、今後新たに変更した箇所にこれ以上不具合を埋め込まないためにも対処したい部分です
Terraformについては、ベースブランチと比較して差分があったディレクトリについて terraform fmt
や terraform validate
を実行するようにしました
すべてのtfファイルに対して検証をおこなわないようにした理由としては、急にCIを導入したことでKubernetesと関係の無い箇所で発生するエラーに逐一対処するのを後回しにしたかったからです(Terraformの状況についても課題が色々とあるため、こちらもいずれ記事にしたいと思います)
また、TerraformのCIツールとしてAtlantisが有名と思いますが、こちらを導入する場合はホスティングする環境を別途用意する必要がありますし、何よりも前述した 当日の作業を最小化する
という視点で考えた時に、GitOpsやレビューのワークフローの改善に取り組むことは現時点では解決方法として大きすぎると思い、見送ることとしました
スクリプトは以下のようなイメージのもので、GitHub Actions上で動作します
#!/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."
ワークフローは以下です
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 による互換性チェック
- Kustomize や Helmfile にてテンプレートが出力可能かチェック
これらを、各クラスタ毎にmatrixの機能を使って並列実行します
加えて、KubeconformやPlutoなどk8sバージョンを指定できるものについては、現在と次バージョンでの実行をおこなうようにして、DeprecatedやRemovedになるものを早期に発見できるようにしました
ただ、こちらも今までCIが不十分だったところに追加したため、当初は多くがfailしている状態でした
これについては、passできたものから順次Requiredにしていくことで、半年くらいかけて段階的に改善をおこなっていきました
現在は全てのチェックをpassできており、定義の変更がしやすい状況に近づけたかと思います
現在のCI
yamlは以下です
最近バイナリのダウンロードなど非効率な部分が気になるようになったので、よきところで Docker container action へ作り変えたいと思います
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アドオンをインストールしたり、マイグレーションのタイミングで構成を変更したいケースがあったため、今回のユースケースには適切でないと判断しました
あるいは、より複製しやすい構成に定義全体をリファクタリングすることも検討しましたが、VPCやセキュリティグループなどの複製したくないリソースを別のステートに分離する必要があったりなど、段階を踏んだ修正が必要なことがわかったため、先程の 当日の作業を最小化する
という観点には寄与しないと結論付け、今回は諦めることにしました
ある問題について考える時、ついつい抜本的な方法を適用しようとしてしまいますが、課題に対して最小の変更を蓄積することが長期的には解決への最短距離であるということを忘れないようにしたいです
マニフェストの一括適用
Kustomize
EKSクラスタの複製方法は改善できたので、次はマニフェストの適用方法について考えていきます
マニフェストの適用作業について従来課題だったこととして、 kubectl apply -f xxxx.yaml
でひとつひとつファイルを当てていくと適用もれが生じやすく、また作業時間も長くなりがちでした
それでは kubectl apply -f ./xxxx
のようにディレクトリ指定して適用できるかというと、Jobなどの移行時にapplyすべきでないyamlファイルが同一ディレクトリに格納されていて、かつそれらの既存ファイルの格納階層を変更するのが容易でない別の事情(今回は説明しません)があり、現状のディレクトリ構成を維持したままマニフェスト一式を適用する方法を必要としていました
これについては、今回新たにKustomizeを導入して対処することとしました
Kustomizeは、本来マニフェストの定義をテンプレート化した上で、環境毎の差分を適用して出力するためのツールですが、resources
のみを指定することで適用対象のyamlファイルをプロダクト毎に明記することができます
ディレクトリ構成が以下のようなものだった場合、(説明のために実際のものから簡略化しています)
$ 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 -
を実行すればリソースの一括適用や差分比較が実現できます
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap.yaml
- deployment.yaml
- ingress.yaml
- service.yaml
更に resouces
にはディレクトリを指定することもできるため、クラスタ全体に対して一括操作することも可能です
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と同様に複数の定義を一括処理する方法があります
先程紹介したディレクトリ構成の場合であれば、以下のように helmfiles
のみを定義したyamlを用意して helmfile sync
すれば、1回のコマンド実行で一括適用が行えます
こちらもCIで helmfile template
を実行するとyamlの記述ミスを発見できるのでオススメです
helmfiles:
- aws-load-balancer-controller/helmfile.yaml
- datadog/helmfile.yaml
自動テストの導入
EKSクラスタ、マニフェスト共に適用方法の改善ができたので、最後は適用結果の検証です
テストについては、どの範囲をどの粒度で(加えて、どの技術で)検証するかが悩みどころでしたが、私が元々アプリケーションエンジニアであったことと、弊社ではDjangoを用いてWebアプリケーションを構築していたことから、Pythonの標準テストフレームワークであるunittestを用いて複数の観点のテスト群を用意し、作業の途中で実行していくことにしました
unittestおよびPythonを選定した理由としては、主要プロダクトの単体テストで利用されており社内の全エンジニアが一定の知見を持っていたことと、実際に運用をはじめないとどのような検証観点が必要となるかわからなかったため、どの方向に転んでも汎用的に対処できそうなフレームワークや言語を使いたいと考えたためでした
本音を言うと、テストをGoで書きたい気持ちもかなりあったのですが、それは 当日の作業を最小化する
ことには寄与しないと考えやめました
もっともPython自体も柔軟で使いよく好きな言語だったので、今となってはやめて正解だったなと思います
具体的な検証観点としては例えば以下のようなものを実装しました
- 旧クラスタの(一部)機能停止
- ジョブスケジューラ等、新旧クラスタで平行稼働してはいけないリソースが停止されていること
- 新クラスタの作成と機能検証
- 新クラスタに必要なリソースが適用され、Runningであること
- Deploymentをロールアウト中でもトラフィックがエラーとならないこと
- 主要なWebアプリケーションにおいて、新旧クラスタの応答が同一であること
- クローラーなどの一部の重要機能において、新クラスタで適切に稼働していること
- 新クラスタへの稼働系切り替え
- 全てのALBにおいてDNS(Route53)レコードの変更が実施済であること
- 新クラスタ側でジョブスケジューラ等が稼働していること
- 旧クラスタの削除
- クラスタ削除前の必要手順を実施済であること
いくつかのテストについて見ていきます
例えば、事業上重要度の高いWebアプリケーションについては、新旧クラスタのエンドポイントをALB経由で叩いて結果を比べる…といったE2Eテストを用意しました
単純なものですが、このレベルの検証をするだけで プロダクトが実はまともに動いていないのに切り替えをしてしまった
という最悪の自体を確実に抑止できるものと思います
実装時に意識したこととしては、多くの機能を検証しようとするとそれだけ維持が難しくなるため、例えばログインセッションについてはテスト前に稼働環境からCookieを取ってきてそれを利用する…といった割り切りをした上で、なるべく多くのコンポーネントが動作するエンドポイントを叩くようにしました
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の稼働状態を検証できます
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
コマンドで目視確認していた様々なオペレーションを自動化できます
kubectl exec
や kubectl rollout restart
のような動作が複雑なコマンドはそれ単体では用意されていないのですが、公式でexamplesが豊富に用意されているため、悩まず実装を進められるものと思います
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レコードの向き先が変更されている)ことを検証するテストは以下のように書けます
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が便利です
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ページの作成といったいくつかの対応についても、並行して取り組んでいきました
これらはクラスタアップグレードとは直接的には関係ないですが、リスクの低減と言った観点ではいずれも重要なものであり、作業に対する心理的な負荷を下げてくれました
結果
改めて書いてみると些細な内容ばかりでしたが、当時は専任のSREは私しかおらず、新機能実装のためのインフラ構築の支援や、既存インフラの不具合対処、SLO運用やプロダクションフリーズ対応、オンコール対応等々を並行しておこないながらの改善活動だったため、実施には10か月ほどかかってしまいました
しかしながら、最終的には用意した手順でバージョン1.21のアップグレードをつつがなくおこなうことができ、当時すごくホッとしたのを覚えています
完了したIssue
また、2022年7月より2人目の専任SREとしてnappaさんが入社されたので、オンボーディング期間終了後の最初のタスクとして早速クラスタアップグレードに取り組んで頂くことにしました
入社して半年くらいでお願いするタスクとしてはかなり挑戦的なものをお渡ししてしまったかと思ったのですが、nappaさんの優秀さも相まって先日1.22へのアップグレードができました🍺
完了したIssueその2
作業実施にあたっては、私が色々と盛り込みすぎて長大になっていた作業手順書を分割し、各タイミングにチェックリストを設けるといった改善をしてもらい、大変ありがたかったです
特定の個人で運用を続けているとどうしても属人化する部分が出てきてしまうので、今後はSREで主担当者をローテーションしながら作業していきたいと思っています
すごいぞ
ということで、弊社の本日時点でのEKSバージョンは1.22で、AWSにおけるセキュリティサポート終了まで残り3か月を切っており、引き継いだ当時よりも期間としては切迫している状況にあります
ですが、この1年間で運用ノウハウを自動テストやスクリプト、作業手順書に蓄積できたおかげで、 まあなんとかなるだろう
という温度感で対応を進められています
作業量の見積もりも格段にしやすくなったため、EKSへの対応にもう少し工数を割いてより新しいバージョンに進めるか、他にすべきことがあるので一旦アップグレードのペースを落とすか…といった意思決定もやりやすくなっていい感じです
また、去年の時点ではアップグレードはどちらかというと憂鬱なタスクだったのですが、不確実性や心的不安を低減できたことで、バージョンアップでどのような新機能を利用できるかについても検討する余力ができたのも嬉しい変化でした
直近のアップグレードだと、弊社ではサービス間通信にgRPCを用いているため、gRPC container probes はぜひプロダクションコードで試してみたいです
おわりに
アップグレード対応のベースラインができたことで、本記事でスコープから外していたTerraform定義の再構成やGitOpsの導入といった内容についても手が届くようになってきたので、地道にやっていきたいと思います🍷
弊社ではSREを募集中ですので、興味を持っていただけた方はぜひどうぞ
カジュアル面談をご希望の方はこちら
Discussion