LoginSignup
41
30

【図解】キャッシュ戦略って何?キャッシュを使ったクエリチューニングって何?

Last updated at Posted at 2023-08-04

基礎知識編

Webシステム開発においてキャッシュの理解と設計は非常に重要です。

キャッシュはサーバーの負荷を軽減し、レスポンス時間を大幅に短縮する役割を果たします。頻繁にアクセスされるデータや計算結果を一時的に保管しておくことで、同じリクエストが来たときに再計算や再取得をせずにすみます。

前半の"基礎知識編"では、主にキャッシュ戦略やAWSが提供しているインメモリのキャッシュサービスについてまとめ、記事の後半の"ハンズオン編では、"MemCached"を使ったクエリチューニングを実践していきます。

キャッシュ戦略種別

キャッシュ戦略は、システムの目的や構成、負荷の性質に応じて選択することが重要です。主に、「ローカル or リモート」、「リード or ライト」、「インライン or アサイド」の三つの軸で戦略が分類されます。それぞれの軸での選択がパフォーマンスや一貫性に大きな影響を及ぼします。

ローカル or リモート

ローカルキャッシュ
一般にはブラウザのようなクライアント側にデータを一時的に保存する方法を指します。これにより、データがローカルに存在すればサーバーへの再度のアクセスを避けることができ、アプリケーションのパフォーマンスが向上します。
名称未設定ファイル-ページ1.png
リモートキャッシュ
一つ以上の専用のキャッシュサーバーにデータを保持します。リモートキャッシュは一般的には複数のクライアント間でのデータ共有に適しており、大規模なシステムでよく使われます。
名称未設定ファイル-ページ1のコピー (1).png

リード or ライト

リードキャッシュ(読み込み時にキャッシュ)
データを読み取る際にキャッシュを利用する戦略です。データソースからのデータを初めて読み取る際には時間がかかりますが、その後のアクセスはキャッシュされたデータを利用するため、大幅に読み取り時間が短縮されます。頻繁に同じデータを読み取るアプリケーションやサービスにおいて特に有効です。
名称未設定ファイル-リード (1).png
ライトキャッシュ(書き込み時にキャッシュ)
データを書き込む際にキャッシュを利用する戦略です。データをデータソースに直接書き込む代わりに、一旦キャッシュにデータを保存します。そして、キャッシュからデータソースへの書き込みはバックグラウンドで効率的に行われます。これにより、アプリケーションは迅速にレスポンスを返すことが可能となり、特に書き込み操作が頻繁に行われるアプリケーションで有効です。
名称未設定ファイル-ライト.png

インライン or アサイド

インラインキャッシュ
データを取得するために呼び出すサービスからは透過的で、キャッシュ自身が上流のデータソースからデータを取得する作業を行います。例えば、AmazonのCloudFrontはインラインキャッシュとして機能し、ユーザーがリクエストを送ると、最も近いエッジロケーションからコンテンツを迅速に配信します。
名称未設定ファイル-インライン.png
アサイドキャッシュ
キャッシュはデータソースから独立して更新されます。これにより、キャッシュとデータソース間で一貫性を維持するための追加のロジックが必要となります。
名称未設定ファイル-アサイド.png
Amazon Web Services (AWS)が提供するAWS Elastic Cacheは、インメモリデータストアとキャッシュサービスを提供します。これにより、アプリケーションからのデータアクセス時間が大幅に短縮され、パフォーマンスが向上します。大量のリードトラフィックを処理するWebサーバー、スケール可能なリアルタイム分析、高速なトランザクション処理、レイテンシに敏感なユースケースに特に適しています。

MemCached

MemCacheは、AWS Elastic Cacheのサポートするキャッシュエンジンの一つで、シンプルなキー値ストアとして動作します。また、マルチスレッドに対応しているため、マルチコアプロセッサの能力を最大限に活用できます。単純なデータ構造を持つデータや小規模なデータセットに最適で、メモリ効率が非常に良いという特徴があります。

Redis

RedisもAWS Elastic Cacheのサポートエンジンで、より高度なデータ構造やトランザクションをサポートしており、さらに、リスト/ハッシュ/セット/ソート済みセットなどのリッチなデータ型にも対応しています。しかし、Redisはシングルスレッドの設計を採用しているため、一度に1つのコマンドしか実行できないという制限があります。大規模で複雑なデータ構造を持つデータセットや高いパフォーマンス要求のあるアプリケーションに適しています。

ハンズオン編

ここからは実際のコードでキャッシュを使ったパフォーマンス・チューニングのデモを行ってみます。

本記事ではローカル環境でDockerを使って動作確認を行います。キャッシュサーバーとしては、memcachedを利用します。

なお、今回はリード・アサイド戦略を用いたケースを想定します。つまり、初回読取り時(キャッシュがヒットしない場合)はDBからデータを取得し、次回以降のリクエストの場合は、キャッシュから取得します。図式化すると以下の通りです。

名称未設定ファイル-リード・アサイド.png
なお、本記事作成時における、筆者のローカルマシンの環境は以下になります。

項目 内容
PC M1 MacBook Pro(14インチ、2021)
OS MacOS Monterey
IDE(統合開発環境) GoLand

サンプルAPI概要

