はじめに

serverless framework で開発を続けていたら、 CloudFormation に詳しくなってきた自称プログラマの遠藤です。

本日は、 serverless framework を使って AWS Fargate へのデプロイに挑戦した話です。
いくつか参考になる記事は見かけたのですが、微妙にやりたいことと異なっていたり、動かしてみて分かったこともあったので、記事にしていきたいと思います。

今回は Fargate を Service で起動する際の定義と、 Scheduled Task で起動する際の定義をまとめます。

Service と Scheduled の違い

その前に、 ざっくりですが Service と Scheduled Task の違いを説明しておきます。

共通点

どちらも ECS のクラスターを使って起動するものです。
また、 Dockerイメージの CMD または ENTRYPOINT で指定したプロセスが終了するまで動き続けます。

そして、タスクを起動するルールがそれぞれ異なっています。

Service

Service はタスクの数を指定し、指定した数になるように、タスクを起動してくれます。
途中で起動したプロセスが終了した場合なども、自動で再立ち上げしてくれます。
(EC2 の Auto Scaling に近いイメージです。)

例えば、 Web サーバを起動したり、何かのプロセスを監視したりするのに向いていると思います。

Scheduled Task

Scheduled Task (AWS コンソールの日本語表記は 「タスクのスケジューリング」 ) はその名の通り起動時間(間隔)を指定して、一度だけタスクを開始します。
タスクは、起動時のプロセスが終了するまで動き続け、再起動することはありません。

定期的に実行したいバッチ処理などに向いていると思います。

逆に、うっかりこちらで Web サーバのような動き続けるプロセスを起動した場合、サーバは無限に立ち上がってしまいます。
例えば、 「1日1回起動」といった定義をしておくと、1ヶ月後には約 30 台の Web サーバが起動している状態となります。

また、多重起動を制御をする方法はなさそうなので、別途DBなどでロックを用意したり、 AWS SDK で ECS の実行中のタスクリストを見て、チェックなどを入れるのも良いかもしれません。

デプロイ

それでは、実際に serverless framework を使ったデプロイの定義をご紹介します。

共通

ECR へ DockerImage をプッシュ

Fargate を起動するにあたり、 Docker Image を ECR にプッシュしておく必要があります。
これは以前の記事CloudWatch Events + Lambda + AWS Batch という項目で紹介していますので、参考にしてください。

Service の定義

Serverless Fargate Tasks プラグインをインストール

Service で起動する場合は、サードパーティ製のプラグインが提供されていますので、こちらを利用するのが便利です。

https://www.serverless.com/plugins/serverless-fargate-tasks

プロジェクトに、npm プラグインを追加します。

$ yarn add serverless-fargate-tasks

IAM で ecsTaskExecutionRole を作成する

プラグインが、 ecsTaskExecutionRole という固定の名前の Role で Fargate
を実行します。
もし、利用している AWS アカウントに同名の Role がない場合は、作成しておきましょう。

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_execution_IAM_role.html

option で Role の指定が可能なので、別名で Role を作成した場合や、 CloudFormation テンプレートで定義する場合は、それらの ARN を指定することも可能です。

serverless.yml へ定義を追加

あとは、 serverless.yml の custom 内に、以下のように必要な項目を定義していきます。

custom:
  fargate:
    environment: # Task 起動時の環境変数
      hoge: hoge # serverless と同じ書式で環境変数を定義可能
    vpc:
      security-groups:
        - "sg-000000"
      subnets:
        - "subnet-000000"
    role: arn:aws:iam::000000000000:role/ecsTaskExecutionRole
    tasks:
      sample-task:
        name: sample-task
        environment: 
          hoge: fuga # 環境変数の上書きが可能(プラグインの仕様で、こちらを定義しないと全体で定義した環境変数も設定されない。バグっぽい。)
        image: 00000000000.dkr.ecr.{region}.amazonaws.com/sample-task:latest
        cpu: 256
        memory: 0.5GB
        override:
          container:
            # Dokcer 起動時に渡すコマンドを上書きで定義可能
            Command:
              - "python"
              - "daemon/main.py"
              - "-f"    

