メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

メドピアのECSデプロイ方法の変遷

f:id:satoshitakumi:20201118153720p:plain

CTO室SREの侘美です。好きなLinuxディストリビューションはLinux Mintです。

メドピアでは現在多数のサービスを運用しており、そのほとんどがAmazon ECSを構成の中核として利用しています。

ECSに対してデプロイを行う方法としては、CodeDeploy、CodePipeline、Copilot(ecs-cli)等があり、CloudFormationやTerraform等のIaCツールで何をどこまで管理するかも合わせて検討する必要があります。 どの方法にもメリット・デメリットがあり、Twitterや技術ブログを観測している範囲ではデファクトスタンダードと呼べる方法は未だに無いように思われます。

メドピアで最初にECSを利用し始めたのは2018年ころであり、これまで試行錯誤しながらECSのデプロイ方法とタスク定義の管理方法を模索してきました。 今回はメドピア社内で試してきた、ECSへのデプロイ方法とその課題や解決方法をご紹介したいと思います。

前提

各デプロイ方法に共通する運用は次の通りです。

  • インフラはTerraformでコード化
  • RailsとTerraformでGitリポジトリは別
  • Railsはサービス担当のエンジニアが実装
    • SREが実装を変更する場合もある
  • Terraformは横断して複数のサービスを担当するSREが実装

1. ecs-cli

社内でECSを採用し始めた当初はecs-cliを利用していました。 現在は後継のCopilotというツールがリリースされています。

ecs-cliの公式ドキュメントでは次のように紹介されており、簡単にECSを構築することが売りのツールです。

ローカルの開発環境からクラスタやタスクの作成、更新、監視を簡素化するための高レベルのコマンドを提供します。

メドピアでは、ecs-cliを使い次のようなデプロイパイプラインを構築していました。

f:id:satoshitakumi:20201117180938p:plain
ecs-cliによるデプロイ

  • CodePipelineでデプロイパイプラインを構築
  • リリース対象ブランチの場合CircleCIからCodePipelineをスタート
  • CodeBuild上でCapistranoでイメージビルド
  • CodeBuild上でCapistrano + ecs-cliでデプロイ
  • 上記Capistranoのタスクやecs-cliで利用するタスク定義用のファイルはRails側リポジトリで管理

ecs-cliによるデプロイの課題

この構成でいくつかサービスを運用していくうちに以下の課題があがってきました。

  • ecs-cliの仕様で一部ECSの機能を利用できない。
    • ECSサービスを複数のTargetGroupに所属させることができない。(現在は複数のTargetGroupに対応しています)
  • タスク定義の管理がRails側リポジトリで行われている。
    • タスクのパラメータ変更等をSREが実装する際に、Rails側リポジトリにPRを作成しRails側リポジトリのリリースフローに則る必要がある。
    • →RailsエンジニアとSREの責任の境界が曖昧になってしまっていた。
  • ALBやECSタスクに付与するIAMロール名やSSMパラメータストアのパラメータ名など、Terraform側リポジトリで管理されているリソースのARN等をRails側リポジトリにハードコードしなければならない。

そこで、これらの課題を解決する構成として、次のCodePipelineによるECSへのデプロイへと移行することにしました。

2. CodePipeline

ecs-cliの利点はあくまでも「簡単にECSを管理できる」というものであり、Terraformを利用して細部まで管理を実施したい場合には不向きと判断しました。

ECSに関する設定の大半をRails側リポジトリで管理していた従来の構成を見直し、それぞれのリポジトリで管理する対象を次のように変更しました。

  • Rails側リポジトリ
    • Dockerfile
  • Terraform側リポジトリ
    • デプロイパイプラインと各CodeBuildで実行するスクリプト
    • ECSタスク定義

CodePipelineは通常CodeBuildやCodeDeployと組み合わせてデプロイパイプラインを構築するサービスですが、CodePipeline自体もECSへデプロイする機能を持っています。(よくCodeDeployを利用していると勘違いされがちです)

CodePipelineによるECSへのデプロイの特徴としては、CodeDeployよりも簡単な代わりに、機能はCodeDeployよりも少ないといった感じです。
参考:https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-ECS.html

terraformの実装例

resource "aws_codepipeline" "deploy" {

  # 他の設定項目は省略

  stage {
    name = "Build"

    action {
      name             = "BuildRails"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["input"]
      version          = "1"
      output_artifacts = ["imagedefinitions"]

      configuration = {
        ProjectName = aws_codebuild_project.rails.name
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name            = "DeployApp"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ECS"
      input_artifacts = ["imagedefinitions"]
      version         = "1"

      configuration = {
        ClusterName = aws_ecs_cluster.app.name
        ServiceName = aws_ecs_service.app.name
        FileName    = "imagedefinitions.json"
      }
    }
  }
}

ビルドステージでは、イメージビルドを行いECRにイメージをpushし、 imagedefinitions.json というファイルを作成しデプロイパイプラインのアーティファクトに登録します。

デプロイステージでは上記の imagedefinitions.json を指定することで、既存タスク定義を流用しイメージのみを新しいイメージに変更したタスク定義のリビジョンが作成され、デプロイされます。

ecs-cliでのデプロイと同様の図で表すと以下のようになります。

f:id:satoshitakumi:20201117180949p:plain
CodePipelineによるデプロイ

このような実装にすることでシンプルにECSへデプロイを行うパイプラインを構築できます。

テクニック:TerraformとCodePipelineの両方からタスク定義を更新可能にする

CodePipelineでECSへのデプロイを行うと、ECSタスク定義に新しいリビジョンが追加されます。 このリビジョンは1つ前のリビジョンのタスク定義を、CodePipelineで指定した imagedefinitions.json に記載されたイメージで置き換えた設定となります。

# デプロイ前のタスク定義のリビジョン
{
  "family": "prd-rails",
  "cpu": "1024",
  "memory": "2048",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "元のイメージのURL",
      "environments": [...略]
    }
  ]
}

