EKSからECSに移行して開発運用コストの削減を図る

SREチームの長田です。

今回はカヤックで運用している「まちのコイン」というプロダクトのアプリケーション基盤を Amazon EKS(以下EKS)からAmazon ECS(以下ECS)に移行したはなしをします。

まちのコインとは

coin.machino.co www.kayac.com

まちのコインはカヤックが運営している、デジタル地域通貨を使ってその地域のコミュニティを活性化させるサービスです。 2019年11月から実証実験を開始し、翌年2月から正式リリースされました。 2022年9月現在、20の地域に導入されています。

一般ユーザーが使用するクライアントアプリと、導入地域の運営団体が使用するブラウザ用の管理画面、 それらにAPIを提供するRailsサーバーアプリがあります。 データベースはAmazon Aurora PostgreSQL、 その他AWSのマネージドサービスを組み合わせたオーソドックスな構成です。 他にも運用監視用途の小さなアプリケーションがECSやAWS Lambdaなどで稼働していますが、 今回の趣旨とは外れますので説明は割愛させていただきます。

アプリケーション周りの構成

まちのコインには開発当初から昨年5月頃から筆者が担当するようになるまで、SREチームの手が入っていませんでした。

なぜEKSからECSに移行したのか

EKSで運用し続けるメリットよりも、デメリットのほうが大きいと判断したためです。

EKSを使い続ける理由がなくなった

EKSを選択した理由として、当初はカヤックではシステムの開発のみを行い運用までは行わない予定だった、というものがあったようです。 システムを運用するプラットフォームに汎用性を持たせるために、AWSに依存しないKubernetes(以下k8s)を採用したとのことでした。

しかし、結局はカヤックで運用することになり、開発時に使用していたAWSを継続利用することになったため、 「EKSを使い続ける理由」がなくなってしまいました。

社内ではECSの利用が一般的

カヤックで運用・開発している自社プロダクトは、ほとんどがECSを採用しています。 運用中の自社プロダクトではECSとAWSのマネージドサービスを組み合わせた構成で十分であったこと、 また社内に先行事例が豊富にあるため新規プロダクトでも採用しやすいという理由があります。 そのため、ECS関連のツールやノウハウは豊富にある一方、EKSに関するものは皆無でした。

EKS関連でなにか問題が発生した場合も社内の事例に当たることができませんし、 ツールを開発してもまちのコインの開発運用チームにしか恩恵がありません。

また、エンジニアが異動してきた際にもEKSの使い方をイチから学習し直す必要がありました。 ECSとの対応づけで個々の用語の意味は推測できるものの、微妙に勝手や使い方が異なる部分を一つ一つ確認していく必要がありました。

なんとなく想像はつくけども・・・

定期的なアップグレードの存在

EKSはマネージドなk8sということで、そのサポートバージョンはk8sのリリースサイクルに依存します。 このため、EKSを継続的に利用していくためには毎年アップグレード作業が発生します。

EKSの自動更新に任せてしまうという手もなくはないですが、非互換な変更や挙動の変化を踏み抜くリスクを考えると 結局は事前の移行テストと、新バージョンでの新クラスタ作成の後に切り替え・問題発生時のロールバックを計画することになるのではないでしょうか。 このコストはなかなか無視できるものではありません。

ECSにもプラットフォームバージョンのアップグレードは存在しますが、 少なくともAWS Fargateを使用している限りはクラスタの作り直しは必要なく、 ECS Serviceごとに対応することができます。

このタイミングでEKSからECSに移行し始めたのも、 EKSにおけるk8s 1.19のEoLが2022年6月末に迫っていたという理由からでした。

参考:

ECSで十分

前述の通りアプリケーションはRailsで作られたAPIサーバーのみです。 これを動かすためだけにEKSを使用するのはオーバースペックであると判断しました。

前準備

アプリケーション実行環境の移行ということでそれなりに大きな変更を行うわけですが、その前にやっておきたいことがありました。 いままでSREチームが関わっていなかったということで、運用に関する作り込みが甘い部分を補強していきました。

監視

CloudWatchによる簡素なアラート設定のみだったので、Mackerelを導入しました。 CloudWatchでの監視を作り込むという手もありましたが、どうせ作り込むなら社内で一般的に使用しているツールを使おうということでMackerelの導入を選択しました。

Infrstructure as Code(IaC)

開発当初にCloudFormationを使用していたということでしたが、最近はメンテナンスされていないということだったので、 Terraformを導入しました。 こちらも社内で一般的に使用されているツールという理由から選択しました。

