suitebook のインフラ環境を ElasticBeanstalk から ECS on Fargate へ移設しました

@laugh_k です。先週 SQUEEZE のソリューション事業のプロダクトである suitebook のインフラ環境をローンチ当初から使われていた ElasticBeanstalk ベースの環境から ECS on Fargate の環境への移設を行いました。今回はその移設がどのような検証・手順を経て行ったのかを紹介します。

背景

ElasticBeanstalk 時代

ElasticBeanstalk は Heroku を使うような感覚でAWS上で手軽にアプリケーションを動かせるのが魅力で、スタートアップのサービスローンチ当初にはさくっとデプロイできる感じもマッチしていました。ですが、プロダクトの成長とともに問題も多く発生していきました。

  • 手軽に利用できる半面、Python3.6 への対応がなかなか来なかったり、Apache + mod_wsgi 以外の Web サーバが選択できなかったりと、環境にロックインされている感じは強かった。
  • そのうち 「staging がほしい」「cron 使いたい」「worker で SQS ではなく Celery + redis を使いたい」「検索基盤がほしい」といった状況が次々発生。その場その場でとりあえず environment を追加したり、.ebextensions にかなり無茶な改造を施すなどでしのいでいた。
  • その場しのぎを続けていった結果、VPC や SecurityGroup, IAM Role なども含めて継ぎ接ぎだらけとなり、管理ができているとは言えない状況になっていた。
  • ビジネス要件に応え続けるシステムを支えるには、限界が来ていることは薄々感じている状況だった。

ECS on Fargate

ECS on Fargate を採用した主な理由は以下です。

  • AWS にロックインされる部分はあるものの、一般的な導入実績や運用事例は見かける機会も多い
  • Fargate を利用することで EC2 の管理から開放され、インフラを使うことに集中できる

また、昨年末に日本リージョンに登場した EKS も検討はしたものの、2019-02-21 時点でまだ Fargate 対応もしていない状況に加え、社内に kubernetes 自体の運用経験者もいなく、インフラに使えるチームのリソースに限りがある状況ではまだ時期尚早と判断し見送りました。

移設までの流れ

大まかに見ても以下の経緯を経て移設を行いました。

  • アプリケーションのリリースごとの Docker イメージの自動生成
  • とりあえず ECS でプロダクトを動かす
  • 設定・環境変数をどうするか
  • デプロイをどうするか
  • 定期実行バッチ処理とそのデプロイをどうするか
  • ログ周りをどうするか
  • 運用上どうしても手動でのオペレーションが必用になる場合の対策
  • インフラ codenize と Production への展開準備
  • 実際の Production の切り替え

アプリケーションのリリースごとの Docker イメージの自動生成

ECS では Docker を使うことが大前提なので、リリースしたいプロダクトコードのバージョンごとに Docker image を自動でビルドできることは必須です。

まずは愚直にアプリケーションコードと必用なライブラリを含む image をビルドする Dockerfile を作ります。この image には アプリケーションサーバも同封します。今回は gunicorn を採用しました。また、suitebook のサーバサイドは Django を用いて実装されており、worker やバッチ処理などの Web アプリケーション以外の処理も同一のアプリケーションから行います。そのため一つの Docker image が用意できれば一通り対応できる状況にありました。

Docker image がビルドできる状況になったら次は自動化です。 SQUEEZE では CI/CD 環境として元々 CircleCI を利用していたため、Docker image build の自動化も CircleCI で行いました。CircleCI で Docker Build を行う際には基本的には以下の公式のドキュメントに従えばよいです。

Running Docker Commands — CircleCI

suitebook では開発フローに git-flow を採用していますが、新しい release の際に必ず発生する GitHub への tag の push をトリガーとして、Docker image の build と ECR への push を行うようにしました。大まかなイメージは以下とおりです。

とりあえず ECS でプロダクトを動かしてみる

