RAKSUL TechBlog

RAKSULのエンジニアが技術トピックを発信するブログです

ECS Fargate による Web アプリケーションを AWS CDK で構築し、GitHub Actions でデプロイしたときに悩んだ箇所を紹介

はじめに

ECS Fargate による Web アプリケーションを AWS CDK で構築し、GitHub Actions でデプロイするシステムを作る際、悩んだ箇所とその解決策を紹介します。

構成概要

AWS CDK で各種 AWS リソースを作成し、resources 配下の Dockerfile を build し、デプロイをするという構成となっています。

下記はディレクトリ構成です。

├── resources/
    ├── workflows/
        └── deploy_prod.yml
        └── deploy_qa.yml
├── cdk/
└── resources/
    └── lnginx/
    └── laravel/
    └── Dockerfile-laravel
    └── Dockerfile-nginx

デプロイは GitHub Actions で行なっています。

GitHub Actions に CDK の実行権限を付与するために OIDC を利用しています。

CDK 内で OIDC を付与したロールを作成し、そのロールを GitHub Actions のワークフローで使用しています。 そのため、初回の CDK デプロイはローカル環境で行なっています。

悩んだところ

DB Migration の実行順序

課題

アプリケーションの DB は AWS Aurora を使用しています。 AWS VPC 内からしか DB にアクセスできないため、GitHub Actions 上で Migration はできません。

そのため、ECS スタンドアロンタスクで Migration を行うことにしました。 スタンドアロンタスクとは ECS Service を使用せずに実行する ECS Task のことでバッチ処理などに使われます。 ECS Task は VPC 内にあるため DB に接続することができます。

最初は CDK で Migration 用のタスクを実行しようとしました。

// まず DB Migration の実行
const runTaskAtOnce = new RunTask(stack, 'RunDemoTaskOnce', { migrationTaskDef });

// 次に Service の更新
const service = new ecs.FargateService(this, 'id', {cluster, serviceTaskDef});

※ スタンドアロンタスクの実行にはこちらのライブラリを使用

https://github.com/pahud/cdk-fargate-run-task

しかし、上手くいきませんでした。DB Migration の完了後に Service を更新(ソースコードの反映)して欲しかったのですが、Service の更新が開始されたあとに DB Migration 用のスタンドアロンタスクが立ち上がってしまいました。

CDK でスタンドアロンタスクを立ち上げるためには AWS CloudFormation のカスタムリソースを使用する必要があり、上記ライブラリでもそのようにしています。

CDK をデプロイした際に、CDK 上のコードではスタンドアロンタスクの記述が先にあるのでカスタムリソースの実行が Service の更新よりも先に開始はされるのですが、カスタムリソースによるスタンドアロンタスクのプロビジョニングは非同期で行われるため、Service の更新が先に完了したという状況でした。

CDK には EcsRunTask というクラスがあり、いかにもスタンドアロンタスクを実行してくれそうではあるのですが、このクラスは ECS Task を実行する AWS Step Functionsを作成してくれるだけで、ECS Task の実行まではしてくれません。

EcsRunTask で作成された Step Functions を実行するためにはやはりカスタムリソースを使用するしかなく、上述した同じ問題に直面します。

解決策

CDK でのデプロイをやめて GitHub Actions でスタンドアロンタスクを実行することにしました。 GitHub Actions は step 内の処理の完了後に次 step に進むため、それぞれの step で DB Migration と Service の更新をすることで、順番を担保しました。

step Run ECS db migration task の内部では AWS CLI の ecs run task を使用してタスクを実行しています。

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Run ECS db migration task
        uses: yyoshiki41/ecs-run-task-action@v0.0.7
        with:
          task-definition: ./db-migration-task-definition.json
          command: '["sh", "-c", "migrate"]'

      - name: ECS Service Deploy
        run: |
          npm run cdk:deploy

※ GitHub Actions 上でのスタンドアロンタスクの実行にはこちらのライブラリを使用 https://github.com/yyoshiki41/ecs-run-task-action

その後の課題

GitHub Actions で DB Migration を実行することで課題も出てきました。それはECS Task の設定値の記載が重複することです。