本ハンズオンでのユースケース
非常に遅いクエリを発行するAPIをキャッシュを用いてパフォーマンス改善するといったケースを想定しています。

ここでは、Goで書かれたAPI(FrameworkはGin)をサンプルとして用います。そして、このアプリケーションには2つのエンドポイントが用意されております。

(1)スロークエリAPI(path:/db/1)

1つ目は、リクエストが発生するとDB(MySQL)に問い合わせを行います。ただし、SELECT id, value, SLEEP(10) FROM customers WHERE id = ?とあるように、クエリが完了するまでに10秒を有するという非常に遅い処理です。

コードの詳細はこちら(main.go)
main.go
// ex. http://localhost:8080/db/1

	r.GET("/db/:id", func(c *gin.Context) {
		paramId := c.Param("id")
		var result Customer
		// 10秒遅延させるクエリの実行
		err := db.QueryRow("SELECT id, value, SLEEP(10) FROM customers WHERE id = ?", paramId).Scan(&result.ID, &result.Value, &result.SleepResult)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}

		c.JSON(http.StatusOK, result)
	})

(2)キャッシュを活用したAPI(path:/cache/1)

2つ目は、リクエストが発生すると、まずキャッシュサーバーに値を確認し、存在しなければ、DBから値を取得しキャッシュサーバーに保存した上で結果を返却します。2回目以降のリクエストに関しては、キャッシュサーバーから値を取得する形になります。

コードの詳細はこちら(main.go)
main.go
// ex. http://localhost:8080/cache/1

r.GET("/cache/:id", func(c *gin.Context) {
		paramId := c.Param("id")
		// キャッシュから取得
		item, err := mc.Get(paramId)
		// キャッシュがない場合
		if err == memcache.ErrCacheMiss {
			var result Customer
			// 10秒遅延させるクエリの実行
			err := db.QueryRow("SELECT id, value, SLEEP(10) FROM customers WHERE id = ?", paramId).Scan(&result.ID, &result.Value, &result.SleepResult)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			// キャッシュ登録
			resultBytes, err := json.Marshal(result)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			item = &memcache.Item{
				Key:   paramId,
				Value: resultBytes,
			}
			if err := mc.Set(item); err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
				return
			}
			c.JSON(http.StatusOK, result)
			return
		}

		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}

		// キャッシュ結果をレスポンス用に加工する
		result := Customer{}
		if err = json.Unmarshal(item.Value, &result); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, result)
	})

ローカル編

インフラ構成図は以下の通りです。

ローカル環境.png

ここから、ローカルマシン上でキャッシュを使ったクエリチューニングを実際に体感してみましょう。
まずはじめに、以下のRepositoryのクローンからお願いします。その後、README.mdを参考に環境構築をお願いします。

Terminal
# クローン
$ git clone git@github.com:WebEngrChild/go-rds-memcached.git

# Docker起動
$ docker compose up -d

# API起動
$ docker compose exec app go run main.go

スロークエリAPIの測定

キャッシュサーバーを用いたパフォーマンスチューニングをする上でも、"現状分析"は必ず行うべきです。実装前後でどのような改善ができたのかを定量的に計測することで施策の良し悪しを振り返るための良い物差しになるからです。

それでは、上記で説明した一つ目のスロークエリAPIにリクエストを投げてみます。レスポンスが返却されるまで、10秒程度のタイムラグが発生するはずです。

Terminal
> which ab
/usr/sbin/ab

> curl http://localhost:8080/db/1
{"id":1,"value":"Initial Value","sleepResult":0}

Apache Benchを使った負荷テスト

次に、Apache Benchを使ってAPIのパフォーマンスを計測してみます。なお、Macの場合はデフォルトでインストールされています。本ツールの詳細は以下の記事を参考にしてみてください。

Terminal
> ab -n 30 -c 30 http://localhost:8080/db/1

Benchmarking localhost (be patient)...

# 一部割愛
# レイテンシの分布(パーセンタイル情報)
Percentage of the requests served within a certain time (ms)
  50%  10079
  66%  10083
  75%  10085
  80%  10087
  90%  10088
  95%  10089
  98%  10089
  99%  10089
 100%  10089 (longest request)

ここで、-nオプションは合計のリクエスト数を指定、-cオプションは並列リクエストの数を指定しています。ここでは、30並列で30回のリクエストを実行しています。実行結果から、レイテンシの分布からも各パーセンタイルで10秒程度遅延が発生していることがわかります。

スロークエリの確認

ローカル環境で利用しているMySQLイメージはデフォルトでスロークエリログを出力します。また、docker-compose.ymlでスロークエリをローカルマシン側にマッピングしています。

コードの詳細はこちら(docker-compose.yml)
docker-compose.yml
  mysql:
    container_name: mysql
    build: .docker/mysql/
    volumes:
      - .docker/mysql/init:/docker-entrypoint-initdb.d
      - .docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      - .docker/mysql/log:/var/log/mysql # ここでログをマッピング
    environment:
      - MYSQL_ROOT_PASSWORD=${DB_PASS}
    ports:
      - "3306:3306"
    networks:
      sample_go_network:

ログを確認すると、いくつか出力されていることがわかります。Query_timeでクエリ実行に10秒程度かかっている事がわかります。