k8sが管理するリソースと、そうでないものを切り分けつつ、ECS移行に必要なリソースを定義していきました。

ステージング環境

アプリケーションのQAに使っている環境はあったのですが、インフラリソースも含めた動作確認を行う環境はありませんでした。 ECS移行にあたり、関連リソースの動作確認が必須になったため、移行に先んじて環境を用意しました。

新しい環境を立ち上げるための、アプリケーションから参照する初期データ類も合わせて用意しました *1

EKSからECSに移行した際のポイント

アプリケーション本体はすでにコンテナ化されていたため、これをECS上で動かすための調整と、関連するマネージドサービスの整備が主となりました。

定期実行処理

定期実行処理にはk8sのcronjobを使用していたため、これを代替する仕組みとして 以下のマネージドサービスを組み合わせた定期実行処理の仕組みを構築しました。

  • Amazon EventBridge: ルールによるjobのスケジューリング。 実行するべきrake taskを指定してStep Functionsのstate machineを起動
  • AWS Step Functions: ECS run Taskの実行。 失敗時のリトライと、リトライ上限を超えた場合のSNS通知
  • ECS run Task: 定期実行処理の本体

ECS run Taskの部分についてはより実行コストの低いAWS Lambdaを採用する案もありましたが、 実行時間がLambda functionの実行上限である15分を超えるjobが存在したため断念しました *2

Step Functionsのstate machine管理にはカヤックのSREチームメンバーが開発したstefunnyを採用しました。 https://github.com/mashiike/stefunny

秘密情報の受け渡し

秘密情報のたぐいはAWS Secrets Managerで管理しています。

Railsアプリへの秘密情報の受け渡しにはKubernetes External Secretsを 使用していましたが、ECSに移行するにあたりECS Taskのsecrets参照に置き換えました。

また、環境変数経由で秘密情報を渡す場合とRailsアプリから直接Secrets Managerを読む場合が混在していたため、 環境変数経由の受け渡しに統一しました。

デプロイ

デプロイ用のECS Task上で所定のコマンドを実行する方式にしました。

まずEKSへのデプロイ方法についてインターフェイスの統一と簡略化を行い、その上でデプロイ処理をECS用のものに置き換えました。 予めインターフェイスの統一を行うことで、ECS移行後のデプロイ操作に混乱が生じにくいようにしました。

ECSへのデプロイには社内で利用実績があるecspressoを採用しました。 https://github.com/kayac/ecspresso

ダウンタイムを発生させない接続先切り替え

単純にダウンタイムを発生させたくないというのもありましたが、それ以上に一度にすべてをECS環境に切り替えることに不安があったため、 EKSとECSそれぞれで並行してアプリケーションを稼働させ、それらに配分するクライアントからのリクエスト比率を操作して様子を見るという方法を取りました。

リクエスト比率の操作にはAmazon Route53weighted recordを使用しました。

まず移行先であるECS環境を用意しました。 この時点ではクライアントからECS環境へのリクエストはありません。

ECS環境にはまだリクエストを回さない

次にECS環境の動作確認のために、一部のリクエストをECS環境に回しました。 Route53 weighted recordの、ECS用ALBの比率を徐々に上げ、ECS Taskの負荷の変化を観察しました。

一部のリクエストをECS環境に振り分ける

ECS環境の動作に問題ないことが確認できた後、Route53 weighted recordの比率をすべてCloudFrontに解決されるよう戻した後、 CloudFrontのOriginをECS用ALBに変更しました。

すべてのリクエストをECS環境に回す

結果、誰にも気づかれることなくダウンタイムなくECS環境への切り替えが成功しました。

より慎重に動作確認を行うのであれば、ECS用ALBの前段にもCloudFrontを置いて動作を確認するべきでしょう。 しかし、CloudFrontにはdistribution間で重複した代替ドメインを設定できないという制約があります。

代替ドメインにワイルドカードを設定することでこの制約を回避する方法もありますが、 以下の理由から採用には至りませんでした。

  • まちのコインには複数のCloudFront distributionが存在しているため、複数同時に動作確認を行おうとするとワイルドカードドメインが重複してしまう
  • CloudFront distributionをひとつずつ動作確認していく時間的余裕がなかった

今回は動作確認として必要十分な結果が得られるであろうと判断し、 CloudFront distributionとALBのそれぞれリクエストを振り分けるという方法を取りました。

失敗談

一見すんなり完了したように見えますが、もちろん失敗もありました。

