📖

Googleカレンダーに合わせてECSサービスをスケールする

に公開

プラットフォームチームの菅原です。

Googleカレンダーのイベントに合わせてECSサービスをスケールする仕組みを作ったので紹介します。

従来のスケール方法について

バンドルカードなどのカンムのサービスはAmazon ECSでコンテナとして動いており、負荷状況に合わせて自動的にスケールするように設定しています。

たとえばターゲットトラッキングスケーリングポリシーを設定してCPU使用率を一定に保つようにしたり

resource "aws_appautoscaling_policy" "api" {
  name               = "scale_out"
  policy_type        = "TargetTrackingScaling"
  service_namespace  = aws_appautoscaling_target.api.service_namespace
  resource_id        = aws_appautoscaling_target.api.resource_id
  scalable_dimension = aws_appautoscaling_target.service_api.scalable_dimension

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }

    target_value      = 60
    scale_in_cooldown = 300
  }
}

アクセス増が想定される月末にスケールアウトをスケジュールするようにしています。

resource "aws_appautoscaling_scheduled_action" "api" {
  name               = "scale_out_last_day_of_the_month"
  service_namespace  = aws_appautoscaling_target.api.service_namespace
  resource_id        = aws_appautoscaling_target.api.resource_id
  scalable_dimension = aws_appautoscaling_target.api.scalable_dimension
  schedule           = "cron(0 xx L * ? *)"
  timezone           = "Asia/Tokyo"

  scalable_target_action {
    min_capacity = xx
    max_capacity = xx
  }
}

しかし、突発的なアクセス増に完全に対応することはむずかしく、一斉プッシュ通知などアクセスの増加が見込めるイベントがある場合には、手動でスケールアウトするようにしていました。

スケールの自動化

イベントに合わせて手動でサービスをスケールするのは手間がかかりますし、作業の漏れが発生してしまう可能性もあります。そこでGoogleカレンダーにプッシュ通知などのイベントを登録してもらい、それに合わせてスケールアウト・スケールインする仕組みを作りました。

システムは以下のような構成になります。

  1. 社内のスタッフがアクセスが増加しそうなイベントを共有カレンダーに登録
  2. EventBrigeスケジューラで定期実行されるLambda関数がイベントを取得して、Scheduled Actionを登録
  3. 登録されてたScheduled Actionに従ってECSがスケールアウト・スケールイン

Scheduled Actionを登録するLambda関数は以下のような実装になります。

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/applicationautoscaling"
	"github.com/aws/aws-sdk-go-v2/service/applicationautoscaling/types"
	"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/calendar/v3"
	"google.golang.org/api/option"
)

var (
	GOOGLE_CALENDAR_CREDENTIALS_FROM = os.Getenv("GOOGLE_CALENDAR_CREDENTIALS_FROM")
	CALENDAR_ID                      = os.Getenv("CALENDAR_ID")
	ECS_SERVICES_JSON                = os.Getenv("ECS_SERVICES_JSON")
	DRY_RUN                          = os.Getenv("DRY_RUN") == "1"
	JST                              *time.Location

	// e.g.)
	// [
	//   {
	//     "cluster": "my-cluster",
	//     "service": "api",
	//     "scale-in": {"min": 4, "max": 10},
	//     "scale-out": {"min": 10, "max": 30}
	//   }
	// ]
	ECSServices []ECSService
)

type ECSServiceTargetAction struct {
	Min int32 `json:"min"`
	Max int32 `json:"max"`
}

type ECSService struct {
	Cluster  string                 `json:"cluster"`
	Service  string                 `json:"service"`
	ScaleIn  ECSServiceTargetAction `json:"scale-in"`
	ScaleOut ECSServiceTargetAction `json:"scale-out"`
}

func init() {
	log.SetFlags(0)

	err := json.Unmarshal([]byte(ECS_SERVICES_JSON), &ECSServices)

	if err != nil {
		log.Fatal(err)
	}

	JST, err = time.LoadLocation("Asia/Tokyo")

	if err != nil {
		log.Fatal(err)
	}
}

func main() {
	lambda.Start(HandleRequest)
}