slow.log
# Time: 2023-07-22T02:32:36.702832Z
# User@Host: root[root] @  [192.168.192.4]  Id:    24
# Query_time: 10.007020  Lock_time: 0.000000 Rows_sent: 1  Rows_examined: 1
SET timestamp=1689993146;
SELECT id, value, SLEEP(10) FROM customers WHERE id = '1';

キャッシュを使ったチューニング後の計測

Terminal
# 1回目は同じく10秒程度かかる
> curl http://localhost:8080/cache/1
{"id":1,"value":"Initial Value","sleepResult":0}

# 2回目以降は遅延の発生はしない
> curl http://localhost:8080/cache/1
{"id":1,"value":"Initial Value","sleepResult":0}

1回目のリクエストは、MemCachedに保存される前であるため、同様に10秒程度遅延が発生します。しかし、2回目以降は保存されたデータを用いるため、レスポンスの遅延は発生しません。

Terminal
> ab -n 30 -c 30 http://localhost:8080/cache/1

Benchmarking localhost (be patient)...

# 一部割愛
# レイテンシの分布(パーセンタイル情報)
Percentage of the requests served within a certain time (ms)
  50%     22
  66%     22
  75%     24
  80%     25
  90%     26
  95%     26
  98%  10047
  99%  10047
 100%  10047 (longest request)

Apache Benchでもレスポンス遅延が大幅に改善している事が確認できます。また、スロークエリも、初回以外は出力されていない事がわかります。

AWS編

インフラ構成図

名称未設定ファイル-AWS編.png

注意点
利用するAWSの各リソースは課金対象となるものが多く含まれています。個人利用される方は特に注意してください。発生した費用に関して一切責任を負いかねます。

事前設定

既に設定済みの方は不必要ですが、本記事ではルートに近いIAMユーザーの準備とAWS CLIのインストールが必要になります。TerraformはDocker上で起動するため、インストールは不要です。

  • AWS CLIのインストール
  • rootに近い権限を持つIAMユーザーの作成

また、以下のRepositoryのクローンからお願いします。その後、README.mdを参考に環境構築をお願いします。

Terminal
# クローン
$ git clone git@github.com:WebEngrChild/go-rds-memcached.git

# Docker起動
$ docker compose up -d

# API起動
$ docker compose exec app go run main.go

Terraformコード紹介

本記事では、AWSのリソース構築にTerraformを利用しています。詳細の説明は省略しますが参考までにコードを掲載しておきます。

(1)main.tf:provider定義
main.tf
provider "aws" {
  region = "ap-northeast-1"
}
(2)variables.tf:プロジェクト名などの変数定義
variables.tf
variable "project" {
  type    = string
  default = "go-api"
}

variable "environment" {
  type    = string
  default = "dev"
}

variable "cidr_blocks" {
  description = "List of CIDR blocks"
  type        = list(string)
  default     = ["<ご自身のグローバルIPを設定してください>/32"]
}
(3)data.tf:ECR, SSM, AMIといった既存リソースの取得
data.tf
# ------------------------------------------------------------#
# Existing ECR
# ------------------------------------------------------------#
data "aws_ecr_repository" "existing" {
  name = "go-dev-repo"
}

# ------------------------------------------------------------#
# Existing SSM Parameter Store
# ------------------------------------------------------------#
data "aws_ssm_parameter" "existing" {
  name = "/env"
}

# ------------------------------------------------------------#
# Existing ECR
# ------------------------------------------------------------#
data "aws_ecr_repository" "existing" {
  name = "go-dev-repo"
}

# ------------------------------------------------------------#
# Existing SSM Parameter Store
# ------------------------------------------------------------#
data "aws_ssm_parameter" "existing" {
  name = "/env"
}

# ------------------------------------------------------------#
# Latest EC2 AMI
# ------------------------------------------------------------#
data "aws_ssm_parameter" "ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64"
}

data.tf
# 最新のAMIイメージを取得
data "aws_ssm_parameter" "ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64"
}

(3)vpc.tf:VPC, subnetといったネットワーク構築
vpc.tf
# ------------------------------------------------------------#
# local variables
# ------------------------------------------------------------#
locals {
  zones         = ["1a", "1c", "1d"]
  public_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_cidrs = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
}

