タグで停止対象を判別するECSの自動停止・起動をCloudFormationで作成してみた

こんにちは、インフラエンジニアの川島です。

ECSの自動停止・自動起動をCloudFormationで作成したので紹介したいと思います。

以前Auroraの自動停止・自動起動をCloudFormationで作成したブログをあげたのですが、実際に開発環境で実装したところむしろコストが増加してしまいました。

engineers.fenrir-inc.com

何が起きていたのか原因を探ってみると、Configのコストが跳ね上がっていると判明しました。

Configのコストがなぜ上がったのかと調べたところ下記の内容が原因でした。

  • Aurora停止中にECSのコンテナが起動していたこと

  • タスクがヘルスチェック時にAuroraに対してPingし失敗すると5xxを返す実装になっていたこと

  • ECSサービスをALBに紐づけていたこと

ECSのコンテナが起動 → Auroraが起動していないためヘルスチェックが通らない → コンテナ再起動 →ヘルスチェックを実行し通らないためコンテナ再起動

という形でAuroraが停止していることでコンテナが再起動を延々と繰り返していました、これだけならば問題ないのですが、Configが上記を継続的に記録していたためConfigのコストが上昇していました。

これを受けてAuroraの自動停止だけではなく、ECSの自動停止も必要だと感じたため本テンプレートの作成を行いました。

全体像

今回構築するアーキテクチャとしては下記になります。

EventBrdigeが定期起動し、LambdaがECSの停止・起動のAPIを叩く事で自動停止・起動を実装しています。

本手法はECSサービスに付けられているタグによって停止対象を判別している事が特徴的です。

そのため、停止対象が増減した場合にもECSサービスのタグを変更するだけでよく、他の手法と比べて手軽に設定することが可能となっています。

設定するパラメータと作成するリソースとしては下記になります。

設定するパラメータ 内容
ProjectName 任意の値
EnvCode 任意の値 (dev, stg, …)
EventBridgeState 自動停止・起動の有効、無効 (ENABLED, DISABLED)
StartSchedule 自動起動時刻 (cron式でUTC時刻で設定)
StopSchedule 自動停止時刻 (cron式でUTC時刻で設定)
作成するリソース リソース名
Lambda用Role LambdaRole
起動Lambda ECSAutoStartLambda
起動Lambda用リソースベースポリシー PermissionForEventsToInvokeStartLambda
起動EventBridge ECSStartEventRule
停止Lambda ECSAutoStopLambda
停止Lambda用リソースベースポリシー PermissionForEventsToInvokeStopLambda
停止EventBridge ECSStopEventRule

テンプレート

AWSTemplateFormatVersion: 2010-09-09
Description: "Create ECSAutoStop template"
Parameters:
  ProjectName:
    Description: "Project name"
    Type: "String"
  EnvCode:
    Description: "Environment type"
    Type: "String"
  EventBridgeState:
    Description : "EventBridge state"
    Type: "String"
    AllowedValues:
      - "ENABLED"
      - "DISABLED"
  StartSchedule:
    Description: "Start ECS schedule (cron UTC)"
    Type: "String"
  StopSchedule:
    Description: "Stop ECS schedule (cron UTC)"
    Type: "String"

