AWS CDKを活用して既存Webサービスをコンテナ化・インフラ刷新した案件の事例紹介
先々月、私がメインで関わっている案件でAWSのインフラを刷新し、無事に完了しました。このとき弊社では採用例のなかったAWS CDKを使ったので、この案件についてご紹介します。堅苦しい表題にしてみましたが、反省なども含みつつ振り返りたいと思います。
TL;DR
- EC2のアプリケーションをコンテナ運用に変更し、DBやVPCも新規に作り直した
- AWS CDKのおかげでスムーズに進んだ
- スタック分割しすぎなどの反省点や、CI/CDなどの今後の改善点は多数あり
案件概要
対象となったWebサービスは数年前に開発されたもので、リプレース開始時点でも本番運用されていました。弊社では珍しくPHPでなくRuby on Railsを使っているなどアプリケーションには特徴がありますが、インフラはロードバランサ(ALB)+サーバ(EC2)+MySQL(RDS)からなるごく一般的な構成でした。
今回のインフラリプレースの主な変更点は、次の3つです。
- EC2インスタンスで動いていたアプリケーションをコンテナ化し、ECS on Fargateで動く構成に変更
- RDSが古いAurora(MySQL Community 5.7)だったため、新しいAurora(MySQL 8.0互換)に変更
- セキュリティ向上のため、新しいアプリケーションやDBは新しいVPCに構築
かなり簡略化していますが、上図が旧環境から新環境へ移行するイメージ図です。本案件にはtest環境、staging環境、prod環境の3つがあり、これらをそれぞれ独立したVPCに新規作成します。
AWS CDKの採用
新しくVPCを作りRDSやECSを構築するのにあたり、現行環境とは別の検証用AWSアカウントでインフラ構成を実際に確認しました。その後、本番リプレース時に本番環境にまとめて構築したのですが、このときにAWS CDKによるインフラ構築が便利でした。
CloudFormationでなくAWS CDKを使ったそれっぽい理由は、端的に次の3点です。
- 再現性・利便性に加え、プログラミング言語ゆえの表現力の高さ
- AWSが公式に提供する安心感と、先行事例の豊富さ
- CloudFormationなどの既存リソースとの高い親和性
検証環境で事前に確認した手順を、本番環境でも簡単に再現できたのが特に大きな利点でした。DBの移行作業があったため「`cdk deploy`の1コマンドだけで完了」とはなりませんでしたが、全体的にスムーズに進み、インフラ構築の作業自体は半日程度で完了しました。そのうち、旧本番環境の停止やDB移行などで手作業した以外は、デプロイコマンドを実行してのんびり結果を眺める時間がほとんどです。
AWS CDKを使って良かった点については、拙筆ながら先日記事を書いたのでそちらをご覧ください。
AWS CDKでのスタック構成
CDKでは、スタックという単位でリソースを分けてデプロイすることができます。この実体はCloudFormationのスタックと同じです。スタック間での共有(リソースのARNなど)はCloudFormationの機能を内部的に使って実現しています。
そのため、スタックを細かく分割すると思わぬ相互参照が発生し、デプロイ時にエラーになる場合があります。CFn生成時に「循環参照になってる」とCDKのコンパイルエラーで済むときもありますが、場合によってはCFnのスタック作成時に実行時エラーが出るときもあります。検証用アカウントで発生したことがあるのですが、そのときは作ったリソースをスタックまるごと全て消すという方法で強引に解決しました。もちろん本番環境ではそうもいかないので、要注意です。
上図が、本案件で構築したスタック構成です。合計9個のスタックになっています。インフラレイヤー(ネットワークやDBなど、ほとんど変更のない基幹部分)とアプリケーションレイヤー(ECS+ALBで、定期的に変更が発生する部分)で分けて、インフラ部分の不要な変更を避けたかったためです。
ただしアプリケーションスタックはインフラスタックに依存しているため、アプリケーション部分のみの変更でも、インフラスタックにも差分があれば同時にデプロイされる挙動になります。そのため、スタックを分けず、コード上で分割するだけならConstructを使った設計にする方がベストでした。
実際、AWS公式のドキュメントでも、ユーザのブログ記事でも「できる限りはスタックを分割しない」というベストプラクティスが主流なようです。
コンテナ運用
本案件では、従来はEC2インスタンスで動いていたアプリケーションをECSとFargateを使ったコンテナ環境に変更しました。コンテナ運用は弊社にも事例はあったのですが、諸事情で参考にできなかったため、主にAWSカンファレンスや他社事例を参考にしました。構築にあたって、気にした項目は次の項目です。
- イメージタグをlatestで運用するのはアンチパターンなので、GitのコミットID(ハッシュ)を使う
- 各環境で運用中のイメージタグをパラメータストアで保持するようにしておく
- CI/CDを整備する
- 本番環境とステージング環境で(ほぼ)同じイメージを参照する
まだ本番運用が始まって間もないため、今後もっと知見が溜まっていくと思いますが、今のところ実感しているコンテナ化の恩恵は、
- サーバ管理が不要(代わりにDockerfileを頑張って作る)になり、色々な手間が減る
- フレームワークや言語に依存するデプロイツールでなく、AWSのデプロイの仕組みを使うので分かりやすい
- 本番環境とステージング環境での差が環境変数のみになり、全体的な心理的安全性が高い
といったところです。ECS Execを使うことで、コンテナのシェルに直接アクセスすることができ、rails console(Ruby on Rails)やartisan(Laravel)も使えます。
ロギングや監視についてはまだ改善の余地が多いので、引き続き今後も改善していく予定です。
開発フローとデプロイの流れ
開発はGitHubのリポジトリで行います。ブランチの運用は、developブランチとmasterブランチの2本を基軸としたゆるめのGit Flowです。
1.feature/hoge-fuga-implementのようなfeatureブランチで機能開発し、プルリクを作ってdevelopブランチにマージします。developブランチの内容は、自動的にtest環境へデプロイされます。
2.test環境で問題なければ、本番リリースするときにdevelopブランチをmasterブランチへマージします。masterブランチにコミットがあると、自動でstaging環境にデプロイされます。
3.staging環境でも問題なければ、prod環境へデプロイします。それには、AWSマネジメントコンソールからCodePipelineの画面で手動承認をします。手動承認すると、staging環境にデプロイしたのと同じイメージがprod環境にデプロイされます。
CodePipelineを使ったCI/CD
CI/CDしなくてもコンテナ運用することはできますし、本案件は1日1回のような頻繁なデプロイはないので必須ではありませんでしたが、
- 開発者の環境によってビルドしたイメージが微妙に違う、みたいな曖昧さを排除したい
- インフラのコードとアプリケーションのコードは別管理が前提
- インフラのデプロイ環境(Node.jsのAWS CDK環境)がなくても、アプリケーションのデプロイを簡単に行いたい
といった理由から、CodePipelineでビルド&デプロイの流れを自動化しています。特別な点はありませんが、流れを図にしました。
1. まず、開発者はGitHubでプルリクエストを作り、レビュー後にマージします
2. リポジトリの特定のブランチにコミットされると、CodePipelineが走ります
3. CodeBuildが `docker build` して、コンテナイメージを作成します
4. CodeBuildは作成した新規イメージを `docker push` して、ECRに格納します
5. デプロイ先が本番環境 (prod)の場合、CodePipelineの画面でデプロイを手動承認します
6. AWS Lambdaで、AWS Systems Managerのパラメータ(運用中のタグを格納)を更新します
7. CodeBuild(AWS ECS)で、新規イメージをデプロイします
わざわざSSMのパラメータストアで運用中のタグを管理しているのは、「イミュータブルタグで起きる問題 – AWS DevDay Japan 2022」を参考にしました。CDKではコンテナをデプロイしないですし、インフラのコードを自動デプロイもしないため、本構成での必要性は微妙ですが念のための対応です。CodeBuildでもパラメータの更新は可能ですが、ビルドとデプロイが1対1でない(1回のビルドで作った同じイメージを複数環境にデプロイするケースがある)のでLambdaを使っています。
ちなみに、CI/CDのCIの部分はまだあまり整備できていません。後回しになっていますが、GitHub Actions(弊社ではまだ珍しい?)で自動テストが動くようにする予定です。CodePipelineを現在使っているのは、AWS ECSやLambdaとの親和性が高いためでした。そのためCI環境によっては、GitHub Actionsでデプロイする方法に変更することも検討しています。
移行作業・CDKの細かい話
DBの移行方法
案件概要のとおり、もともと長く本番運用していたサービスのインフラ刷新で、データベースも更新する必要がありました。そのため、旧データベース(コミュニティ版 MySQL 5.7)を新しいAuroraに移行する方法を考える必要があります。mysqldumpを使って手動で移行する方法も考えられますが、セキュリティや利便性を鑑みるとやはりRDSのスナップショットを使うのがベストと判断しました。
本番環境を停止し、旧DBのスナップショットを作成し、そのスナップショットを参照して新しくDBを作りました。注意点は、旧DBはコミュニティ版 MySQL 5.7で動いていたため、スナップショットもMySQL 5.7互換になる点です。旧DBのスナップショットから、MySQL 8.0互換のAurora MySQL 3を直接作ることはできません。そのため、まずはMySQL 5.7互換のAurora MySQL 2.11.1を指定します。そのデプロイ後、Aurora MySQL 3.03.0に変更して再デプロイします。
実際のCDKのコードは、このような感じです。
import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as rds from 'aws-cdk-lib/aws-rds'; // RDBの作成(抜粋) this.databaseCluster = new rds.DatabaseClusterFromSnapshot(this, `staging-db-cluster`, { engine: rds.DatabaseClusterEngine.auroraMysql({ // MySQL 5.7のスナップショットから作成時 version: rds.AuroraMysqlEngineVersion.VER_2_11_1, // → VER_3_03_0 }), instanceProps: { vpc: this.vpc, instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MEDIUM), vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }, }, snapshotIdentifier: databaseSnapshotIdentifier, instances: 2, clusterIdentifier: `hogefuga-staging-db-cluster`, instanceIdentifierBase: `hogefuga-staging-db-instance`, removalPolicy: cdk.RemovalPolicy.RETAIN, });
databaseSnapshotIdentifier
には旧DBのスナップショットIDを指定します。上のコードの外側で、cdk.jsonに保存されているパラメータを参照するようにしています。
cronの移行
もともとEC2を前提にcronで動いていたバッチ処理を、ECS on Fargateの構成にあった方法に変更してあげなくてはいけません。いろいろな方法が考えられますが、この構成であればECSの機能で「スケジュールされたタスク」を作るのがシンプルで適しています。
それをCDKで設定するにはどうしようかと調べると、ScheduledFargateTaskを使う方法がでてきます。ただ、これを使うと必ず新しいタスク定義が作成されてしまうようでした。Webアプリケーションとバッチ処理は同じコード・同じイメージなので、Webアプリケーションと同じタスク定義で実行したいです。複数のバッチ処理を登録すると、その数だけタスク定義が作成されるのも嬉しくありません。
そのため、次のようなConstructを自分で定義して利用することにしました。aws-ecs-patters/lib/ecs/scheduled-fargate-task.ts とほぼ同じですが、taskDefinition
をpropsで渡してそれを利用するようにしています。
import { Construct } from 'constructs'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as ecsp from 'aws-cdk-lib/aws-ecs-patterns'; import * as eventsTargets from 'aws-cdk-lib/aws-events-targets'; type CustomScheduledFargateTaskProps = ecsp.ScheduledTaskBaseProps & { taskDefinition: ecs.FargateTaskDefinition; containerOverrides: eventsTargets.ContainerOverride[]; }; export class CustomScheduledFargateTask extends ecsp.ScheduledTaskBase { public readonly task: eventsTargets.EcsTask; constructor(scope: Construct, id: string, props: CustomScheduledFargateTaskProps) { super(scope, id, props); this.task = new eventsTargets.EcsTask({ cluster: this.cluster, taskDefinition: props.taskDefinition, taskCount: this.desiredTaskCount, subnetSelection: this.subnetSelection, securityGroups: props.securityGroups, containerOverrides: props.containerOverrides, }); this.addTaskAsTarget(this.task); } }
おわりに
他にもやったことや今後の展望など多くあるのですが、長くなってしまったので、尻すぼみではありますが一度ここまでにします。
まだ作業中の内容もあるため、またの機会に改善内容や他の事項、詳細についても記載できればと思います。