# ------------------------------------------------------------#
# VPC
# ------------------------------------------------------------#
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = format("%s-%s-aws_vpc", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# Subnet Public
# ------------------------------------------------------------#
resource "aws_subnet" "public" {
  for_each = toset(local.zones)

  vpc_id            = aws_vpc.main.id
  availability_zone = "ap-northeast-${each.value}"
  cidr_block        = local.public_cidrs[index(local.zones, each.value)]

  tags = {
    Name = format("%s-%s-public-%s", var.environment, var.project, each.value)
  }
}

# ------------------------------------------------------------#
# Subnets Private
# ------------------------------------------------------------#
resource "aws_subnet" "private" {
  for_each = toset(local.zones)

  vpc_id            = aws_vpc.main.id
  availability_zone = "ap-northeast-${each.value}"
  cidr_block        = local.private_cidrs[index(local.zones, each.value)]

  tags = {
    Name = format("%s-%s-private-%s", var.environment, var.project, each.value)
  }
}

# ------------------------------------------------------------#
# Internet Gateway
# ------------------------------------------------------------#
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws_internet_gateway", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# Elastic IP
# ------------------------------------------------------------#
resource "aws_eip" "nat" {
  for_each = toset(local.zones)

  domain = "vpc"

  tags = {
    Name = format("%s-%s-aws_eip-nat_%s", var.environment, var.project, each.value)
  }
}

# ------------------------------------------------------------#
# NAT Gateway
# ------------------------------------------------------------#
resource "aws_nat_gateway" "nat" {
  for_each = toset(local.zones)

  subnet_id     = aws_subnet.public[each.value].id
  allocation_id = aws_eip.nat[each.value].id

  tags = {
    Name = format("%s-%s-nat_%s", var.environment, var.project, each.value)
  }
}

# ------------------------------------------------------------#
# Route Table Public
# ------------------------------------------------------------#
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws_route_table-public", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# Route Public
# ------------------------------------------------------------#
resource "aws_route" "public" {
  destination_cidr_block = "0.0.0.0/0"
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.main.id
}

# ------------------------------------------------------------#
# Association Public
# ------------------------------------------------------------#
resource "aws_route_table_association" "public" {
  for_each = toset(local.zones)

  subnet_id      = aws_subnet.public[each.value].id
  route_table_id = aws_route_table.public.id
}

# ------------------------------------------------------------#
# Route Table Private
# ------------------------------------------------------------#
resource "aws_route_table" "private" {
  for_each = toset(local.zones)

  vpc_id = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-private_%s", var.environment, var.project, each.value)
  }
}

# ------------------------------------------------------------#
# Route Private
# ------------------------------------------------------------#
resource "aws_route" "private" {
  for_each = toset(local.zones)

  destination_cidr_block = "0.0.0.0/0"
  route_table_id         = aws_route_table.private[each.value].id
  nat_gateway_id         = aws_nat_gateway.nat[each.value].id
}

# ------------------------------------------------------------#
# Association Private
# ------------------------------------------------------------#
resource "aws_route_table_association" "private" {
  for_each = toset(local.zones)

  subnet_id      = aws_subnet.private[each.value].id
  route_table_id = aws_route_table.private[each.value].id
}

# ------------------------------------------------------------#
# SecurityGroup ALB
# ------------------------------------------------------------#
resource "aws_security_group" "alb" {
  name        = var.project
  description = var.project
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws_security_group-alb", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# SecurityGroup Rule
# ------------------------------------------------------------#
resource "aws_security_group_rule" "alb_egress" {
  security_group_id = aws_security_group.alb.id

  type        = "egress"
  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "alb_ingress" {
  security_group_id = aws_security_group.alb.id

  type        = "ingress"
  from_port   = 80
  to_port     = 80
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}

localsでファイル内で利用できる変数を定義することができます。ここでは、アベイラビリティゾーン, サブネットの作成を定義しつつ、for_eachを使ってループによって作成しています。

また、format()を使って各変数を任意の形式で出力しています。

vpc.tf
# ------------------------------------------------------------#
# local variables
# ------------------------------------------------------------#
locals {
  zones         = ["1a", "1c", "1d"]
  public_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_cidrs = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
}

## 省略... 

# ------------------------------------------------------------#
# Subnet Public
# ------------------------------------------------------------#
resource "aws_subnet" "public" {
  for_each = toset(local.zones)

  vpc_id            = aws_vpc.main.id
  availability_zone = "ap-northeast-${each.value}"
  cidr_block        = local.public_cidrs[index(local.zones, each.value)]

  tags = {
    Name = format("%s-%s-public-%s", var.environment, var.project, each.value)
  }
}

## 省略... 
(4)ecs.tf:ECS, ALB構築
ecs.tf
# ------------------------------------------------------------#
# ALB
# ------------------------------------------------------------#
resource "aws_lb" "main" {
  load_balancer_type = "application"
  name               = var.project

  security_groups = [aws_security_group.alb.id]
  subnets         = values(aws_subnet.public)[*].id
}

# ------------------------------------------------------------#
# ALB Listener
# ------------------------------------------------------------#
resource "aws_lb_listener" "main" {
  port              = 80
  protocol          = "HTTP"
  load_balancer_arn = aws_lb.main.arn

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      status_code  = "200"
      message_body = "ok"
    }
  }
}

# ------------------------------------------------------------#
# Task Definition
# ------------------------------------------------------------#
resource "aws_ecs_task_definition" "main" {
  family                   = var.project
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  network_mode             = "awsvpc"
  container_definitions    = <<EOL
[
  {
    "name": "go",
    "image": "${data.aws_ecr_repository.existing.repository_url}:latest",
    "portMappings": [
      {
        "containerPort": 8080,
        "hostPort": 8080
      }
    ],
    "secrets": [
      {
        "name": "ENV_FILE",
        "valueFrom": "${data.aws_ssm_parameter.existing.arn}"
      }
    ],
    "command": ["/bin/sh", "-c", "printenv ENV_FILE > .env && ./main"],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group" : "${aws_cloudwatch_log_group.ecs_logs.name}",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "ecs"
      }
    },
    "healthCheck": {
      "command": ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"],
      "interval": 30,
      "timeout": 5,
      "retries": 3,
      "startPeriod": 10
    }
  }
]
EOL

  execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn      = aws_iam_role.task_role.arn
}