func HandleRequest(ctx context.Context, event any) error {
	now := time.Now()
	// Googleカレンダーからイベントを取得
	events, err := listCalendarEvents(ctx, now)

	if err != nil {
		return err
	}

	awsCfg, err := config.LoadDefaultConfig(ctx)

	if err != nil {
		return err
	}

	aas := applicationautoscaling.NewFromConfig(awsCfg)

	for _, e := range events.Items {
		eventStart, err := time.Parse(time.RFC3339, e.Start.DateTime)

		if err != nil {
			return err
		}

		eventEnd, err := time.Parse(time.RFC3339, e.End.DateTime)

		if err != nil {
			return err
		}

		// 現在〜30分後までに開始されるイベントのスケーリングをスケジュール
		if eventStart.After(now) && eventStart.Before(now.Add(30*time.Minute)) {
			scaleOut(ctx, aas, e, eventStart)
			scaleIn(ctx, aas, e, eventEnd)
		}
	}

	return nil
}

func listCalendarEvents(ctx context.Context, now time.Time) (*calendar.Events, error) {
	credsJson, err := getSecretValue(ctx, GOOGLE_CALENDAR_CREDENTIALS_FROM)

	if err != nil {
		return nil, err
	}

	creds, err := google.CredentialsFromJSON(ctx, []byte(credsJson), calendar.CalendarReadonlyScope)

	if err != nil {
		return nil, err
	}

	client, err := calendar.NewService(ctx, option.WithCredentials(creds))

	if err != nil {
		return nil, err
	}

	// 10分前〜1時間後のイベントを取得
	min := now.Add(-10 * time.Minute)
	max := now.Add(+1 * time.Hour)

	events, err := client.Events.List(CALENDAR_ID).
		TimeMin(min.Format(time.RFC3339)).
		TimeMax(max.Format(time.RFC3339)).
		SingleEvents(true).
		Do()

	if err != nil {
		return nil, err
	}

	return events, err
}

func scaleOut(ctx context.Context, aas *applicationautoscaling.Client, event *calendar.Event, eventStart time.Time) error {
	for _, svr := range ECSServices {
		name := fmt.Sprintf("ecs-scaling-out-%s-%s-%s", svr.Cluster, svr.Service, event.Id)
		exist, err := isScheduleExist(ctx, aas, name)

		if err != nil {
			return err
		}

		// すでにスケジュールが存在したらスキップ
		if exist {
			log.Printf("%s already scheduled", name)
			return nil
		}

		// 10分前にスケールアウト開始
		schedule := eventStart.Add(-10 * time.Minute)

		ta := svr.ScaleOut
		err = putSchedule(ctx, aas, name, schedule, &svr, &ta)

		if err != nil {
			return err
		}
	}

	return nil
}

func scaleIn(ctx context.Context, aas *applicationautoscaling.Client, event *calendar.Event, eventEnd time.Time) error {
	for _, svr := range ECSServices {
		name := fmt.Sprintf("ecs-scaling-in-%s-%s-%s", svr.Cluster, svr.Service, event.Id)
		exist, err := isScheduleExist(ctx, aas, name)

		if err != nil {
			return err
		}

		// すでにスケジュールが存在したらスキップ
		if exist {
			log.Printf("%s already scheduled", name)
			return nil
		}

		// イベント終了時刻にスケールイン開始
		schedule := eventEnd

		ta := svr.ScaleIn
		err = putSchedule(ctx, aas, name, schedule, &svr, &ta)

		if err != nil {
			return err
		}
	}

	return nil
}

func isScheduleExist(ctx context.Context, aas *applicationautoscaling.Client, name string) (bool, error) {
	output, err := aas.DescribeScheduledActions(ctx, &applicationautoscaling.DescribeScheduledActionsInput{
		ServiceNamespace:     "ecs",
		ScheduledActionNames: []string{name},
	})

	if err != nil {
		return false, err
	}

	return len(output.ScheduledActions) > 0, nil
}