検証を始める段階ではまだ ECS でプロダクトを動かす状況がイメージできていなかったため、まずは「とりあえず」でいいので動かしてみないことには始まりません。

最初は独立した VPC とテスト用DBを用意し、バックエンドを EC2 にした ECS を用意。愚直に手動で ECS Task を登録することろから Service として動かし ALB のターゲットに追加してみたり、Celery Worker 用の Service を動かしながら Fargate 上で動かして見るところまで、一つ一つの動作の検証を行いました。

EC2 をバックエンドとして検証を始めたのはコネヒトさんの以下のブログを参考にさせていただいたのが大きいのですが、実際 docker exec でコンテナの動きを確認したり、EC2 上から直接ネットワークの様子を探るケースが多くこの選択は大正解でした。有益な情報を公開していただき本当に感謝です。

AWS Fargateを本番運用した所感 — コネヒト開発者ブログ

実際に検証を重ねた結果、この段階で以下の方針を決めました。

  • ECS Service のデプロイに関しては AWS が提供する ecs-cli を使う
  • 環境変数は機密情報も安全に扱える Parameter Store を使う
  • cron で動かしているバッチジョブは Scheduled Tasks を使って動かす
  • ECS クラスタ以外のクラウドリソースは状況を見つつ必要性が出たところで Codenize を行う

設定・環境変数をどうするか

先程も記載したとおり、環境変数は実際の値の保存先に Parameter Store を利用するという方針に固めました。これについてはおよそ以下の理由です。

  • Secure String で機密情報も安全に扱える
  • /suitebook/production/... , /suitebook/staging/... と言った具合に各ステージごとの値を管理でき、IAM Policy でもアクセス制限ができる。
  • ecs-cli で ECS Task の設定を行う際に利用する ecs-params.yml でも利用可能

ecs-cli で ECS のデプロイを行う際は、ecs-params.yml にて以下のように services 対象の ECS Task の secrets に指定することで利用可能です。

task_definition:
-- -- snip -- --
services:
webapp:
secrets:
- name: "ENVIRON_1"
value_from: "/app/production/environt_1"

また ECS Task の IAM Role に以下のような Policy も忘れずに付与します。ssm:GetParametersResouce は実際にセットした Parameter のパスに対応したもの、kms:DecryptResouce は SecureString の暗号化の際に利用した KMS Key の ID に読み替えてください。

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Action": "ssm:DescribeParameters",
"Resource": "*"
},
{
"Sid": "",
"Effect": "Allow",
"Action": "ssm:GetParameters",
"Resource": [
"arn:aws:ssm:ap-northeast-1:(AWS ACCOUT ID):parameter/app/production/*"
]
},
{
"Sid": "",
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": [
"arn:aws:kms:ap-northeast-1:(AWS ACCOUT ID):key/(KMS KEY ID)"
]
}
]
}

ECS 環境での環境変数の扱い方や Parameter Store の扱い方については以下のスライドの内容が非常にわかりやすく、大変参考になりました。本当にありがとうございます。

デプロイをどうするか

実際に検証を重ねた結果のところにも記載しましたが、 ECS Service のデプロイには原則 ecs-cli を利用することにしました。理由としては主に以下のものがあります。

  • AWS 公式のツールであるため、メンテナンスが止まるなど心配はひとまずなさそう
  • docker-compose.yml と同じ形式の yml ファイルで基本的な設定が記載できるのがとっつきやすかった
  • docker-compose.yml で足りない部分は ecs-params.yml を利用することになるが、2つで設定できる内容で問題ないと判断。

ただし、この記事の執筆時点で ecs-cli は一つの ECS Task に対して docker-compose.yml と ecs-params.yml を一つずつ持つことが前提となっており、複数の ECS Task の設定をまとめて持つということはできません。suitebook は Django REST framework で実装されたサーバサイド API 以外にも Celery worker を用いた Job Queue があるので、それぞれに対して docker-compose.yml と ecs-params.yml を用意する必用がありました。また、 staging と production で違う設定も考慮する必用もありました。

