Cloudformationを使って0から社内アプリケーションのAWSインフラを構築した

published_at: 2023-11-04

概要

仕事で新しい社内アプリケーションを作る機会があり、Cloudformationを使って0からAWSインフラを構築したので、振り返りも兼ねてやってきたことや苦労したことをまとめる。

技術スタック

  • Rails 7系
  • Ruby 3系

社内の技術ナレッジ的にRailsがベストなのでそちらを採用していた。

やったこと

大まかにやったことは下記の通り。

  1. AWSリソース構成図を書く
  2. Cloudformationでざっくりとリソースを定義
  3. Cloudformationをデプロイしてスタックを作りながら微調整
  4. Dockerfileを本番用にビルドしてECRにpush
  5. スタックを更新してアプリケーションが動くように微調整
  6. ドメインの取得とSSL

1. AWSリソース構成図を書く

FargateでWebアプリケーションを動作させて、DBはRDSを利用するシンプルな構成。

Gatewayがあって、LoadBalancerを挟んでトラフィックを受け入れる形。

ひとまず下図のような形になった。

AWS構成図

2. Cloudformationでざっくりとリソースを定義

Cloudformationで大まかにリソースを定義していった。

ChatGPTやサンプルテンプレートを参照してリソースを定義し、公式docを見ながら正しいこと確認する流れで進めた。

 

ChatGPTが出力したリソース例だと割とエラーになるケースが多く、公式docをベースにしつつエラーが起きた場合だけ、ChatGPTに聞きつつ進めるという形に落ち着いた。

特にAWS公式が出している下記のECSテンプレートスニペットはかなり重宝した。

refs. https://docs.aws.amazon.com/ja_jp/AWSCloudformation/latest/UserGuide/quickref-ecs.html

3. Cloudformationで作成したリソース定義をもとにスタックを作りながら微調整

作成したリソース定義をもとにスタックを作りながら動作等を微調整していった。

スタック作る過程でRDSのリソース作成はかなり時間がかかるということが分かってきた。(それはそう)

そのため、RDSECSは別々でリソース作成するように進め方を変更した。

 

まず、RDS単体のリソース単体で作成できることを確認し、その後ECS単体のリソース作成を確認。

最後に両者を紐づけていく流れにしていった。

3-1. RDS周り

今回のアプリケーションはI/O負荷等が高くないので、RDSは小さめの構成を選択した。

RDSインスタンスのクラスも小さめだし、ストレージタイプも小さいものを選んだ。マルチAZもなし。

refs. https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/CHAP_Storage.html#Concepts.Storage

3-2. ECS周り

ECSのリソースを定義する過程で下記のようなエラーに遭遇し、CloudTrailCloudWatch Logs等を確認しつつ対応していった。

  • ECSのタスク実行ロールにポリシーが足りないためスタック作成時にエラー
  • ゲートウェイがデタッチできないエラー

ECSのタスク実行ロールにポリシーが足りないためスタック作成時にエラー

ECSタスク実行ロール権限を付与したロールを作成し、ECSのタスクから各AWSリソースにアクセスするためにポリシーを付与。

最初は、AWS管理の AmazonECSTaskExecutionRolePolicyを付与すればすんなりいけるのかなと思っていたが、中身を見てみると CloudWatch Logs のWrite、ECR のReadしか許可されていなかった。

 

そのため、追加でRDSS3Systems Managerなどのリソースにアクセスできるようにポリシーを付与した。

今回は利用しなかったが、CloudTrailイベントに基づいてポリシーを生成できる機能が便利そう。

ゲートウェイがデタッチできないエラー

1Network vpc-xxxxxxxx has some mapped public address(es). Please unmap those public address(es) before detaching the gateway.

大幅なスタック変更を反映しようとすると、Internet GatewayがデタッチできずCloudtrailで上記のエラーが出ていた。

どうやら、スタックを作り替える際にVPCからパブリックIPをアンマッピングする前にInternet Gatewayをデタッチしようとしているよう。

 

下記の記事でも同じエラーに遭遇していた。

記事によると、一度リソースを削除してみるしかないようで、削除したら解決した。

refs. https://hyp0th3rmi4.medium.com/aws-Cloudformation-adventures-part1-build-your-own-vpc-d3f6d990d1fd

 

この作業を通じて NAT Gatewayの存在を知った。

NAT GatewayPrivate Subnet内のインスタンスからネットワーク接続するためのアドレス変換サービスのよう。

4. Dockerfileを本番用にビルドしてECRにpush

Dockerfileは事前にビルドしてECRに格納した上で利用していたが、そのイメージが正しくなかったので修正した。