func putSchedule(ctx context.Context, aas *applicationautoscaling.Client, name string, schedule time.Time, svr *ECSService, targetAction *ECSServiceTargetAction) error {
	if DRY_RUN {
		return nil
	}

	_, err := aas.PutScheduledAction(ctx, &applicationautoscaling.PutScheduledActionInput{
		ScheduledActionName: aws.String(name),
		ServiceNamespace:    "ecs",
		ResourceId:          aws.String(fmt.Sprintf("service/%s/%s", svr.Cluster, svr.Service)),
		Schedule:            aws.String(schedule.In(JST).Format("at(2006-01-02T15:04:05)")),
		Timezone:            aws.String("Asia/Tokyo"),
		ScalableDimension:   "ecs:service:DesiredCount",
		ScalableTargetAction: &types.ScalableTargetAction{
			MinCapacity: aws.Int32(targetAction.Min),
			MaxCapacity: aws.Int32(targetAction.Max),
		},
	})

	return err
}

func getSecretValue(ctx context.Context, secretId string) (string, error) {
	cfg, err := config.LoadDefaultConfig(ctx)

	if err != nil {
		return "", err
	}

	client := secretsmanager.NewFromConfig(cfg)
	output, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{SecretId: aws.String(secretId)})

	if err != nil {
		return "", err
	}

	return aws.ToString(output.SecretString), nil
}

これをterraformでLambda関数としてデプロイします。

#####################################################################
# IAM
#####################################################################

resource "aws_iam_role" "lambda_ecs_scaling" {
  name = "lambda-ecs-scaling"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      },
    ]
  })
}

resource "aws_iam_role_policy" "lambda_ecs_scaling" {
  role = aws_iam_role.lambda_ecs_scaling.name
  name = "ecs-scaling"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "application-autoscaling:DeleteScheduledAction",
          "application-autoscaling:DescribeScheduledActions",
          "application-autoscaling:PutScheduledAction",
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = "secretsmanager:GetSecretValue"
        Resource = [
          aws_secretsmanager_secret.GOOGLE_CALENDAR_CREDENTIALS.arn,
        ]
      },
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_ecs_scaling" {
  for_each = {
    for policy in toset([
      data.aws_iam_policy.aws_lambda_basic_execution_role,
    ]) : policy.name => policy.arn
  }

  role       = aws_iam_role.lambda_ecs_scaling.name
  policy_arn = each.value
}

#####################################################################
# Lambda
#####################################################################

data "lambdazip_files_sha256" "ecs_scaling" {
  files = [
    "lambda/*.go",
    "lambda/go.mod",
    "lambda/go.sum",
  ]
}

resource "lambdazip_file" "ecs_scaling" {
  base_dir      = "lambda"
  sources       = ["bootstrap"]
  output        = "lambda-ecs-scaling.zip"
  before_create = "GOOS=linux GOARCH=amd64 go build -o bootstrap main.go"
  triggers      = data.lambdazip_files_sha256.ecs_scaling.map
}

resource "aws_lambda_function" "ecs_scaling" {
  function_name    = "ecs-scaling"
  runtime          = "provided.al2023"
  role             = aws_iam_role.lambda_ecs_scaling.arn
  handler          = "bootstrap"
  filename         = lambdazip_file.ecs_scaling.output
  source_code_hash = lambdazip_file.ecs_scaling.base64sha256
  timeout          = 600

  environment {
    variables = {
      # DRY_RUN = 1

      GOOGLE_CALENDAR_CREDENTIALS_FROM = aws_secretsmanager_secret.GOOGLE_CALENDAR_CREDENTIALS.name
      CALENDAR_ID                      = "xxx@group.calendar.google.com"

      ECS_SERVICES_JSON = jsonencode([
        {
          cluster   = "my-cluster"
          service   = "api"
          scale-in  = { min = 4, max = 10 }
          scale-out = { min = 10, max = 30 }
        },
      ])
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda_ecs_scaling,
  ]
}

resource "aws_cloudwatch_log_group" "lambda_ecs_scaling" {
  name = "/aws/lambda/ecs-scaling"
}

#####################################################################
# EventBridge Scheduler
#####################################################################

resource "aws_iam_role" "ecs_scaling_schedule" {
  name = "ecs-scaling-schedule"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "scheduler.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      },
    ]
  })
}