ECS Task を実行するためにはタスク定義の情報が必要であり、DB Migration 用のタスク定義情報は json ファイルとして保持し、ECS Service 用のタスク定義情報は CDK コード内に記載があります。

そのため、Migration タスクと Service タスクで設定値に変わりがない環境変数とシークレットの記載が重複しています。 これにより環境変数を追加するときに全く同じ記載を CDK のコードと json ファイル、両方にしなくてはなりません。

このままでも環境変数は env file を使用することで共通化することはできますが、シークレットには env file のような仕組みがないため json ファイルと CDK コードでは共通化する方法がありません。

そのため、GitHub Actions 上でのスタンドアロンタスクの実行をやめて ECS Service の反映とは別の DB Migration を実行するスタックを作成し、そのスタックを ECS Service より前の GitHub Actions step で実行する改修を入れる予定です。

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Run ECS db migration task
        run: |
          npm run cdk:deploy:migration

      - name: ECS Service Deploy
        run: |
          npm run cdk:deploy:service

このようにすることでシークレットを CDK 内のプログラミングコードのモジュールとして共通化できるためです。

デメリットとしては2つのスタック間での AWS リソースの共有がしにくいことですが、今回のケースではVPCやサブネット、セキュリティグループは既存の同一のものを利用しているため問題ありません。

スタック内で作成したものではない既存の ALB のリスナー設定を使用して ECS Service を作成する

課題

CDK で ALB を使用して ECS Service を作成するときには ECS Patterns という便利なモジュールが用意されています。

const loadBalancedFargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
  cluster,
  memoryLimitMiB: 1024,
  cpu: 512,
  taskImageOptions: {
    image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
    command: ['command'],
    entryPoint: ['entry', 'point'],
  },
});

loadBalancedFargateService.targetGroup.configureHealthCheck({
  path: "/custom-health-path",
});

このモジュールを利用すると ALBやターゲットグループ、タスク定義をまとめて作成してくれます。

しかし今回の要件ではターゲットグループは既存のALBリソースを使用するがターゲットグループは CDK で作成するというものでした。 またリスナーも新規作成せずに既存の物を使います。

既存のリスナーを取得しそれにターゲットグループを紐付けようとしても、スタック内で作成したALBのリスナーしか設定変更ができないため失敗してしまいました。

const listener = ApplicationListener.fromLookup()
listener.addTargets('id', {port: 8080,targets: [tg]});

解決策

リスナーとCDKで作成されたタッゲートグループの紐付けは手動で行い、ECS Service にターゲットグループを紐付けることで実現できました。

service.attachToApplicationTargetGroup(targetGroup)

attachToApplicationTargetGroup メソッドには「Don't call this function directly」と記載もあるため、できる限りALBはスタック内で作成するのが CDK のベストプラクティスには合っていそうです。

タスクに登録されている別コンテナのデータにアクセスしたい

課題

今回タスクには n=Nginx と Laravel のコンテナを登録しました。 Nginx の設定上、Laravel のイメージ内で build した javascript のファイルにアクセスできるようにする必要がありました。

Laravel で使用する Vuejs の設定の関係上、javascript ファイルを build するためには npm のインストールだけではなく、PHP、composer のインストールも必要であり、Nginx のイメージでも Laravel のイメージと同じように下記のようなインストールを行ってしまいました。

Nginx Dockerfile 例

RUN apt-get update \
&& apt install -y lsb-release ca-certificates apt-transport-https software-properties-common gnupg2
RUN echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/sury-php.list
RUN curl -fsSL  https://packages.sury.org/php/apt.gpg| gpg --dearmor -o /etc/apt/trusted.gpg.d/sury-keyring.gpg
RUN apt update && apt install -y php8.1 php8.1-dom php8.1-mbstring php8.1-curl php8.1-zip php8.1-gd

RUN composer update && composer install --optimize-autoloader --no-dev \
&& yarn install && yarn build

EXPOSE 80

解決策

Laravel コンテナのファイルをボリュームとして共有することで、Nginx イメージでは PHP のインストールや javascript の build をする必要がなくなりました。

Laravel Dockerfile

VOLUME ["/application/public"]

CDK

nginxContainer.addVolumesFrom({sourceContainer: laravelContainer.containerName, readOnly: true})

おわりに

参考になれば幸いです。