【AWSコスト削減の道 for ステージング環境】 Fargate編

# 【AWSコスト削減の道 for 開発環境】 Fargate編

## 結論
* FargateをFargate Spotに変更及び使用していない時間に自動停止処理を設定することで、通常料金の **約70%** を低下

## 前提
* 本記事で記載している料金については、2022/02/13時点の料金を表示している
* 料金は「東京リージョン」の料金を表示している
* Fargateのリソース構築についてはTerraformを用いてリソース構築をしている
* Fagateについてある程度理解している
* Fargate Spotについてある程度理解している
    * 詳細は[参考URL](#参考URL)を参照
* Fargateのスペックは以下としている
    * vCPU: 2
    * メモリ(GB): 4
* 本記事内ではタスク数を**1**としている

## 目次
[前提](#前提)
[概要](#概要)
[背景](#背景)
[本記事内の利用技術について](#本記事内の利用技術について)
[料金について](#料金について)
[Fargate Spotの指定方法について](#FargateSpotの指定方法について)
[起動・停止方法について](#起動・停止方法について)
[次回記事について](#次回記事について)
[参考url](#参考url)

## 概要
* ステージング環境にて利用しているFargateをFargate Spotに変更する
* ステージング環境を利用していない時間に関してはFargateの実行タスク数を0とし、</br>料金が発生しないようにする
* Fargate Spot強制停止時のハンドリングについては別記事で記載を行う

## 背景
* 業務時間外にも課金状態となっているAWSリソースに対してコストを削減しようとしたため

## 本記事内の利用技術について
* Terraform
    * AWSのリソース構築をソースコードで表現するIac
* Fargate
    * Elastic Container Service（ECS）とElastic Kubernetes Service（EKS）利用したマネーシドサービス
* Fargate Spot
    * Fargateにて実行されたサービス（タスク）をSpotサービスで起動するサービス

## 料金について

### 前提
* データ処理料金については加味していない ※ 利用していない時間の為、データ処理は「0」と判断したため

### 詳細
* Fargateのサービス内の1タスクあたりの料金（USD/時）
    * **約0.125USD**

### 停止時間について
* 停止曜日は以下としている
    * **月 ~ 金** ※ 祝日は考慮していない
* 各曜日の停止時間は以下としている
    * **PM10:00 ~ AM8:00**

### 結論

#### **約70%の節約!!**
* **約0.3...** = 0.037USD（停止後の料金） / 0.125USD

### 停止前
* 720時間（１か月の料金）
    * 0.125USD = **90USD**

### 停止後
* 440時間（1か月の料金） * 0.032USD = **14.08USD**
    * 1日の起動時間: **14時間** = 24時間 - 10時間（一日あたりの停止時間）
    * 1週間の起動時間: **70時間** = 168時間 - (10 * 5)時間（1週間内の平日停止時間） - 48時間（１週間内の土、日停止時間）
    * 1か月の起動時間: **440時間** = 720時間 - (70 * 7)時間

## Fargate Spotの指定方法について
* Capacity Providerを利用して、サービス単位でタスクが起動する際の「Fargate」及び「Fargate Spot」の割合を設定することで実現している
* 上記のCapacity Providerの設定についてはTerraformにて設定をしている

### 考慮した点について
* ステージング環境である点を考慮して、Fargateは一切利用せず、タスクを起動の際はすべてFargate Spotで起動するようにしている
* Fargate SpotはAWS側から強制停止をされることが想定されるので、強制停止通知が来たら安全に停止するための時間として「120秒」停止を待つよう設定をしている ※ デフォルトでは30秒となっている</br>※ 強制停止通知時のハンドリングについては別記事に記載

### Terraformのソースコードについて

```terraform:main.tf
resource "aws_ecs_task_definition" "api" {
  container_definitions = jsonencode(
    [
      {
        cpu         = 0
        environment = []
        essential   = true
        image       = "${data.aws_ecr_repository.api.repository_url}:latest"
        logconfiguration = {
          logdriver = "awslogs"
          options = {
            awslogs-group         = aws_cloudwatch_log_group.api.name
            awslogs-region        = data.aws_region.current.name
            awslogs-stream-prefix = "ecs"
          }
        }
        mountPoints = []
        name        = local.api_container
        portMappings = [
          {
            containerPort = local.container_port
            hostPort      = local.container_port
            protocol      = "tcp"
          },
        ]
        volumesFrom = []
        # Fargate Spotが強制停止通知が来た際に停止遅延時間として「120秒」を設定している
        stopTimeout = 120
      }
    ]
  )
  cpu                = var.for_cpu_spec_value
  memory             = var.for_memory_spec_value
  execution_role_arn = data.aws_iam_role.ecs_task_exe.arn
  family             = local.service_name
  network_mode       = "awsvpc"
  task_role_arn      = var.ecs_role_arn
  requires_compatibilities = [
    "FARGATE",
  ]
  tags = {
    Name = local.service_name
  }
}

resource "aws_ecs_service" "api" {
  name                               = local.service_name
  cluster                            = data.aws_ecs_cluster.cluster.arn
  task_definition                    = aws_ecs_task_definition.api.arn
  deployment_maximum_percent         = 200
  deployment_minimum_healthy_percent = 100
  health_check_grace_period_seconds  = 60
  # このサービスでは、タスク数の基準として「1」としている
  desired_count                      = 1
  load_balancer {
    container_name   = local.api_container
    container_port   = local.container_port
    target_group_arn = data.aws_lb_target_group.api.arn
  }

  capacity_provider_strategy {
    capacity_provider = "FARGATE"
    base   = 0
    weight = 0
  }

  capacity_provider_strategy {
    capacity_provider = "FARGATE_SPOT"
    base              = 0
    # weightを100にすることで新規で起動するタスクに対して必ず「Fargate Spot」を起動するようになる
    weight            = 100
  }
  network_configuration {
    assign_public_ip = false
    security_groups = [
      var.for_ecs_service_security_group_id
    ]
    subnets = [for value in data.aws_subnet.private : value.id]
  }
  lifecycle {
    ignore_changes = [
      desired_count
    ]
  }
}
```

## 起動・停止方法について 
* EventBrigeを用いて特定の時間にLambdaを実行するようにしている

```python:fargate_task_count_handler.py
import calendar
import json
import os
from datetime import datetime
from enum import Enum
from dataclasses import dataclass

import boto3

ecs_client = boto3.client('ecs', region_name='ap-northeast-1')
# 処理対象
processing_objects = {
	'DEV_TARGET': os.environ['DEV_TARGET']
}
NUMBER_OF_TASKS_AT_THE_TIME_OF_STARTING = 1
NUMBER_OF_TASKS_AT_THE_TIME_OF_STOPPING = 0


class LaunchType(Enum):
	STARTUP = 'startup'
	STOP = 'stop'


class LogLevel(Enum):
	TRACE = 'TRACE'
	DEBUG = 'debug'
	INFO = 'info'
	WARN = 'warn'
	ERROR = 'error'
	FATAL = 'fatal'


@dataclass
class LambdaResponse:
	processed: dict
	launch_type: LaunchType

	def successful_format(self) -> dict:
		return {'statusCode': 200, 'processed {}'.format(self.launch_type.value): self.processed}

	def failed_format(self) -> dict:
		return {'statusCode': 500, 'processed {}'.format(self.launch_type.value): self.processed}

	def convert_to_str_from_successful_format(self) -> str:
		return json.dumps(self.successful_format())

	def convert_to_str_from_failed_format(self) -> str:
		return json.dumps(self.failed_format())


def is_holiday() -> bool:
	class HolidayName(Enum):
		SATURDAY = 'Saturday'
		SUNDAY = 'Sunday'

	# 実行時の曜日を文字列で取得(JST)
	today_str = calendar.day_name[datetime.today().weekday()]

	return today_str == HolidayName.SATURDAY.value or today_str == HolidayName.SUNDAY.value


def get_launch_type(event: dict) -> LaunchType:
	key_name = 'status'
	if event[key_name] is None:
		raise Exception('control tag name the None: variable name is {}'.format(key_name))

	launch_type = event[key_name]
	if launch_type == LaunchType.STARTUP.value:
		return LaunchType.STARTUP
	elif launch_type == LaunchType.STOP.value:
		return LaunchType.STOP
	else:
		raise Exception('control tag name illegal value: variable name is {}'.format(key_name))


def fargate_task_count_handler(launch_type: LaunchType) -> LambdaResponse:
	processed_result = {}
	update_service_function = lambda i, c: ecs_client.update_service(cluster=i['ClusterName'],
																	 service=i['ServiceName'], desiredCount=c)
	for value in processing_objects.values():
		info = json.loads(value)
		if launch_type == LaunchType.STARTUP:
			update_service_function(info, NUMBER_OF_TASKS_AT_THE_TIME_OF_STARTING)
		elif launch_type == LaunchType.STOP:
			update_service_function(info, NUMBER_OF_TASKS_AT_THE_TIME_OF_STOPPING)
		else:
			raise Exception('control tag name illegal value: variable name is launch_type')

		processed_result[info['ClusterName']] = value

	return LambdaResponse(processed_result, launch_type)


def logging(log_lv: LogLevel, lambda_name: str, error_msg: str) -> None:
	logging_date_str = (datetime.now()).strftime('%Y/%m/%d %H:%M:%S')
	print("{0} {1} [{2}] {3}".format(logging_date_str, lambda_name, log_lv.value, error_msg))


def lambda_handler(event, context):
	# 休日の場合は処理を実行しない
	if is_holiday():
		return {'statusCode': 300, 'message': 'today is holiday! Take a rest now!!'}

	launch_type = get_launch_type(event)
	response = fargate_task_count_handler(launch_type)

	logging(LogLevel.INFO, context.function_name, response.convert_to_str_from_successful_format())
```

## Fargate Spotのライフサイクル

![fargate_task_life_cycle.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2209596/44eef014-2991-2824-3f6e-b8a9dc45b125.png)

## 次回記事について
* 次回は本記事のFargate Spotが強制停止された際のハンドリング及びライフタイムについて記事にしようと思っています！

## 参考URL
* https://aws.amazon.com/jp/blogs/aws/aws-fargate-spot-now-generally-available/
* https://aws.amazon.com/jp/fargate/pricing/