# ------------------------------------------------------------#
# ECS Cluster
# ------------------------------------------------------------#
resource "aws_ecs_cluster" "main" {
  name = var.project
}

# ------------------------------------------------------------#
# ELB Target Group
# ------------------------------------------------------------#
resource "aws_lb_target_group" "main" {
  name        = var.project
  vpc_id      = aws_vpc.main.id
  port        = 8080
  protocol    = "HTTP"
  target_type = "ip"

  health_check {
    port     = 8080
    path     = "/"
    protocol = "HTTP"
  }
}

# ------------------------------------------------------------#
# ALB Listener Rule
# ------------------------------------------------------------#
resource "aws_lb_listener_rule" "main" {
  listener_arn = aws_lb_listener.main.arn

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.main.arn
  }

  condition {
    path_pattern {
      values = ["*"]
    }
  }
}

# ------------------------------------------------------------#
# ECS SecurityGroup
# ------------------------------------------------------------#
resource "aws_security_group" "ecs" {
  name        = format("%s-ecs", var.project)
  description = format("%s-ecs", var.project)
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = format("%s-ecs", var.project)
  }
}

# ------------------------------------------------------------#
# ECS Security Group Egress rule
# ------------------------------------------------------------#
resource "aws_security_group_rule" "ecs_egress" {
  security_group_id = aws_security_group.ecs.id
  type              = "egress"

  from_port   = 0
  to_port     = 0
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

# ------------------------------------------------------------#
# ECS Security Group Ingress rule
# ------------------------------------------------------------#
resource "aws_security_group_rule" "ecs_ingress" {
  security_group_id = aws_security_group.ecs.id
  type              = "ingress"
  from_port         = 8080
  to_port           = 8080
  protocol          = "tcp"
  cidr_blocks       = ["10.0.0.0/16"]
}

# ------------------------------------------------------------#
# ECS Service
# ------------------------------------------------------------#
resource "aws_ecs_service" "main" {
  name            = var.project
  depends_on      = [aws_lb_listener_rule.main]
  cluster         = aws_ecs_cluster.main.name
  launch_type     = "FARGATE"
  desired_count   = 1
  task_definition = aws_ecs_task_definition.main.arn

  network_configuration {
    subnets         = values(aws_subnet.private)[*].id
    security_groups = [aws_security_group.ecs.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.main.arn
    container_name   = "go"
    container_port   = 8080
  }
}

# ------------------------------------------------------------#
# Task Execution Role
# ------------------------------------------------------------#
resource "aws_iam_role" "ecs_task_execution_role" {
  name = format("%s-%s-ecs_task_execution_role", var.environment, var.project)

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

# ------------------------------------------------------------#
# Association Private
# ------------------------------------------------------------#
resource "aws_iam_policy" "ecs_task_execution_policy" {
  name        = format("%s-%s-ecs_task_execution_policy", var.environment, var.project)
  path        = "/"
  description = format("%s-ecs-task-execution-policy", var.project)

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecs:StartTask",
        "ecs:StopTask",
        "ecs:DescribeTasks",
        "ecs:ListTasks",
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "ssm:GetParameters"
      ],
      "Resource": "*"
    }
  ]
}
EOF
}

# ------------------------------------------------------------#
# Task Policy Attachment
# ------------------------------------------------------------#
resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy_attach" {
  role       = aws_iam_role.ecs_task_execution_role.name
  policy_arn = aws_iam_policy.ecs_task_execution_policy.arn
}

# ------------------------------------------------------------#
# Task Role
# ------------------------------------------------------------#
resource "aws_iam_role" "task_role" {
  name = var.project

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

# ------------------------------------------------------------#
# SSM parameter store policy
# ------------------------------------------------------------#
resource "aws_iam_policy" "ssm_parameter_store_policy" {
  name        = var.project
  description = "Allow access to SSM Parameter Store"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameters",
      "Resource": "*"
    }
  ]
}
EOF
}

# ------------------------------------------------------------#
# SSM parameter store policy attachment
# ------------------------------------------------------------#
resource "aws_iam_role_policy_attachment" "ssm_policy_attach" {
  role       = aws_iam_role.task_role.name
  policy_arn = aws_iam_policy.ssm_parameter_store_policy.arn
}

# ------------------------------------------------------------#
# CloudWatch Logs Group
# ------------------------------------------------------------#
resource "aws_cloudwatch_log_group" "ecs_logs" {
  name              = "/ecs/handson"
  retention_in_days = 14
}

# ------------------------------------------------------------#
# OutPut
# ------------------------------------------------------------#
output "alb_dns" {
  value       = aws_lb.main.dns_name
  description = "DNS name"
}

タスク定義内で以下のように記述することでALBのヘルスチェックとは別にコンテナの起動チェックを記述することができます。