移行開発期間中の複数環境対応

移行のための開発期間中も、アプリケーションの開発は続いています。 移行開発ブランチとメインブランチとの差分が大きくなると、後のマージコストが大きくなります。 そのため、移行開発ブランチを要素ごとに細分化し、こまめにメインブランチに取り込むようにしていました。

本番環境との互換性は特に注意していたのですが、それとは別のQA環境での対応が疎かになることがありました。 アプリケーションのデプロイに関する設定がうまく切り分けられていなかったり、 アプリケーションの起動に必要な環境変数の設定漏れがあったり、という問題です。

QA用環境ということで開発チーム内にしか影響がない問題ではあったのですが、 それにより開発を妨げてしまったのは事実です。

「EKSからECSに移行した際のポイント」の「デプロイ」で述べたような、 インターフェイスの共通化 → 実装の変更 という手順を徹底できれば問題の発生をより少なくできたように思います。

時期尚早なドッグフーディング

移行開発期間の終盤で、動作確認として開発・運用メンバーからのリクエストのみECS環境に切り替えました *3。 クライアントアプリ用のAPIについては問題がなかったのですが、 管理画面用のAPIについては動作テストが不十分な部分があり、実際に管理画面を使用する運用メンバーの業務に支障をきたす場面がありました。

内部メンバーからのリクエストのみ切り替えた意図は、 「実際にクライアントアプリ・管理画面を使用した場合に問題が発生しないかどうかを確認する」ことでした。 そのため運用メンバーの操作による不具合発生は狙い通りではあったのですが、 「リリース前の不具合の発見」と「円滑な運用」のバランスが悪かった、あるいは運用メンバーとの合意形成が不十分であったと反省しています。

本番環境とステージング環境の差異

作成したステージング環境はECS移行後の状態となっていました。 本来であれば現行の本番環境に合わせてEKSで環境を構築し、ECSへの移行作業をそのまま再現できるようようにするべきでしょう。

そのため、全体を通しての移行操作リハーサルは行なえませんでした。 もちろん個々の操作については検証を行ってはいましたが、それでも実際に本番環境で移行を行った際にはいくつかの問題が発生しました *4

プロダクトの価値を落とすリスクを減らすためにも、ぶっつけ本番のストレスを無くすためにも、 開発の初期段階からステージング環境を用意することを強くおすすめします。

ECSへの移行を終えて

EKSからECSへの移行に要した期間は、間に他の案件もはさみつつではありましたが、およそ1年でした。 大部分の期間は筆者一人で進めましたが、終盤の詰めの部分ではアプリケーションとのつなぎこみ担当としてサーバーサイドのエンジニアに、 他のチームとの調整役としてプロジェクトマネージャーにも参加してもらいました。 また、移行に関する変更はすべてGitHubのPull Requestとしてサーバーサイドメンバーにレビューしてもらいました。

使い慣れた構成ということで、移行後も目立った問題は発生していません。 なにか問題が発生したとしても、見るべき場所がわかっているので安心感があります。

EKS(k8s)でシステムを運用することに関しては個人的には興味があったのですが、総合的なコストを鑑みてECSに移行するという判断をしました。 移行に際してプロダクション環境として使用しているEKS(k8s)に触れる機会が得られ、 その上で「汎用性は高いが作り込みと覚悟が必要」「今のまちのコインにはオーバースペック」という考えを持てたことは、自分としては収穫だったように思います。

まちのコインの今後

まちのコインはまだまだ成長の余地があるプロダクトです。 その成長を妨げないための課題として、安全なオペレーションのための改善・システムの自動化・不要なコストの削減など、SREとしてできることが多くあります。 「適切なアプリケーション実行環境への移行」によって、それらの課題に取り組む地盤ができたと言えます。

カヤックのSREチームの目標である「SREチームの仕事をなくすこと」 に向けて日々前進している最中なのです。

まとめ

今回の移行プロジェクトを経て、要件・規模・環境にあった技術選定が重要であることを再認識しました。 ビジネスを鈍化させないために、状況に合わせて適切な選択をしていきたいですね (あと、長期運用するプロダクトには開発初期からSREを関わらせてくださいお願いします 🙏)。

カヤックでは一緒に働く仲間を募集しています!

hubspot.kayac.com

*1: 受け継がれた秘伝のDBダンプファイルを取り込んで・・・というのはままある話なのではないでしょうか。 直後に開発環境のmirage化も控えていたため、 外部依存のないまっさらな初期データが必要だったという背景もあります