そのため docker-compose.yml に関しては環境変数を利用することで staging も production も共通のものを使うことにしました。一方で ecs-params.yml については変更したいものが subnetsecurity grouptask_sizetask_execution_role とそれなりに多くの項目があったため、staging 用と production 用を別に用意し、以下のようになりました。

 compose/
├── webapp.yml
└── worker.yml
params/
├── production/
│ ├── webapp.yml
│ └── worker.yml
└── staging/
├── webapp.yml
└── worker.yml

ただこのままだと、例えば 「production の web に version x.xx.xx をデプロイしたい」となった場合以下のような感じの環境変数とオプションで実行する必用があります。(実際はもっと多いです)

$ STAGE=production \
> VERSION=x.xx.xx \
> ecs-cli compose \
> --file compose/webapp.yml \
> --cluster suitebook-production \
> --region ap-northeast-1 \
> --ecs-params params/production/webapp.yml \
> --project-name suitebook-webapp \
> service up \
> --launch-type FARGATE

流石にこれだけのオプションは覚えられないですし、手順書化や snippet を共有してもオペミスにつながる可能性もあります。そのため誰でも利用しやすいように wrap するシェルスクリプトを用意し、以下のようなもう少しシンプルなオプションでデプロイできるようにしています。

$ ./deploy-services.sh -s production -t webapp -i x.xx.xx

ここまでで既存の環境の WEB アプリケーションなどのデーモンとして起動していたものを ECS 上に乗せる見通しが立ってきていました。ですが、cron で動かしていたバッチはまだ乗せることができていません。そこに関しては次で紹介をします。

定期実行バッチ処理とそのデプロイをどうするか

実際に検証を重ねた結果のところにも記載しましたが、これまで cron で動かしていた定期実行のバッチ処理については ECS Scheduled Task を使って動かすことにしました。仕組みとしては Cloudwatch events のルールとターゲットを使用して、cron のように ECS Task を実行するというものです。以下のドキュメントにも説明があります。

タスクのスケジューリング (cron) — Amazon Elastic Container Service

ECS Task の定義さえできていていれば aws cli の events コマンドを使うことでデプロイも比較的簡単にできます。そのためバッチの種類が少なければ、以下の手順でも十分対応可能な範囲だとは思います。

  1. $ ecs-cli compose create を使い ECS Task の定義を更新
  2. $ aws events put-target を使い、実行する ECS Task を最新のものに指定しなおす

ですが、suitebook の場合はバッチの種類はこの記事の執筆時点で 28 あります。そうすると以下のような問題が発生します。

  • 28 種類に対応する docker-compose.yml と ecs-params.yml を用意する必用があり、とてもではないがメンテできない
  • 検証の過程や、後から追加されるバッチなどを考慮すると Scheduled Task で指定するべき ECS Task のバージョンがバラバラになり、手作業で指定するのは困難になる
  • シェルスクリプトによる wrap にも無理がある

これをうけて Scheduled Task のデプロイができそうなツールは無いか調査・検討は行ったものの、丁度いい規模感のものが残念ながら見つけられませんでした。

そのためバッチに限ってはスクリプトを Python (boto3, pyyaml)で自作しました。suitebook に特化する仕様のため内容が公開できるものではないのですが、ざっと紹介すると以下のとおりです。設定をする際に他の ECS Service と同様にできるよう ecs-cli に合わせています。

  1. compose/scheduled-tasks-base.yml という yaml ファイルを ecs-cli の docker-compose.yml と同様に用意する
  2. params/{production,staging}/scheduled-tasks-base.yml という yaml ファイルを ecs-cli の ecs-params.yml と同様に用意する
  3. {production,staging}-scheduled-tasks-define.yml という yaml ファイルに以下のように Scheduled Task で実行したい内容を書く
- name: batch 1
cron: '0 16 * * ? *' # Clowdwatch events 用の cron 式
command: batchcommand arg1 arg2 # `python manage.py` に続くコマンド