ecs.tf
# ------------------------------------------------------------#
# Task Definition
# ------------------------------------------------------------#
resource "aws_ecs_task_definition" "main" {

## 省略...

    "healthCheck": {
      "command": ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"],
      "interval": 30,
      "timeout": 5,
      "retries": 3,
      "startPeriod": 10
    }

(5)rds.tf:RDS構築
rds.tf
# ------------------------------------------------------------#
# RDS parameter group
# ------------------------------------------------------------#
resource "aws_db_parameter_group" "main" {
  name   = var.project
  family = "mysql8.0"

  parameter {
    name  = "character_set_database"
    value = "utf8mb4"
  }

  parameter {
    name  = "character_set_server"
    value = "utf8mb4"
  }

  tags = {
    Name = format("%s-%s-aws-db-parameter-group", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# RDS option group
# ------------------------------------------------------------#
resource "aws_db_option_group" "main" {
  name                     = var.project
  option_group_description = var.project
  engine_name              = "mysql"
  major_engine_version     = "8.0"

  tags = {
    Name = format("%s-%s-aws-db-option-group", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# RDS subnet group
# ------------------------------------------------------------#
resource "aws_db_subnet_group" "main" {
  name = var.project
  subnet_ids = [
    aws_subnet.private["1c"].id,
    aws_subnet.private["1d"].id,
  ]

  tags = {
    Name = format("%s-%s-aws-db-subnet-group", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# RDS Security Group
# ------------------------------------------------------------#
resource "aws_security_group" "rds" {
  name        = format("%s-%s-aws-security-group-rds", var.environment, var.project)
  description = "Allow inbound traffic on port 3306"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws-security-group", var.environment, var.project)
  }
}

resource "aws_security_group_rule" "allow_ecs_mysql" {
  security_group_id        = aws_security_group.rds.id
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.ecs.id
}

resource "aws_security_group_rule" "allow_ec2_mysql" {
  security_group_id        = aws_security_group.rds.id
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.ssm.id
}

# ------------------------------------------------------------#
# RDS Instance
# ------------------------------------------------------------#
resource "random_string" "db_password" {
  length  = 16
  special = false
}

resource "aws_db_instance" "main" {
  engine         = "mysql"
  engine_version = "8.0"

  identifier = var.project

  username = "admin"
  password = random_string.db_password.result

  skip_final_snapshot = true

  instance_class = "db.t3.medium"

  storage_type      = "gp2"
  allocated_storage = 20
  storage_encrypted = false

  multi_az               = false
  availability_zone      = "ap-northeast-1d"
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  publicly_accessible    = false
  port                   = 3306

  parameter_group_name = aws_db_parameter_group.main.name
  option_group_name    = aws_db_option_group.main.name

  apply_immediately = true

  performance_insights_enabled = true

  tags = {
    Name = format("%s-%s-aws-db-instance", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# Bastion EC2
# ------------------------------------------------------------#
resource "aws_security_group" "ssm" {
  name        = format("%s-%s-aws-security-group-ssm", var.environment, var.project)
  description = "Security Group for SSM EC2 Instance"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws-security-group-ssm", var.environment, var.project)
  }
}

resource "aws_security_group_rule" "ssm_egress" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.ssm.id
}

resource "aws_security_group_rule" "ssm_ingress_ssh" {
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = var.cidr_blocks
  security_group_id = aws_security_group.ssm.id
}

resource "aws_security_group_rule" "ssm_ingress_mysql" {
  type              = "ingress"
  from_port         = 3306
  to_port           = 3306
  protocol          = "tcp"
  cidr_blocks       = var.cidr_blocks
  security_group_id = aws_security_group.ssm.id
}

resource "aws_iam_role" "rds_access" {
  name = format("%s-%s-aws_iam_role-rds_access", var.environment, var.project)

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

resource "aws_iam_role_policy" "rds_access" {
  name = "RDSPolicy"
  role = aws_iam_role.rds_access.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "rds:*",
        ]
        Resource = ["*"]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "ssm_managed_policy_attach" {
  role       = aws_iam_role.rds_access.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "rds_access" {
  name = "RDSAccessProfile"
  role = aws_iam_role.rds_access.name
}

resource "aws_instance" "ssm" {
  ami                         = "ami-0947c48ae0aaf6781"
  instance_type               = "t2.micro"
  iam_instance_profile        = aws_iam_instance_profile.rds_access.name
  subnet_id                   = aws_subnet.public["1d"].id
  vpc_security_group_ids      = [aws_security_group.ssm.id]
  associate_public_ip_address = true
  key_name                    = "access_db"
  user_data                   = <<-EOF
            #!/bin/bash
            sudo systemctl enable amazon-ssm-agent
            sudo systemctl start amazon-ssm-agent
            EOF

  tags = {
    Name = format("%s-%s-aws-ssm-ec2-instance", var.environment, var.project)
  }
}

# ------------------------------------------------------------#
# OutPut
# ------------------------------------------------------------#
output "DB_USER" {
  value       = "admin"
  description = "Database username"
}

output "DB_PASS" {
  value       = random_string.db_password.result
  description = "Database password"
}

output "DB_HOST" {
  value       = aws_db_instance.main.address
  description = "Database endpoint"
}

output "DB_NAME" {
  value       = "golang"
  description = "Database name"
}

output "DB_PORT" {
  value       = aws_db_instance.main.port
  description = "Database port"
}

output "bastion_ec2_id" {
  value       = aws_instance.ssm.id
  description = "bastion ec2 id"
}

最新のEC2のAMIにはssm agentがインストール済みですが、起動はされていません。そこで、user_dataにコマンドを記述することで、EC2起動時に実行することができます。

rds.tf
resource "aws_instance" "ssm" {
  ami                         = data.aws_ssm_parameter.ami.value
  instance_type               = "t2.micro"
  iam_instance_profile        = aws_iam_instance_profile.rds_access.name
  subnet_id                   = aws_subnet.public["1d"].id
  vpc_security_group_ids      = [aws_security_group.ssm.id]
  associate_public_ip_address = true
  key_name                    = "access_db"
  user_data                   = <<-EOF
            #!/bin/bash
            sudo systemctl enable amazon-ssm-agent
            sudo systemctl start amazon-ssm-agent
            EOF

  tags = {
    Name = format("%s-%s-aws-ssm-ec2-instance", var.environment, var.project)
  }
}

(6)memcached.tf:ElasticCache(Memcached)構築
memcached.tf
# ------------------------------------------------------------#
# Memcached Security Group
# ------------------------------------------------------------#
resource "aws_security_group" "memcached" {
  name        = format("%s-%s-aws-security-group", var.environment, var.project)
  description = "Allow inbound traffic on port 11211"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = format("%s-%s-aws-security-group", var.environment, var.project)
  }
}

resource "aws_security_group_rule" "memcached" {
  security_group_id        = aws_security_group.memcached.id
  type                     = "ingress"
  from_port                = 11211
  to_port                  = 11211
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.ecs.id
}

# ------------------------------------------------------------#
# Memcached subnet group
# ------------------------------------------------------------#
resource "aws_elasticache_subnet_group" "memcached" {
  name = format("%s-%s-memcached-subnet-group", var.environment, var.project)
  subnet_ids = [
    aws_subnet.private["1c"].id,
    aws_subnet.private["1d"].id,
  ]
}

# ------------------------------------------------------------#
# Memcached cluster
# ------------------------------------------------------------#
resource "aws_elasticache_cluster" "memcached" {
  cluster_id           = format("%s-%s-memcached-cluster", var.environment, var.project)
  engine               = "memcached"
  node_type            = "cache.t3.micro"
  num_cache_nodes      = 2
  parameter_group_name = "default.memcached1.6"
  subnet_group_name    = aws_elasticache_subnet_group.memcached.name
  security_group_ids   = [aws_security_group.memcached.id]
}

# ------------------------------------------------------------#
# OutPut
# ------------------------------------------------------------#
output "CACHE_HOST1" {
  value       = "${aws_elasticache_cluster.memcached.cache_nodes.0.address}:11211"
  description = "Cache cluster node 1 endpoint"
}

output "CACHE_HOST2" {
  value       = "${aws_elasticache_cluster.memcached.cache_nodes.1.address}:11211"
  description = "Cache cluster node 2 endpoint"
}

ECR構築

まずはECRの作成になります。ECRはAWS CLIでリソース構築しています。<account_id>には、ご自身のAWSアカウントIDを入力してください。なお、#イメージビルド以外のコマンドは以下からも確認できます。

  • マネージメントコンソール > Amazon ECR > リポジトリ > go-dev-repo > プッシュコマンドの表示
# ECR作成
aws ecr create-repository --repository-name go-dev-repo

# ログイン
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com

# イメージビルド
docker build --no-cache --target runner -t go-dev-repo --platform linux/amd64 -f ./.docker/go/Dockerfile .

# タグ付け
docker tag go-dev-repo:latest <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/go-dev-repo:latest

# プッシュ
docker push <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/go-dev-repo:latest

Systems Manager Parameterの初期値設定

次に、ECSのタスク上で稼働するアプリケーションで用いる環境変数を保管するためのパラメーターストアを作成します。具体的な値は後続の作業で設定するため、ここでは初期値として仮置きしています。

# /envというパスに初期値を仮置き
aws ssm put-parameter \
    --name "/env" \
    --value "init" \
    --type SecureString

Terraformの実行

最低限のセキュリティとして、本サンプルアプリがアクセスできるリソースのip制限を行います。以下のサイト等を参考にグローバルIPを取得してください。その後、variables.tfに転記します。

variables.tf
variable "cidr_blocks" {
  description = "List of CIDR blocks"
  type        = list(string)
  default     = ["<ご自身のグローバルIPを設定してください>/32"]
}

本記事では、ローカルマシンに直接Terraformをインストールするのではなく、Dockerを使ってHashiCorpが提供する公式のTerraformイメージ(バージョン1.5)を起動しています。

# ローカルマシンのAWS認証キー格納先ディレクトリと設定ファイルをマウントしてコンテナ起動
docker run \
  -v ~/.aws:/root/.aws \
  -v $(pwd)/.infrastructure:/terraform \
  -w /terraform \
  -it \
  --entrypoint=ash \
  hashicorp/terraform:1.5

# 初期化
terraform init

# 差分検出
terraform plan

# コードを適用する
terraform apply -auto-approve

> aws_elasticache_cluster.memcached: Creation complete after 7m15s [id=dev-go-api-memcached-cluster]

> Apply complete! Resources: 69 added, 0 changed, 0 destroyed.

> Outputs:

# ここにParameter Storeに登録する環境変数の値が表示される

実行後に表示されるOutputsはどこかにメモするかTerminalのタブを開いたままにしておいてください。

Systems Manager Port fordingでRDSに接続

Session Managerを使用すると、踏み台用のEC2インスタンスにパブリックIPアドレスを付与したり、インバウンドSSH接続を許可したりする必要がなくなります。さらに、秘密鍵の発行・管理が不必要になり、IAMポリシーでアクセス管理することができるためよりセキュアになります。

SSMを利用した方式ではこれまで、セッション接続先のEC2インスタンス内で LISTEN しているポートしかフォワードできませんでしたが、今回のアップデートにより、リモートホストのポートも転送できるようになりました。

# セッション開始
aws ssm start-session \
  --target "<terraformコマンドで実行後に出力されるbastion_ec2_idを転記>" \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters \
'{
  "host": ["<terraformコマンドで実行後に出力されるDB_HOSTを転記>"],
  "portNumber": ["3306"],
  "localPortNumber":["3306"]
}'

# 以下が表示されたらタブは開いたままにする
> Waiting for connections...
# 別タブでDocker起動Docker起動
docker run --name mysql-client --rm -it mysql:8.0 /bin/bash

# MySQLクライアントで接続
mysql -h host.docker.internal -P 3306 -u admin -p

# パスワード入力
Enter password: <terraformコマンドで実行後に出力されるDB_PASSを転記>

# 初期クエリ
mysql> <.docker/mysql/init/1_create.sqlの内容をそのまま転記して実行>

Systems Manager Parameterに環境変数を格納

キーとバリュー形式で個別にそれぞれ環境変数として格納するのでなく、envというキーに以下の値をそのままの形式で格納してください。
ecs.tfのタスク定義書の箇所で以下のようにして、Parameterから格納した値を.envに転記しています。

ecs.tf
# 省略

    "secrets": [
      {
        "name": "ENV_FILE",
        "valueFrom": "${data.aws_ssm_parameter.existing.arn}"
      }
    ],
    "command": ["/bin/sh", "-c", "printenv ENV_FILE > .env && ./main"],

# 省略
# Terraformでリソース作成が完了すると出力される
Apply complete! Resources: 69 added, 0 changed, 0 destroyed.

Outputs:
CACHE_HOST1 = "dev-go-api-memcached-cluster.xxxx.0001.apne1.cache.amazonaws.com:11211"
CACHE_HOST2 = "dev-go-api-memcached-cluster.xxxx.0002.apne1.cache.amazonaws.com:11211"
DB_HOST = "go-api.xxxxx.ap-northeast-1.rds.amazonaws.com"
DB_NAME = "golang"
DB_PASS = "xxxxxx"
DB_PORT = 3306
DB_USER = "admin"
# terraformコマンドで実行後に出力される内容を以下の形に修正して`env`にそのままの形で転記
# ダブルコロン""は不要なので注意すること

DB_USER=admin
DB_PASS=xxxxxxx
DB_HOST=go-api.xxxxxxx.ap-northeast-1.rds.amazonaws.com
DB_NAME=golang
DB_PORT=3306
CACHE_HOST1=go-api-dev-memcached-cluster.xxxxxxx.0001.apne1.cache.amazonaws.com:11211
CACHE_HOST2=go-api-dev-memcached-cluster.xxxxxxx.0002.apne1.cache.amazonaws.com:11211

デプロイ

AWS CLIのupdate-serviceコマンドを用いてデプロイを行います。コマンド実行後に#ステータス確認ACTIVEが表示されていれば完了です。3分ぐらいはかかります。

# デプロイ
aws ecs update-service --cluster go-api --service go-api --task-definition go-api --force-new-deployment

# ステータス確認
aws ecs describe-services --cluster go-api --services go-api --query 'services[*].status' --output text

> ACTIVE

動作確認

Terraformで作成完了後に出力されるalb_dnsを使います。

Terminal
# スロークエリ
 curl http://go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com/db/1
> {"id":1,"value":"Initial Value","sleepResult":0}%

# キャッシュ
 curl http://go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com/cache/1
> {"id":1,"value":"Initial Value","sleepResult":0}%

RDS Performance Insightを使ったパフォーマンスチェック

RDS Performance InsightsはAWSの機能で、データベースのパフォーマンス問題を視覚化・診断するためのツールです。SQLクエリのロード分布を分析し、パフォーマンスのボトルネックを特定します。

ここでは、db.SQL.Queries.avgdb.SQL.Com_select.avgについて確認してみます。

Terminal

# スロークエリ
ab -n 30 -c 30 http://go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com/db/1

> Benchmarking go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com (be patient).....done

# キャッシュ
ab -n 30 -c 30 http://go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com/cache/1

> Benchmarking go-api-xxxxxx.ap-northeast-1.elb.amazonaws.com (be patient).....done

マネージメントコンソール > RDS > データベース > go-api > モニタリング > 新しいモニタリングビューに移動 > メトリクス

   

スロークエリAPI実行時は両指標が上昇していることが確認できます。その後、キャッシュAPIの実行をすると指標が低減しています。

リソース削除

本記事のハンズオンが完了した後は必ずリソース削除をしてください。ECRSSM Parameterも必要であれば削除してください。

# コンテナを立ち上げる
docker run \
  -v ~/.aws:/root/.aws \
  -v $(pwd)/.infrastructure:/terraform \
  -w /terraform \
  -it \
  --entrypoint=ash \
  hashicorp/terraform:1.5

# 削除
terraform destroy

41
30
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
30