resource "aws_iam_role_policy" "ecs_scaling_schedule" {
  role = aws_iam_role.ecs_scaling_schedule.name
  name = "ecs-scaling-schedule"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "lambda:InvokeFunction"
        Resource = aws_lambda_function.ecs_scaling.arn
      },
    ]
  })
}

resource "aws_scheduler_schedule" "ecs_scaling" {
  name                         = "ecs-scaling"
  schedule_expression          = "rate(5 minutes)"
  schedule_expression_timezone = "Asia/Tokyo"
  state                        = "ENABLED"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = aws_lambda_function.ecs_scaling.arn
    role_arn = aws_iam_role.ecs_scaling_schedule.arn
  }
}

#####################################################################
# Secrets Manager
#####################################################################

resource "aws_secretsmanager_secret" "GOOGLE_CALENDAR_CREDENTIALS" {
  name = "GOOGLE_CALENDAR_CREDENTIALS"
}

実際の動作

以下のようなイベントがカレンダーに登録されていた場合

LambdaによってScheduled Actionが登録されます

% aws logs tail --follow /aws/lambda/ecs-scaling --since '2025/04/10 11:27:00 +09:00'
2025-04-10T02:27:18.699000+00:00 2025/04/10/[$LATEST]xxx START RequestId: 1767f72c-7db1-4a65-9b06-7e396d524d3e Version: $LATEST
2025-04-10T02:27:19.351000+00:00 2025/04/10/[$LATEST]xxx END RequestId: xxx-xxx-xxx-xxx-xxx
2025-04-10T02:27:19.351000+00:00 2025/04/10/[$LATEST]xxx REPORT RequestId: xxx-xxx-xxx-xxx-xxx	Duration: 652.00 ms	Billed Duration: 652 ms	Memory Size: 128 MB	Max Memory Used: 41 MB
2025-04-10T02:32:18.537000+00:00 2025/04/10/[$LATEST]xxx START RequestId: xxx-xxx-xxx-xxx-xxx Version: $LATEST
2025-04-10T02:32:19.364000+00:00 2025/04/10/[$LATEST]xxx Evant '[push]xxx': ecs-scaling-out-xxxx-xxx-xxx_20250410T030000Z scheduled at 2025-04-10T11:50:00+09:00 (min=xx, max=xx, dry-run=false)
2025-04-10T02:32:19.489000+00:00 2025/04/10/[$LATEST]xxx Evant '[push]xxx': ecs-scaling-out-xxx-xxx-xxx_20250410T030000Z scheduled at 2025-04-10T11:50:00+09:00 (min=xx, max=xx, dry-run=false)
2025-04-10T02:32:19.614000+00:00 2025/04/10/[$LATEST]xxx Evant '[push]xxx': ecs-scaling-in-xxx-xxx-xxx_20250410T030000Z scheduled at 2025-04-10T13:00:00+09:00 (min=xx, max=xx, dry-run=false)
2025-04-10T02:32:19.741000+00:00 2025/04/10/[$LATEST]xxx Evant '[push]xxx': ecs-scaling-in-xxx-xxx-xxx_20250410T030000Z scheduled at 2025-04-10T13:00:00+09:00 (min=xx, max=xxx, dry-run=false)
2025-04-10T02:32:19.742000+00:00 2025/04/10/[$LATEST]xxx END RequestId: xxx-xxx-xxx-xxx-xxx
2025-04-10T02:32:19.742000+00:00 2025/04/10/[$LATEST]xxx REPORT RequestId: xxx-xxx-xxx-xxx-xxx	Duration: 1204.83 ms	Billed Duration: 1205 ms	Memory Size: 128 MB	Max Memory Used: 41 MB

メトリクスでECSタスクが増えていることも確認できました。

まとめ

自動スケールに限らず仕組み自体はかなり単純なのですが、わりと効果的にトイルを削減できたと思います。
Googleカレンダー+Lambdaバッチの組み合わせはいろいろと応用が利きそうなので、機会があればほかにも活用していきたいと考えています。

株式会社カンム

Discussion