role の部分は CloudFormation の組み込み関数を利用して、以下のように書くことも可能です。

role:
  !Join
  - ''
  - - 'arn:aws:iam::'
    - !Ref AWS::AccountId
    - ":role/ecsTaskExecutionRole"

良いところ

プラグインを使わなくても、 CloudForamtion テンプレートで定義することも可能ですが、このプラグインの良いところは、 Lambda 用に定義している環境変数を使いまわせる点にあります。

どういうことかというと、 Lambda 用の環境変数は以下の書式で定義します。

provider:
  name: aws
  environment: 
    Key1: Value1
    Key2: Value2

AWS::Lambda::Function Environment

一方で、 ECS で実行するコンテナの環境変数は以下の書式で記載します。

Environment:
  - { Name: Key1, Value: Value1 }
  - { Name: Key2, Value: Value2 }

AWS::ECS::TaskDefinition KeyValuePair

わざわざ serverless framework でデプロイするのは、作成したリソースを共通で使いたかったりする目的があります。
そう言った意味で、このプラグインは、書式の差分を丸めてくれるので、素晴らしいと思います。

他にも設定できる項目がありますが、 Readme より、 コードを見た方が、個人的には分かりやすかったです。

なお、コードを見るとわかりますが、このプラグインでは、 Service の定義しかしてくれません。
Scheduled Task で Fargate を起動したい場合は、残念ながら自分で CloudFormation の定義をする必要があります。

Scheduled Task の定義

serverless.yml へ定義を追加

それでは、必要なリソースを1つずつ定義していきます。

タスクロール

起動したタスクがどのような権限で動くか定義します。
Fargate を起動させるには、 AssumeRolePolicyDocument で ecs-tasks.amazonaws.com に対して AssumeRole できるようにしておく必要があるようです。

FargateRole:
  Type: AWS::IAM::Role
  Properties:
    RoleName: fargate-sample-role
    AssumeRolePolicyDocument:
      Version: '2012-10-17'
      Statement:
        - Sid: AssumeRolePolicy
          Effect: Allow
          Principal:
            Service:
              - lambda.amazonaws.com
              - ecs-tasks.amazonaws.com
              - events.amazonaws.com
          Action: sts:AssumeRole
    Path: /
    ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceEventsRole

LogGroup

Fargate の実行結果を出力する CloudWatch のロググループAWS::Logs::LogGroupを作成します。

FargateLogGroup:
  Type: AWS::Logs::LogGroup
  Properties:
    LogGroupName: FargateSampleClusterLog

Cluster

Fargate を実行するクラスタAWS::ECS::Clusterを作成します。

FargateCluster:
  Type: AWS::ECS::Cluster
  Properties:
    ClusterName: FargateSampleCluster

TaskDefinition

起動するタスクAWS::ECS::TaskDefinitionを定義します。

FargateSampleTaskDefinition:
  Type: AWS::ECS::TaskDefinition
  Properties:
    ContainerDefinitions:
      - Command:
          - python
          - sample.py
          - hogehoge
        Name: FargateSampleTaskDefinition
        Image: 00000000000.dkr.ecr.{region}.amazonaws.com/sample-task:latest
        Environment: # 環境変数の設定
          - { Name: name, Value: FargateTestCluster }
          - { Name: hoge, Value: hoge }
        LogConfiguration:
          LogDriver: awslogs
          Options:
            awslogs-region: ap-northeast-1
            awslogs-group: !Ref FargateLogGroup # 上記で定義したロググループを参照
    Cpu: 256
    ExecutionRoleArn: arn:aws:iam::000000000000:role/ecsTaskExecutionRole
    Family: FargateSampleTaskDefinition
    Memory: 512
    NetworkMode: awsvpc
    TaskRoleArn: !Ref FargateRole # 上記で定義したタスクロールを参照