Resources:
  ECSAutoStartLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import boto3
          import json

          def lambda_handler(event, context):
              ecs = boto3.client('ecs')

              try:
                  # ECSクラスターARNをリストアップ
                  response = ecs.list_clusters()
                  cluster_arns = response['clusterArns']

                  for cluster_arn in cluster_arns:
                      # クラスターの詳細を取得
                      response = ecs.describe_clusters(clusters=[cluster_arn])
                      cluster = response['clusters'][0]
                      cluster_name = cluster['clusterName']

                      # ECSサービスをリストアップ
                      response = ecs.list_services(
                          cluster=cluster_arn,
                          launchType='FARGATE'
                      )
                      service_arns = response['serviceArns']

                      for service_arn in service_arns:
                          # サービスの詳細を取得 (タグを含む)
                          service_description = ecs.describe_services(
                              cluster=cluster_arn,
                              services=[service_arn],
                              include=['TAGS']
                          )['services'][0]

                          service_name = service_description['serviceName']
                          tags = service_description.get('tags', [])

                          auto_start_tag = next((tag for tag in tags if tag['key'] == 'autostart'), None)
                          if auto_start_tag and auto_start_tag['value'] == 'yes':
                              desired_count = 1
                              desired_count_tag = next((tag for tag in tags if tag['key'] == 'desiredcount'), None)
                              if desired_count_tag:
                                  try:
                                      desired_count = int(desired_count_tag['value'])
                                  except ValueError:
                                      log_message = {
                                          "level": "WARNING",
                                          "message": f"Invalid desiredcount value for service: {service_name} in cluster: {cluster_name}. Using default value 1.",
                                          "cluster_name": cluster_name,
                                          "service_name": service_name
                                      }
                                      print(json.dumps(log_message))

                              ecs.update_service(
                                  cluster=cluster_arn,
                                  service=service_arn,
                                  desiredCount=desired_count
                              )
                              log_message = {
                                  "level": "INFO",
                                  "message": f"Service '{service_name}' in cluster '{cluster_name}' started with desired count: {desired_count}",
                                  "cluster_name": cluster_name,
                                  "service_name": service_name,
                                  "desired_count": desired_count
                              }
                              print(json.dumps(log_message))

                  return {
                      'statusCode': 200,
                      'body': 'ECS services started successfully'
                  }

              except Exception as e:
                  log_message = {
                      "level": "ERROR",
                      "message": f"Error: {e}",
                      "error": str(e)
                  }
                  print(json.dumps(log_message))
                  return {
                      'statusCode': 500,
                      "body": json.dumps(log_message)
                  }
      FunctionName: !Sub "${ProjectName}-${EnvCode}-ecs-auto-start-function"
      Handler: index.lambda_handler
      Runtime: python3.13
      Role: !GetAtt LambdaRole.Arn
      Timeout: 60

  ECSAutoStopLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import boto3
          import json

          def lambda_handler(event, context):
              ecs = boto3.client('ecs')

              try:
                  # ECSクラスターARNをリストアップ
                  response = ecs.list_clusters()
                  cluster_arns = response['clusterArns']

                  for cluster_arn in cluster_arns:
                      # クラスターの詳細を取得
                      response = ecs.describe_clusters(clusters=[cluster_arn])
                      cluster = response['clusters'][0]
                      cluster_name = cluster['clusterName']

                      # ECSサービスをリストアップ
                      response = ecs.list_services(
                          cluster=cluster_arn,
                          launchType='FARGATE'
                      )
                      service_arns = response['serviceArns']

                      for service_arn in service_arns:
                          # サービスの詳細を取得 (タグを含む)
                          service_description = ecs.describe_services(
                              cluster=cluster_arn,
                              services=[service_arn],
                              include=['TAGS']
                          )['services'][0]

                          service_name = service_description['serviceName']
                          tags = service_description.get('tags', [])

                          auto_stop_tag = next((tag for tag in tags if tag['key'] == 'autostop'), None)
                          if auto_stop_tag and auto_stop_tag['value'] == 'yes':
                              ecs.update_service(
                                  cluster=cluster_arn,
                                  service=service_arn,
                                  desiredCount=0
                              )
                              log_message = {
                                  "level": "INFO",
                                  "message": f"Service '{service_name}' in cluster '{cluster_name}' stopped",
                                  "cluster_name": cluster_name,
                                  "service_name": service_name
                              }
                              print(json.dumps(log_message))

                  return {
                      'statusCode': 200,
                      "body": json.dumps({"message": "ECS services stopped successfully"})
                  }

              except Exception as e:
                  log_message = {
                      "level": "ERROR",
                      "message": f"Error: {e}",
                      "error": str(e)
                  }
                  print(json.dumps(log_message))
                  return {
                      'statusCode': 500,
                      "body": json.dumps(log_message)
                  }
      FunctionName: !Sub "${ProjectName}-${EnvCode}-ecs-auto-stop-function"
      Handler: index.lambda_handler
      Runtime: python3.13
      Role: !GetAtt LambdaRole.Arn
      Timeout: 60

  LambdaRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Principal:
              Service:
                - lambda.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ECSStartStopPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - ecs:ListClusters
                  - ecs:DescribeClusters
                  - ecs:ListServices
                  - ecs:DescribeServices
                  - ecs:UpdateService
                  - ecs:ListTagsForResource
                Resource: "*"

  PermissionForEventsToInvokeStartLambda:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref ECSAutoStartLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt  ECSStartEventRule.Arn

  ECSStartEventRule:
    Type: "AWS::Events::Rule"
    Properties:
      Name: !Sub "${ProjectName}-${EnvCode}-ecs-start-rule"
      Description: "Push ECSStartLambda Batch event rule"
      ScheduleExpression: !Ref StartSchedule
      State: !Ref EventBridgeState
      Targets: 
        - Arn: !GetAtt ECSAutoStartLambda.Arn
          Id: StartLambdaFunction

  PermissionForEventsToInvokeStopLambda:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref ECSAutoStopLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt ECSStopEventRule.Arn

  ECSStopEventRule:
    Type: "AWS::Events::Rule"
    Properties:
      Name: !Sub "${ProjectName}-${EnvCode}-ecs-stop-rule"
      Description: "Push ECSStopLambda Batch event rule"
      ScheduleExpression: !Ref StopSchedule
      State: !Ref EventBridgeState
      Targets: 
        - Arn: !GetAtt ECSAutoStopLambda.Arn
          Id: StopLambdaFunction

実装方法

対象ECSにタグを設定する

停止対象のECSのサービスにタグ autostart:yes, autostop:yes を設定する。

CloudFormationでリソースを作成する

上記テンプレートをYAMLファイルとして保存し、コンソール画面からアップロードし、パラメータに適切な値を入力します。

ProjectNameEnvCodeは任意の値を入力します。

EventBridgeStateは自動停止を有効化する場合はENABLED、無効化する場合はDISABLEDを選択します。

StartScheduleStopScheduleにはcron式かつUTCで値を入力します。(例 : cron(15 13 ? * MON *))

cron 式と rate 式を使用して Amazon EventBridge でルールをスケジュールする - Amazon EventBridge

まとめ

今回はタグで停止対象を判別するECSの自動停止・起動を紹介いたしました。

停止対象を直接指定していないため、新しいECSのサービスを作成した時にもタグを追加するだけで自動停止・起動の対象とする事ができます。

この記事がどなたかのお役に立てば幸いです。