はじめに
以前、サーバーレスアーキテクチャの全体像という記事を書きました。あの構成には自分で管理するサーバーが1台もありませんでしたが、今回携わった案件はほぼ真逆で、EC2の上でRailsが動く昔ながらの3層構成です。
現状AIと会話することでよしなにインフラを組んでもらえてしまってるので、理解を深めるためにまとめました。
今回どんなプロダクトを作ったか
あるブランドの期間限定イベント向けに、LINEミニアプリ(LIFF。LINEアプリの中でWebアプリを動かす仕組み)の予約システムを作りました。ユーザーの体験はこうです。
- LINE公式アカウントを友だち追加して、LINE内で予約ページを開く
- 希望の日時枠を選んで予約すると、LINEに予約完了メッセージが届く
- 当日は会場でQRコードを読み込んでチェックインする
裏側にはスタッフ用の管理画面もあり、予約状況の確認や当日の受付操作はそこで行います。数週間で作って数週間だけ動かす、イベント案件ならではの短期決戦なプロジェクトでした。
もう1つ今回の特徴として、ゼロからの新規開発ではなく、2〜3年前に開発されたシステムを再利用する案件だったことがあります。ベースはインフラの基盤強化が入る前のバージョンで、ところどころ不備も残っていました。そのため、まず既存のコードと構成を読んで挙動を把握し、不備を直しながら今回の要件を追加実装する、という進め方になりました。この記事で紹介する構成には、その過程で強化した部分(後述するスタンバイ機や監視まわりなど)も含まれています。
対象読者
「S3やEC2という名前は知っているけど、実際どう組み合わせて1つのサービスになるのかが掴めない」という人に向けて書きます。
構成図で見る全体像
まず全体像です。インフラの土台はAWS CDK(TypeScriptでAWSリソースを定義できるIaCツール)でコード管理しています。後述するWAFやスタンバイ機のような、運用の中で後から足した部分は一部コンソールでの手作業です。
流れは2つに分けると理解しやすいです。
1. ユーザーが画面を開く流れ
画面の表示に関わるのはCloudFrontとS3だけで、サーバー(EC2)はまだ登場しません。
2. ユーザーが予約を送信する流れ
予約が確定すると、RailsがLINE Messaging APIを呼んで、ユーザーのLINEに完了メッセージが届きます。
3. 管理者が予約を管理する流れ
経路自体はユーザー側とほぼ同じですが、管理画面用には別のCloudFront配信を立てていて、そちらにはWAFでIP制限をかけています。この話は後半で書きます。
各サービスの役割と「なぜ使うのか」
S3(ビルド済みSPAの置き場所)
フロントはVue 3のSPA(Single Page Application)で、ビルドするとHTMLとJSとCSSの静的ファイルになります。S3はその置き場所です。
バケットは公開設定にせず、CloudFront経由でのみ読めるようにしています(OAI、Origin Access Identityという仕組みです)。バケットを直接公開しても動きはしますが、URLが2系統できてしまう上に、後述するHTTPS化やキャッシュ制御が全部CloudFront頼みになるので、入口は1つに絞ります。
CloudFront(すべてのリクエストの入口)
CloudFrontはCDN(世界中のエッジサーバーからコンテンツを配信する仕組み)ですが、この構成では「配信の高速化」よりも「ルーティングの司令塔」としての役割が大きいです。
ポイントは、パスによって行き先を振り分けているところです。
defaultBehavior: {
// 通常のパスは S3 の SPA を返す
origin: new origins.S3Origin(s3bucket, { originAccessIdentity }),
},
additionalBehaviors: {
'/api/*': {
// API だけ ALB(Rails)へ流す
origin: apiOrigin,
},
},
なぜこうするかというと、フロントとAPIを同一オリジン(同じドメイン)にできるからです。フロントを xxxxx.cloudfront.net、APIを別ドメインで配信すると、ブラウザのCORS(別ドメインへの通信をブラウザが制限する仕組み)への対応が必要になります。同一オリジンならCORS設定そのものが不要になり、preflightリクエストも飛ばないのでAPIが速くなります。地味ですが、初学者が一番ハマりやすいCORS地獄を設計ごと消せる構成です。
もう1つSPAならではの設定があります。
errorResponses: [
{ httpStatus: 403, responsePagePath: '/index.html', responseHttpStatus: 200 },
],
SPAは /reserve のようなURLに直接アクセスされてもS3にそんなファイルは存在しないので、S3がエラーを返します(OAI構成だと404ではなく403になります)。それをCloudFront側で index.html に差し替えて、ルーティングはフロントのVue Routerに任せる、という定番の作りです。
この設定を知らずにSPAをS3配信すると、トップページ以外でリロードした瞬間にエラー画面が出ます。「SPAのS3配信にはエラーページの差し替えが必要」とセットで覚えておくのがおすすめです。
ACM(証明書はus-east-1で作るという罠)
ACMはHTTPS用のTLS証明書を無料で発行、自動更新してくれるサービスです。
ここには有名なハマりどころがあります。
CloudFrontに紐付ける証明書は、アプリ本体がどこのリージョンにあっても、us-east-1(バージニア北部)で作る必要があります。
アプリ本体は東京リージョンなのに証明書だけバージニア、という奇妙な状態になるため、CDKではスタックを分けて、証明書のARN(AWSリソースの識別子)をSSMパラメータ経由で東京側のスタックから参照する作りにしました。CDKのクロスリージョン参照は素直には書けないので、この案件で一番「インフラっぽい面倒くささ」を感じた部分です。
ALB(EC2の前に立つ受付)
ALB(Application Load Balancer)は本来、複数のサーバーにリクエストを分散する装置です。この案件のEC2は1台なので「分散」はしていないのですが、それでも置く理由があります。
まず、EC2を直接インターネットに晒さずに済みます。もちろんALB自身はインターネットに面するのですが、ALBはOSもSSHポートも持たないAWS管理の装置で、できることはHTTPの転送だけです。OSごと丸ごと攻撃対象になるEC2を守衛の後ろに下げて、掴みどころのないALBを矢面に立てる、という交代です。セキュリティグループ(インスタンス単位のファイアウォール)をALB用とEC2用で分けて、EC2の80番ポートは「ALBのセキュリティグループからのみ許可」にしています。
instanceSecurityGroup.addIngressRule(
albSecurityGroup, ec2.Port.tcp(80), 'Allow HTTP access from ALB only'
);
加えて、ALBは配下のEC2に定期的にHTTPリクエストを送って、正常な応答が返るかを確認し続けます(ヘルスチェック)。「サーバー自体は起動しているのに、上で動くRailsだけが落ちている」という故障はEC2の標準監視では見つけられないのですが、実際にHTTPで応答を確かめるヘルスチェックなら検知できます。
さらに、障害対応でEC2を差し替えるときに入口のDNS名が変わらないことも効いてきます。1台構成でもALBを挟んでおくと、後から打てる手が増えます。
EC2(Railsを動かす本体)
EC2はいわゆる仮想サーバーで、この上でnginxとRails(puma)が動いています。以前の記事ではここがLambdaでした。
なぜサーバーレスにしなかったのかですが、社内には既存のRails製の予約システムのベースがあり、それを流用して開発した方が工数が少なくすみそうだったからです。イベント期間限定でアクセス規模も読めるので、オートスケールの恩恵も薄そうでした。
サーバーの初期構築はCDKのユーザーデータ(EC2初回起動時に実行されるスクリプト)に寄せていて、nginxのインストールからrbenvでのRuby構築までコード化されています。手作業のセットアップ手順書が存在しないので、後述のスタンバイ機を作るときにそのまま効いてきました。
Elastic IP(送信元IPを固定する)
予約時に外部の顧客管理APIを呼んで本人特定をするのですが、そのAPIにはIPアドレス制限があり、事前に申請したIPからしかアクセスできません。
EC2のパブリックIPは、インスタンスを停止して起動し直すと変わってしまいます。そこでElastic IP(固定のグローバルIP)をEC2に紐付けて、送信元IPを固定しました。CDKではこう書いています。
// 外部APIへのアウトバウンド送信元IPをStop/Startで変えないため、EIPで固定する
const instanceEip = new ec2.CfnEIP(this, 'InstanceEip', { domain: 'vpc' });
// 先方へ申請済みの固定IPをスタック削除/置換で解放させないため保持する
instanceEip.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
RemovalPolicy.RETAIN がミソです。CDKはコードと実環境を同期するツールなので、スタックの削除やリソースの作り直しの際にEIPを「解放」(そのIPアドレス自体を手放してAWSに返却)しようとします。一度解放されたIPは取り戻せないので、新しいIPを取得して外部へ再申請するところからやり直しです。RETAINを付けておくと、CDKが削除しようとする場面でもIPの所有だけはアカウントに残ります。
「消えたら取り返しがつかないリソース」には保持ポリシーを付ける。
RDS(予約データの保管庫)
RDSはマネージドなデータベースサービスで、ここではMySQL 8.0を動かしています。EC2に自分でMySQLを入れる選択肢もありますが、バックアップ、パッチ適用、障害時の復旧をAWSに任せられるのが大きいです。
RDSには自動バックアップの仕組みがあり、有効にすると毎日のスナップショットに加えて、トランザクションログ(データ変更の記録)が5分おきに保存されます。この2つを組み合わせて「昨日の14時32分の状態」のような任意の時点にDBを復元できる機能がPITR(ポイントインタイムリカバリ)です。誤操作やバグでデータを壊しても直前まで巻き戻せる保険でこれを選定理由の一つにしました。バックアップ保持期間は最大の35日に設定しています。
幸いまだ本番で使う事態は起きていませんが、いざという時に手順が分からないと意味がないので、本番相当環境で復元リハーサルをやりました。復元自体は約10分で完了しました。
PITRの復元は既存のDBを上書きするのではなく「別インスタンス」として作られます。復元して終わりではなく、アプリの接続先を切り替えるところまでが復旧作業です。実際に手を動かして初めて分かった点でした。
Secrets ManagerとSSM(パスワードを書かない、SSHしないための裏方)
DBのパスワードはSecrets Managerに自動生成させて、EC2のIAMロールに読み取り権限だけを渡しています。コードにも環境変数ファイルにも生のパスワードが存在しない状態を作れます。
credentials: rds.Credentials.fromGeneratedSecret(props.dbUser),
// ...
dbInstance.secret.grantRead(instanceRole);
デプロイはSSM(AWS Systems Manager)のセッション経由でEC2にコマンドを送る方式にしました。SSHの鍵ファイルを配る運用は、メンバーの入れ替わりや鍵の漏洩リスクを考えると避けたかったからです。誰がいつ何を実行したかがAWS側に記録されるのも、本番環境では安心材料になります。
運用まわりの構成(動かし続けるための工夫)
作って終わりではなく、イベント期間中は「止めない」ことが最優先でした。ここからは可用性まわりです。
WAF(管理画面へのIP制限)
管理画面は個人情報を扱うので、WAF(Web Application Firewall。リクエストをルールで検査して弾く仕組み)で事務所のIPアドレスからしかアクセスできないようにしました。CloudFrontにWAFを付けるだけだと、ALBのDNS名に直接アクセスされたときに素通りしてしまうので、ALB側のセキュリティグループも合わせて締めています。
WAFが検査できるのは「WAFを通ってきたリクエスト」だけです。オリジン(ALBやEC2)へ直接アクセスできる裏口が開いたままだと素通りするので、入口の制限と裏口の閉鎖は必ずセットで行います。
スタンバイEC2(もう1台を止めて備える)
EC2が1台構成である以上、その1台が壊れたらサービスは止まります。冗長化のセオリーはマルチAZでのオートスケーリングですが、短期イベントには過剰と判断して、代わりに「本番と同じAMI(サーバーの起動イメージ)から作ったスタンバイ機を、別のアベイラビリティゾーンに停止状態で置いておく」方式にしました。停止中のEC2はほぼ課金されないので、コストは実質ゼロです。
障害時は、スタンバイを起動してElastic IPを付け替え、ALBのターゲットを差し替えれば復旧します。手順はランブックにまとめた上で、本番環境で実際に切り替え訓練までやりました。実測でダウンタイムは約5分。訓練をやってみると「デプロイスクリプトがNameタグでサーバーを特定しているので、旧サーバーの改名を忘れると事故る」といった手順書の穴が次々見つかりました。
ランブックは作成するだけではなく、その後実際にリハーサルしてみて問題ないか確認するところまでやりましょうとレビュワーに指摘を受け実際にやってみたのですが、意外と不備が見つかりました、、。やっておいてよかったです。
サービス間の連携(予約完了までの流れ)
最後に、ユーザーが予約を1件完了するまでの流れを追ってみます。
Step 1は画面表示です。ユーザーがLINE内で予約ページを開くと、リクエストはCloudFrontに届き、S3からSPAのファイルが返されます。以降の画面遷移はブラウザ内でVue Routerが処理するので、サーバーへの画面リクエストはもう発生しません。
Step 2は予約の送信です。SPAが /api/reserves へPOSTすると、CloudFrontが /api/* のルールでALBへ転送し、EC2上のRailsが予約枠の空きを確認してRDSに書き込みます。このときEC2はElastic IP経由で外部の顧客管理APIを呼び、本人特定も行います。
Step 3は通知です。予約が確定すると、RailsがLINE Messaging APIを叩いて、ユーザーのLINEに予約内容のメッセージを送ります。ユーザーから見ると「予約ボタンを押したらLINEに通知が来た」だけですが、裏では5つ以上のサービスがリレーしています。
まとめ
| サービス | 一言で言うと | なぜ必要か |
|---|---|---|
| S3 | SPAの置き場所 | 静的ファイルを安く確実に配信するため |
| CloudFront | 全リクエストの入口 | フロントとAPIを同一オリジンにしてCORSを消すため |
| ACM | 証明書の発行と自動更新 | HTTPS化のため(CloudFront用はus-east-1) |
| ALB | EC2の前の受付 | EC2を直接晒さず、差し替え可能にするため |
| EC2 | Railsが動く本体 | 既存のRails資産で短納期に間に合わせるため |
| Elastic IP | 送信元IPの固定 | IP制限のある外部APIを呼ぶため |
| RDS | 予約データの保管庫 | バックアップとPITRをAWSに任せるため |
| Secrets Manager / SSM | 認証情報と操作の裏方 | パスワードを書かない、SSHしないため |
| WAF | 管理画面の門番 | 個人情報を扱う画面をIP制限するため |
感想
EC2、ACM、ALB、WAFは今回初めて使うことができたので覚えておきたいです。
今回の案件は時間がなかったことに加えインフラの理解度も浅いままスタートしたのもあり
インフラ構築をする際「〇〇したい」とclaudeにお願いすると「それならこれが必要ですね!」とサービスを提示されて「じゃあ一旦それで、、」とあまり理解をしないまま提案を受け入れて実装することが多かった気がします。
最近はAIの性能が上がりすぎてふわっと頼んでもコーディングはほとんどよしなにやってくれます。
浅い理解でもインフラが組めるのはありがたい反面、なあなあの知識のままで終わってしまいそうなのが恐ろしいなと危機感を覚えてます。こうやってqiitaにアウトプットしていく過程で整理したり曖昧だったところを潰していくことで知識を自分のものにしていけたらと思います。