4. register スクリプトで ECS Task の定義を更新する

$ python ./register-bach-task-deifinitions.py -s production -i x.xx.xx

5. デプロイスクリプトで最新の ECS Task を使うよう Cloudwatch events を更新する

$ python ./deploy-scheduled-tasks.py -s production

また deploy-scheduled-tasks.py についてはメンテナンス時に一斉にバッチを停止できるよう、disabled モードも実装してます。

$ python ./deploy-scheduled-tasks.py -s production --disabled

ログ周りをどうするか

Docker 前提の環境の場合、ログをどうするかは必ずクリアする必用のある問題ですが、Fargate 環境で ECS の Task を実行する場合、現状では選択肢は Cloudwatch Logs 一択になると思います。

Log の収集自体は、以下のようにしてecs-cli 向けの docker-compose.yml に数行の設定をすることでできるため比較的簡単に実現できます。

services:
webapp:

-- -- snip -- --

logging:
driver: awslogs
options:
awslogs-region: ap-northeast-1
awslogs-group: /ecs/${STAGE}-webapp
awslogs-stream-prefix: app

他にポイントがあるとすれば、log の形式を JSON 形式にしたほうが良いというものがあります。そうすることで Cloudwatch Logs の強力なフィルタが利用できるようになります。以下の公式ドキュメントに JSON 形式のログに関する説明があります。

フィルターとパターンの構文 — Amazon CloudWatch Logs

Python の場合、JSON 形式で logging するためのライブラリが無数に存在します。現時点でのsuitebook では、メンテナンス状況が良さそうで比較的ユーザー数がいそうな python-json-logger を採用しています。

Django アプリケーションで利用する場合は、settings.py に以下のような設定をすることで log の JSON 化ができます。

LOGGING = {

# -- -- snip -- --

'formatters': {
'json_format': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'format': '%(created)f %(asctime)s %(levelname)s %(name)s %(message)s',
},
},
'handlers': {
'json_log_stream': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'json_format',
},
},
'loggers': {
'django': {
'level': 'WARNING',
'handlers': ['json_log_stream'],
'propagate': False,
},
},
}

運用上どうしても手動でのオペレーションが必用になる場合の対策

ここまでの対応でおおよそ ECS on Fargate で suitebook の production 環境が動かせる見通しが出てきました。ですがまだ課題は残っています。それはこれまで運用上手動で行っていたオペレーションをどうするかということです。

Fargate 環境では基本的に SSH や docker exec を伴うような対面的なオペレーションを行うことはできません。一方で suitebook のサーバサイドは Django を用いて開発されており、運用上発生する各種調査の際には、Django の提供する Python のインタラクティブシェルを経由して行う機会が多くあります。そのため手動でのオペレーションをする手段をなんとか残しておく必用がありました。

この問題に対しては、オペレーションを行う専用の ECS Task を用意し、その Task だけは ECS Instanse 上で動かすという方法で対応しています。どうしても手動のオペレーションが必用になったときだけ、対応する docker image の tag を指定して起動をし、ECS Instance から docker exec を行い対応します。

イメージとしては以下の様な感じになります。

  1. ecs-cli compose up でオペレーション用 Task を起動
$ VERSION=x.xx.x \
> STAGE=production \
> ecs-cli compose \
> --file compose/operation.yml \
> --ecs-params params/production/operation.yml \
> up --launch-type EC2

2. ECS Instanse にログインし、対処コンテナの ID を確認

$ ssh (ECS Instanse IP or DNS)
$ docker ps

3. 確認した ID で docker exec する

$ docker exec -it (Container ID) python3 manage.py shell

EC2 の運用も発生してしまいますが、operation 以外の Task は実行させない環境と割り切って許容することにしています。

また DB migration のような一つのコマンドだけ実行できればよいというものに関しては、以下のように ecs-cli compose run で実行することもあります。対面で実行結果が見れないのは不便ではありますが、Cloudwatch Logs で結果は参照可能なので、ここも割り切っているという状況です。