docker-compose.yml側に寄せていたため、Dockerfile単体のイメージにリポジトリのファイルがマウントされておらず、そのままECSにあげても rails Command not foundとなってしまっていた。

そのため、本番環境用にDockerfileを作り直してビルドした上で利用した。

 

その際にやったことをリストにまとめると下記のようになる。

  • Systems Managerで環境変数を渡すようにする
  • イメージビルドと同時にprecompileしておく
  • AWS_ACCESS_KEYなどの秘匿情報は渡さずにECSタスク実行ロールにポリシーを付与
  • SECRET_KEY_BASEの渡し方を工夫する
  • Dockerfilex86_64のプラットフォームを指定
  • ENTRYPOINTを追加しshellスクリプトでサーバー起動

4-1. Systems Managerで環境変数を渡すようにする

元々docker-compose.ymlに環境変数をベタ書きしていた。

プライベートリポジトリだから漏洩リスクが低いことと、スピード感を優先した結果、そういう構成になっていた。

 

ただ、Dockerfileのイメージに環境変数を含めるのはリスクなので、Systems Managerのパラメータストアに格納した環境変数を利用するようにした。

4-2. イメージビルドと同時にprecompileしておく

当初はコンテナ起動時にサーバー起動と一緒にprecompileをしていた。

ただ、それだと後々起動に時間がかかってしまうというレビューをいただき、イメージビルド時にprecompileすることにした。

4-3.AWS_ACCESS_KEYなどの秘匿情報は渡さずにECSタスク実行ロールにポリシーを付与

ローカルでS3と接続する際に AWS_ACCESS_KEY_IDなどを環境変数で渡していた。

環境変数をパラメータストアから注入する形にすると、事前にローカルでイメージビルドする際にprecompileでこける。(AWS_ACCESS_KEY_IDがないため)

 

そもそも本番環境でS3にアクセスさせるために、AWS_ACCESS_KEY_IDなど必要ではなく、ECSタスク実行ロールにS3へのアクセスポリシーを付与すれば良いというレビューをいただいた。

 

そのため、ローカル環境ならAWS_ACCESS_KEY_IDを環境変数として利用し、本番環境ではポリシー付与することで該当の環境変数を不要にした。

4-4. SECRET_KEY_BASEの渡し方を工夫する

precompileを成功させるにはSECRET_KEY_BASEの環境変数が必要になる。

当初はRUN --mount=type=secretを使ってSECRET_KEY_BASEの秘匿情報を渡していた。

refs. https://docs.docker.com/engine/reference/builder/#run---mounttypesecret

 

しかし、RoRの下記issueを確認したところ、そもそもビルドステップ時において本物のSECRET_KEY_BASEを渡す必要がないとのことで、dummyの値を渡すように変更した。(buildのコマンドも短くなってスッキリする)

refs. https://github.com/rails/rails/issues/32947#issuecomment-470380517

 

また、ビルド時にproductionという変数を渡せばprecompileするし、そうでなければしないようにすることで、ローカル環境と本番環境でDockerfileを併用できるようにした。

4-5. Dockerfileでx86_64のプラットフォームを指定

M1 Macだとビルドするとarmのイメージが作成されてしまい、ECSタスク起動時に下記エラーが吐かれる。

1[FATAL tini (8)] exec /bin/sh failed: Exec format error

ECSタスクのデフォルトアーキテクチャはx86_64なので、そちらに合わせてDockerfile内でプラットフォームをx86_64に指定した。

4-6. ENTRYPOINTを追加しshellスクリプトでサーバー起動

当初はCloudformationのECSタスク定義のCommandオプションにサーバー起動のコマンドを記述していた。

ただ、ECSタスク起動時にDockerfileENTRYPOINTが実行されると公式ドキュメントに記載されていたのでそちらに変更。

ECSタスク起動時にdocker runするから実行されるとのこと。

refs. https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/task_definition_parameters.html

5. スタックを更新してアプリケーションが動くように微調整

ここまできたらアプリケーションが動くかなと思っていたが、タスク起動時にヘルスチェックに失敗し続けていた。

ヘルスチェックのエンドポイントがなかったのでコケ続けていたため、ELBのタイムアウト設定を追加(HealthCheckIntervalSecondsなど)して、専用のエンドポイントを設定したところ、ヘルスチェックに成功した。

 

また、ActionView::Template::Error (The asset "application.css" is not present in the asset pipeline.)エラーが出たので、RAILS_SERVE_STATIC_FILESの環境変数を正しく設定した。

 

さらに、コンテナ側では80番ポートを開けているのに、Railsサーバー起動時に3000番ポートで動かしてしまいエラーが出たので、ポートを80番で起動させるように修正した。(ローカルのやつそのままコピペしてた)

 