# これに↓のimagedefitions.jsonを使ってCodePipelineでデプロイする

# imagedefinitions.json
{
  "name": "web",
  "imageUrl": "新しいイメージのURL"
}

# すると、imageUrl以外が同じである次のようなタスク定義のリビジョンが追加される

# デプロイ時に追加されるタスク定義のリビジョン
{
  "family": "prd-rails",
  "cpu": "1024",
  "memory": "2048",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "新しいイメージのURL",
      "environments": [...略]
    }
  ]
}

このように、イメージ以外はデプロイ時に変更することができません。 そのため、CPUやメモリ、各コンテナの環境変数等はterraformで変更する必要があります。

terraformはtfstateに現在のリソースのARN等を記録し、コードとリソースの関係を保持しています。 そのため普通に実装してしまうと「デプロイするたびに新しいタスク定義のリビジョンが作成され、tfstateとAWS上のリソースに乖離が発生する」という問題を引き起こしてしまいます。

これを解決する実装方法が、terraformの公式ドキュメント中にかかれています。
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ecs_task_definition

resource "aws_ecs_service" "mongo" {
  name          = "mongo"
  cluster       = aws_ecs_cluster.foo.id
  desired_count = 2

  # Track the latest ACTIVE revision
  task_definition = "${aws_ecs_task_definition.mongo.family}:${max(aws_ecs_task_definition.mongo.revision, data.aws_ecs_task_definition.mongo.revision)}"
}

ECSサービスに指定するタスク定義を data を用いて最新のタスク定義を参照することで、CodePipelineでデプロイ時に作成された最新のタスク定義を参照することができます。

また、terraform側でタスク定義のメモリ等の設定を変更した際は、terraform apply時に新たにタスク定義のリビジョンが作成されます。 data で取得した存在する最新のリビジョンと、メモリの変更でこれから作成されるリビジョンをmax関数にわたすことで、以下を実現できます。

  • terraform側のタスク定義に変更なし
    • CodePipelineがデプロイしたリビジョンが最新なので、apply時はそのまま最新のリビジョンが使われる
  • terraform側でタスク定義の属性を変更
    • terraformがこれから作成するリビジョンが最新なので、作成されたリビジョンで置き換えられる

この実装を利用することで、メモリを変更し terraform apply を実行すれば、新しいタスク定義のリビジョンが作成され、それがECSサービスに指定されることで新しいタスクに入れ替わります。

CodePipelineによるデプロイの課題

ecs-cliを利用していた際の課題はだいぶ解決できたのですが、CodePipelineを利用したECSへのデプロイにも課題がありました。

CodePipelineによるECSへのデプロイはローリングアップデートで行われます。 Rails等のassetsファイルをハッシュ付きで生成し配信するWebアプリケーションの場合、ローリングアップデートを行うと、アップデート時に404エラーが確立で発生してしまいます。

f:id:satoshitakumi:20201117183217p:plain
ローリングアップデート時に404エラーが発生する仕組み

メドピア AWS勉強会 ECS編より

この課題を解決する方法はいくつかありますが、ロールバック方法まで含めて検討した結果、次のCodeDeployによるデプロイを採用することとなりました。(CodeDeploy以外の案に興味がある方は上記勉強会資料をご参照ください)

3. CodeDeploy

CodeDeployの ECSAllAtOnce デプロイ設定を利用することで、ローリングアップデートではなく、Blue/Greenデプロイメント方式でECSタスクを更新することができます。
参考:https://docs.aws.amazon.com/codedeploy/latest/userguide/deployment-configurations.html

また、CodeDeployのロールバック機能を利用すればシンプルな操作でECSタスクのロールバックを実施することも可能です。

CodeDeployを利用した場合のデプロイパイプラインは次のようになります。

f:id:satoshitakumi:20201117183711p:plain
CodeDeployによるデプロイ

ちなみに、CodeDeployによるECSへのデプロイの場合はデプロイ毎に完全なタスク定義の設定をCodeDeployにわたすので、前述したようなテクニックを利用してTerraformとデプロイによるタスク定義更新を考慮する必要はなくなります。
タスク定義はTerraformの aws_s3_bucket_object リソースを使いTerraform側のリポジトリでテンプレートファイルとして管理しています。ビルドステージのCodeBuild上でS3からタスク定義のテンプレートを取得し、ビルドしたDockerイメージのURLを埋め込み、アーティファクトとして次のステージのCodeDeployへ伝える実装としています。

いろいろとデプロイ方法、タスク定義の管理方法を試してきましたが、結局はスタンダードなCodeDeployの利用に落ち着きました。

今後の改善予定

現状のデプロイにもいくつか課題があるため、まずは下記の点を改善していこうと計画しています。

SREとRailsエンジニアの責任境界の明確化

現状ではDockerfileをSREが用意し、CodeBuild上で実行するイメージビルドコマンドもSREがメンテしています。 DockerfileとDockerイメージビルドはRailsエンジニア管理に変更し、SREとRailsエンジニアの責任の境界を下記のように変更したいと考えています。

  • Railsエンジニア:Dockerイメージをビルドし、pushするまで
  • SRE:Docker Registryにpushされたイメージをデプロイする

PRのCIでイメージビルドを行いデプロイパイプラインを短縮

現在イメージビルドに約10分程度時間がかかっています。 デプロイパイプラインをさらに短縮するため、リリースPRの段階でイメージビルドをCI上で実行できないか検討しています。

これらの改善がある程度進んだ後には、また本ブログで内容を公開したいと思います。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html