$ VERSION=x.xx.x \
> STAGE=production \
> ecs-cli compose \
> --file compose/operation.yml \
> --ecs-params params/production/operation.yml \
> run python3 manage.py migrate

インフラ codenize と Production への展開準備

ここまでの検証で実際に production 環境を ECS 上で動かす見通しが付きました。次はここまでの検証結果を適切に production 環境に展開していきます。

一番最初に、とりあえず ECS 上で動かしてみた段階ではインフラの codenize をどうするかは「一通りやってみて必要性を感じたらやる」という温度感にしていたのですが、実際にやってみると以下の点で codenize の必要性を感じ、着手することにしました。

  • Security Group と IAM Role / Policy の設定が複雑になり、Web 画面からの操作のみでは状況の把握と適切な変更が難しくなる

ツールには Terraform を採用し、検証で実際に使った環境を codenize するところからはじめました。「suitebook のインフラリソースだけを管理すると割り切る」という形で極力シンプルになるように実装をしていきましたが、最終的に以下のようなファイル構成に落ち着きました。

 main.tf
modules/
├── alb.tf
├── cloudwatch_logs.tf
├── ecs_assume_role_policy.json
├── ecs.tf
├── eip.tf
├── elasticache.tf
├── iam_policy.tf
├── iam_role.tf
├── nat_gateway.tf
├── rds_parameter_group.tf
├── route_tables.tf
├── s3.tf
├── security_group.tf
├── subnet.tf
└── variables.tf

この構成は色々なプラクティスを調査していった結果、以下の記事が大変参考になったというより、ほぼそのまま使わせていただきました。本当に有益な情報をありがとうございました。

AWS FargateとTerraformで最強&簡単なインフラ環境を目指す — Qiita

variables.tf は staging と prodcution それぞれの情報を持つようにしています。 workspace と組み合わせた map 関数のプラクティスに関して以下の記事が大変参考になりました。ありがとうございます。

Terraform Best Practices in 2017 — Qiita

それ以外の tf ファイルの中身はシンプルに「 suitebook で使うこれ」と列挙するような形にしています。これらの codenize の際、「このリソース使ってるな」というものを空っぽの resouce で列挙して terraform import、その後に terraform plan を実行しひたすら差分を潰していくというやり方をしましたが、 Attributes を活用しながら codenize を進めることもでき、効率は良かったように思えます。

ECS 環境の Production 環境への展開も、この Terraform の code の適用で suitebook がデプロイ 可能な状態にもっていけたので、非常にスムーズになりました。

実際の Production 環境の切り替え

最終的な Production の切り替えは、DB や ElastiCache は対象ではなかったため、行ったことは以下の通り非常にシンプルです。事前に動作確認を行った上で無事に完了しました。

  • Web は事前に ECS 環境へデプロイした状態で旧環境からドメインの切り替え
  • worker は旧環境で停止後に新環境で可動開始
  • バッチは旧環境で停止 (crond の停止)後、Scheduled Task をデプロイ

さいごに

かなり駆け足ではありますが、ElasticBeanstalk 環境から ECS on Fargate な環境への移設の際に行ったことを紹介しました。

ElasticBeanstalk の話がほぼ出てこなかったあたりでおわかりいただけると思いますが、実際にやったこととしては根本的な作り直しに近い移設でした。これが無事に完了し、ホッとしているというのが正直なところです。

機能的な変化はありませんが、ECS on Fargate の環境に移設することができ、今後のプロダクトの成長にもに応えていけるインフラにすることができました。これからもまだまだ suitebook は成長していきますのでよろしくお願いいたします。

告知

SQUEEZEはこれからプロダクト、会社と一緒に成長していく仲間を募集しています。この記事や他の案内を見て、SQUEEZEに少しでも興味をもってもらえた方は下記のページをご覧ください!