こんにちは、インフラエンジニアの川島です。
ECSの自動停止・自動起動をCloudFormationで作成したので紹介したいと思います。
以前Auroraの自動停止・自動起動をCloudFormationで作成したブログをあげたのですが、実際に開発環境で実装したところむしろコストが増加してしまいました。
何が起きていたのか原因を探ってみると、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ファイルとして保存し、コンソール画面からアップロードし、パラメータに適切な値を入力します。
ProjectName
とEnvCode
は任意の値を入力します。
EventBridgeState
は自動停止を有効化する場合はENABLED
、無効化する場合はDISABLED
を選択します。
StartSchedule
とStopSchedule
にはcron式かつUTCで値を入力します。(例 : cron(15 13 ? * MON *)
)
cron 式と rate 式を使用して Amazon EventBridge でルールをスケジュールする - Amazon EventBridge
まとめ
今回はタグで停止対象を判別するECSの自動停止・起動を紹介いたしました。
停止対象を直接指定していないため、新しいECSのサービスを作成した時にもタグを追加するだけで自動停止・起動の対象とする事ができます。
この記事がどなたかのお役に立てば幸いです。