*2:15分以内に終了するjobについては部分的にLambdaで実行することも検討しています。

*3:今回はリクエスト元のIPアドレスを見て、開発・運用拠点のものであればECS環境に振り分けました。 恒常的に使用する場合は心許ない条件ですが、完全移行までの期間限定ということで実現コストがかからない方法を選択しました

*4:発生した問題の詳細については設定ミス等の小さな原因がほとんどでしたので割愛させていただきます

ecrm - Amazon ECRから不要イメージを安全に削除するOSSを作った

SREチームの藤原です。今回は、AWSのコンテナレジストリであるAmazon ECRから、不要になったコンテナイメージを安全に削除するツールをOSSとして作った話です。

  • Amazon ECRのライフサイクルポリシーでは、設定によっては実際に利用中のイメージを削除してしまうことがあります
  • 現在利用中のイメージを避けて、それ以外の不要なイメージを安全に削除できるCLIツールをOSSとして作成しました

Amazon ECSとECRでのイメージ運用

カヤックでは、コンテナのオーケストレーションにAmazon ECSを主に使用しています。ECSにタスクをデプロイする場合は、イメージのタグにアプリケーションのGitリポジトリのコミットハッシュ(git log -1 --format=%Hで計算した値)を付与してAmazon ECRにpushし、タスク定義ではそのタグを含めたURLを指定しています。

例: タスク定義に指定するイメージURL 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:5ff700463f8852c8556fbf9d11dd041eb7234016

このようにデプロイごとに一意のタグを付与することで、どの時点のアプリケーションがイメージに含まれているかが明確になります。デプロイした結果に問題があってECSサービスをロールバックした場合にも、以前動作していたイメージに確実に戻ることができる、というメリットがあります。

しかしこのように運用すると、デプロイを重ねるごとにECRにイメージが溜まっていくことになります。ECRの容量が増加するとコストも増加するため、適宜不要になったイメージを削除する運用が必要になってきます。

ECRに保存されたイメージでは1GBあたり0.10USDの課金が発生します。例えば1GBのイメージを毎日10回(月に20回)保存した場合、1ヶ月で200GB(20USD)のイメージが溜まります。一切削除せずにこれを1年間続けた場合は、2ヶ月目は40USD、3ヶ月目には60USD……と積み上がっていくため、1年間の合計では1560USDもの課金が発生することになります。

ECRライフサイクルポリシーによる削除で発生した問題

ECRでは、ライフサイクルポリシーという設定によって、条件に一致したイメージを自動で削除できます。pushされてからの日数や、世代数を指定して削除する設定をタグのprefixごと指定して、不要イメージを削除できます。

しかし、ライフサイクルポリシーを設定していたあるプロダクトで、ECSサービスで利用中のイメージが削除された結果、新しいタスクが起動できないという問題が発生しました。

調査の結果、次のような事情で、実際にはまだ稼働しているECSタスクが利用しているイメージが削除されたのが原因と判明しました。

  • このプロダクトでは同一AWSアカウントに複数のマイクロサービスがあり、ECSサービスとタスク定義がそれぞれ存在する
  • 複数のマイクロサービスが共通で利用するサイドカーのイメージ(例: ログ転送のためのFluentdなど)は、共通のECRリポジトリを参照している
  • それぞれのマイクロサービスがデプロイするたびに新しいタグでECRにイメージがpushされる
  • 頻繁にデプロイするサービスAがイメージタグを更新して世代が積まれていく
  • めったにデプロイされないサービスBが使用していたイメージタグが、世代管理で削除された

ライフサイクルルールによる削除では、そのイメージが実際にECSで利用されているかは考慮されません。(一旦pullされたイメージがどう利用されているかはコンテナレジストリが関知できることではないので、当然ではあります)

そのため、単純にイメージを世代で管理すると、まだ実際には利用しているイメージが削除されてしまう可能性があります。

世代管理で使用中のイメージが削除される例

pushからの日数を条件にして削除する場合、頻繁にデプロイされないサービスのイメージを確実に保持するためには(頻繁にデプロイするサービスが更新した)大量の世代を残す必要があります。

デプロイするごとに、コミットハッシュではなく特定のprefixを持つタグ(例: service-a-20220830-112233, service-b-20220830-112233 など) を付けて、そのタグを対象に世代管理をすることも検討しました。

