📖
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カレンダーにプッシュ通知などのイベントを登録してもらい、それに合わせてスケールアウト・スケールインする仕組みを作りました。
システムは以下のような構成になります。
- 社内のスタッフがアクセスが増加しそうなイベントを共有カレンダーに登録
- EventBrigeスケジューラで定期実行されるLambda関数がイベントを取得して、Scheduled Actionを登録
- 登録されてた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