加えて、RDS起動後にECSを立ち上げるようにDependsOnで立ち上げ順序の指定を行うなどした。

6. ドメインの取得とSSL化

  • ドメインの取得
  • ドメインにLBを割り当て
  • SSL

基本的に下記の記事を参考にして行った。

refs. https://qiita.com/NaokiIshimura/items/654f1f82adb039f1ad47

6-1. ドメインの取得

Route53でドメイン名を取得した。円安....

6-2. ドメインにLBを割り当て

ECSと紐づいているLBにドメインを割り当てることで、ドメイン名を入力してアクセスできるようになる。

6-3. SSL化

ACMにて証明書の発行リクエストを行い、LBに紐づいているSecurity GroupのインバウンドルールにHTTPSでの通信を許可し、LBのリスナーにHTTPSを追加すればOK。

デバッグについて

基本的には記述したCloudformationstackを作成しイベントログやCloudTrailを参照してリソース作成時のエラーを確認。

アプリケーション立ち上げ時には、CloudWatch Logsを確認していた。

また、必要に応じて下記コマンドでECSタスク内部に入ってファイル等を確認することを行った。

1$ aws ecs update-service --region region-name --cluster cluster-name --service service-name --enable-execute-command

 

上記のコマンドでECSクラスター内のサービスでコマンド実行を可能にする。

1$ aws ecs describe-services --cluster cluster-name --services service-name | jq '.services[].enableExecuteCommand'

 

上記のコマンドでクラスター内のサービスでコマンド実行が可能状態かどうか確認。

1$ aws ecs update-service --force-new-deployment --service service-name --cluster cluster-name

 

上記のコマンドでクラスター内のサービスで強制デプロイをかけることで、コマンド実行可能なタスクが作成される。

1$ aws ecs execute-command --region region-name --cluster cluster-name --task task-name --container container-name --interactive --command "/bin/sh"

その上で新規作成したタスク名を指定して。コマンドを実行することでコンテナに入る。

RDSに接続してDBが作成されているかどうかなど確認した。

デプロイについて

  1. イメージをビルド
  2. ECRにpush
  3. タスクの新規登録
  4. ECSサービスの更新

1. イメージをビルド

1$ docker build -t image-name:version -f Dockerfile . --build-arg RAILS_ENV=production

RAILS_ENVの引数を渡したらprecompileが走るようになっている。

 

1$ docker tag image-name:version *******.dkr.ecr.ap-northeast-1.amazonaws.com/repository-name:version

イメージにタグを付与する。

2. ECRにpush

1$ aws ecr get-login-password --region resion-name | docker login --username user-name --password-stdin ********.dkr.ecr.resion-name.amazonaws.com

ECRにログイン

 

1$ docker push **********.dkr.ecr.resion-name.amazonaws.com/repository-name:version

ECRにイメージpushする。

3. タスクの新規登録

1$ aws ecs register-task-definition --family family_name --cli-input-json "$(aws ecs describe-task-definition --task-definition task_definition_name:{$number} | jq '.taskDefinition' | jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredBy, .registeredAt)')"

jqを使ってdescribe-task-definitionで取得したタスク定義から不要なものを取り除いた上で新規のタスクを登録している。

 

下記の記事にもあるように、describe-task-definitionで取得したタスク定義のJSONはそのままだと登録できず、不要な項目を省いて登録する必要がある。

refs. https://dev.classmethod.jp/articles/describe-task-definition-to-register-task-definition/

4. ECSサービスの更新

1$ aws ecs update-service --cluster cluster-name --service service-name --task-definition task_definition_name:{$number}

3のコマンドを実行すると、JSONが返却されてタスク定義名が返却されるので、そちらを指定してECSを更新する。

そうすることで、新しいタスクが実行されてデプロイが完了する。

まとめ

0からAWSリソースを構築することができたのはとても良い経験になった。

社内の既存アプリケーションのインフラはコード化されておらず、今回のアプリケーション作成を通じてIaCの基盤を構築できたということも一つ成果として挙げられる。

 

インフラ構築は経験したことがなかったので、最初はリソースの調査をしつつ、1つ1つ定義していった。

stackの作成・更新を通じてエラーが出てそれを直すという繰り返しで、エラー駆動で進めていた。

 

エラーの原因がよく分からないとなった時は、どこのリソースまで定義したらエラーになるんだっけ等、問題の分割ができるのも新しい発見だった。

AWSコンソール画面で手動作成するのではなく、Cloudformationを使ったからそれがやりやすかったのではないかという印象を受けている。

社内でインフラに精通しているエンジニアの方にアドバイスももらいつつ進められた。この場を借りて改めて感謝したい。