しかしライフサイクルポリシーにtagPrefixListの設定を複数行った場合、そのいずれかにマッチすると削除対象になってしまうため、利用中のイメージを確実に残すことはできなさそうでした。複数のサービスが同時にデプロイされる場合は同一のイメージを参照することもあるため、片方のサービスで条件にマッチしてしまうと削除されてしまいます。

ecrm を作った

ということで、ライフサイクルポリシーによらず、ECRから「使用されていない」イメージを削除するCLIツールを作ってみました。

github.com

ここでは、ECRのイメージが「使用されている」とは、以下の状態を指します。

  • ECSクラスタで現在実行中のタスクに含まれるイメージ
  • ECSサービスで指定されているタスク定義に含まれるイメージ
  • ECSタスク定義で指定されているイメージ(最新リビジョンから指定した世代数分)
  • Lambda関数で指定されているイメージ(最新バージョンから指定した世代数分)

これらのイメージはいままさに利用中だったり、デプロイがロールバックされて利用される可能性があるイメージのため、削除してはいけません。

それらのイメージ以外で、かつpushから一定以上の日数が経過したイメージは安全に削除できるものとして、削除を実行するツールです。

ecrm の使い方

設定ファイルの生成

AWSアカウントのcredentialを適切に環境変数などで設定した状態で ecrm generateを実行すると、アカウントに存在するECRやECS、Lambdaのリソースを元に、設定ファイル(ecrm.yaml)を自動生成します。

クラスタやタスク定義はワイルドカードのパターンで指定できるため、prefixが記号([_/-])で区切れてまとめられそうなものは、適宜まとめたパターンで設定ファイルを生成するようになっています。

clusters:
  - name_pattern: prod-*
  - name_pattern: stg-*
task_definitions:
  - name_pattern: prod-*
    keep_count: 5
  - name_pattern: stg-*
    keep_count: 5
lambda_functions:
  - name_pattern: prod-*
    keep_count: 5
    keep_aliase: true
  - name_pattern: stg-*
    keep_count: 5
    keep_aliase: true
repositories:
  - name_pattern: prod/*
    expires: 30d
    keep_count: 5
    keep_tag_patterns:
      - latest
  - name_pattern: stg/*
    expires: 30d
    keep_count: 5
    keep_tag_patterns:
      - latest

デフォルトでは以下の条件にマッチしたイメージを残して、それ以外を削除する設定を生成します。

  • ECSタスク定義に含まれるもの
    • 最新から5世代
    • 実際にECSサービスやタスクで利用中のものがあればそれも
  • Lambda関数で指定されているもの
    • 最新バージョンから5世代
    • バージョンにaliasが設定されているもの
  • イメージがpushされれてから30日以内
  • イメージに latest タグが付いているもの

対象のリポジトリ、ECSクラスタやタスク定義、Lambda関数の名前、保存する世代、ECRにpushされてからの日数、保護するタグのパターンは設定ファイルで適宜カスタマイズが可能です。

削除可能なイメージの確認と削除

ecrm plan を実行すると、AWSアカウント内に存在するECSやLambdaをスキャンし、ECRから安全に削除可能なイメージを検知し、情報を表示します。

EXPIREDの欄に表示されているのが、安全に削除可能なイメージ数と容量です。

$ ecrm plan

       REPOSITORY      |    TOTAL     |    EXPIRED    |    KEEP      
-----------------------+--------------+---------------+--------------
      dev/app          | 732 (594 GB) | -707 (574 GB) | 25 (21 GB)   
      dev/nginx        | 720 (28 GB)  | -697 (27 GB)  | 23 (875 MB)  
      prod/app         | 97 (80 GB)   | -87 (72 GB)   | 10 (8.4 GB)  
      prod/nginx       | 95 (3.7 GB)  | -85 (3.3 GB)  | 10 (381 MB)  

ecrm delete を実行すると、planで検出された、安全に削除可能と思われるイメージを実際にECRから削除します。

ecrm利用上の注意

ecrmでは現在、ECS(タスク、タスク定義、サービス)とLambda関数をスキャンして利用中のイメージを検知しますが、AWS App RunnerやAmazon EKSで利用されているイメージについては一切関知しません。App RuunerやEKSをご利用の場合はご注意ください。

まとめ

  • Amazon ECRのライフサイクルポリシーは、設定によっては実際に利用中のイメージを削除してしまうことがあります
  • 現在利用中のイメージを避けて、それ以外の不要なイメージを安全に削除できるCLIツールをOSSとして作成しました

カヤックでは、運用目的に応じたツールを必要に応じて作成できるSREを募集しています!

hubspot.kayac.com