Rule

起動ルールAWS::Events::Ruleを作成します。

ECSSchedule:
  Type: AWS::Events::Rule
  Properties:
    State: ENABLED
    Targets:
      - Id: ScheduledName
        RoleArn: !GetAtt FargateRole.Arn
        EcsParameters:
          TaskDefinitionArn: # 上記で定義した TaskDefinition の ARN を参照
            Fn::GetAtt:
              - FargateSampleTaskDefinition
              - TaskDefinitionArn
          TaskCount: 1
          LaunchType: FARGATE
          NetworkConfiguration:
            AwsVpcConfiguration:
              AssignPublicIp: ENABLED
              SecurityGroups:
                - sg-00000000
              Subnets:
                - subnet-00000000
        Arn:
          Fn::GetAtt:
            - FargateCluster # 上記で定義した Cluster を参照
            - Arn
    ScheduleExpression: cron(* * * * ? *)

全体

これらを yml の resources 配下に以下のように指定します。

resources:
  - Resources:
      # タスクロール
      FargateRole:
        ...
      # ログの出力先
      FargateLogGroup:
        ...
      # クラスター定義
      FargateCluster:
        ...
      # タスク定義
      FargateTestTaskDefinition:
        ...
      # 定期実行ルール
      ECSSchedule:
        ...

Env の読み込みは?

以上でデプロイすれば、 Fargate を起動させることはできますが、先でもお話ししたように Environment の定義が課題です。

そこで、 Lambda 用の env を別ファイルにしておいて、 python の起動時に読み込ませるという方法が取れます。

env.yml を用意

STAGE: dev

HOGE_NAME: hoge
FUGA_NAME: fuga

lambda は env.yml を参照する

以下は、 serverless.yml に記載する

provider:
  name: aws
  region: ap-northeast-1
  environment: ${file(./env/env.yml)}

Docker は image に env ファイルをコピー

ECR にプッシュする Dockerfile に、以下を記載する

RUN pip install PyYAML
COPY env/env.yml env.yml

python 起動時に環境変数をセットする

Docker の Command で起動する python の先頭で env ファイルを読み込んで環境変数にセットしてしまう

import yaml
f = open("env.yml")
data = yaml.load(f, Loader=yaml.SafeLoader)
for key in data:
    os.environ[key] = str(data[key])

対応できないケースがある

基本的には以上の内容で、問題ないのですが、環境変数の読み込みで対応できないケースがあります。
それは、 CloudFormation の組み込み関数を利用している場合です。

たとえば、作成したリソースの ARN を環境変数にセットしたい場合、以下のように書くことができます。

HOGE_ARN: 
  - Fn::GetAtt:
    - HOGE
    - Arn
# または以下の書式
HOGE_ARN: !GetAtt HOGE.Arn

これらは、デプロイ時に自動的に補完されるのですが、当然、 Docker のビルドでは補完されず、環境変数には !GetAtt HOGE.Arn という文字列が登録されることになっていまいます。

現状、これを回避できないケースには遭遇していないので、助かっています。

感想など

以上、 Fargate の起動方法別に、定義をご紹介いたしました。

Lambda は便利なのですが、起動時間に制約があったり、デプロイ可能なファイルサイズに上限があったり、どうしても使えないケースもあるかと思います。
そんな時に、既存のコードを使いまわしながら、一部を Fargate に持っていく、という選択肢が取れると実装の幅が広がるのではないかと思いました。

Scheduled Task については、プラグイン化されていないこともあり、 Service と比べると記載ボリュームが増えてしまったり、いくつかの課題が残っています。

というわけで、 Scheduled Task に関しては、近いうちにカスタムプラグインの作成を試したいなと思っています。
うまく実装できたら、また記事にしていきたいと思います。