基礎知識編
Webシステム開発においてキャッシュの理解と設計は非常に重要です。
キャッシュはサーバーの負荷を軽減し、レスポンス時間を大幅に短縮する役割を果たします。頻繁にアクセスされるデータや計算結果を一時的に保管しておくことで、同じリクエストが来たときに再計算や再取得をせずにすみます。
前半の"基礎知識編"では、主にキャッシュ戦略やAWSが提供しているインメモリのキャッシュサービスについてまとめ、記事の後半の"ハンズオン編では、"MemCached"を使ったクエリチューニングを実践していきます。
キャッシュ戦略種別
キャッシュ戦略は、システムの目的や構成、負荷の性質に応じて選択することが重要です。主に、「ローカル or リモート」、「リード or ライト」、「インライン or アサイド」の三つの軸で戦略が分類されます。それぞれの軸での選択がパフォーマンスや一貫性に大きな影響を及ぼします。
ローカル or リモート
ローカルキャッシュ
一般にはブラウザのようなクライアント側にデータを一時的に保存する方法を指します。これにより、データがローカルに存在すればサーバーへの再度のアクセスを避けることができ、アプリケーションのパフォーマンスが向上します。
リモートキャッシュ
一つ以上の専用のキャッシュサーバーにデータを保持します。リモートキャッシュは一般的には複数のクライアント間でのデータ共有に適しており、大規模なシステムでよく使われます。
リード or ライト
リードキャッシュ(読み込み時にキャッシュ)
データを読み取る際にキャッシュを利用する戦略です。データソースからのデータを初めて読み取る際には時間がかかりますが、その後のアクセスはキャッシュされたデータを利用するため、大幅に読み取り時間が短縮されます。頻繁に同じデータを読み取るアプリケーションやサービスにおいて特に有効です。
ライトキャッシュ(書き込み時にキャッシュ)
データを書き込む際にキャッシュを利用する戦略です。データをデータソースに直接書き込む代わりに、一旦キャッシュにデータを保存します。そして、キャッシュからデータソースへの書き込みはバックグラウンドで効率的に行われます。これにより、アプリケーションは迅速にレスポンスを返すことが可能となり、特に書き込み操作が頻繁に行われるアプリケーションで有効です。
インライン or アサイド
インラインキャッシュ
データを取得するために呼び出すサービスからは透過的で、キャッシュ自身が上流のデータソースからデータを取得する作業を行います。例えば、AmazonのCloudFrontはインラインキャッシュとして機能し、ユーザーがリクエストを送ると、最も近いエッジロケーションからコンテンツを迅速に配信します。
アサイドキャッシュ
キャッシュはデータソースから独立して更新されます。これにより、キャッシュとデータソース間で一貫性を維持するための追加のロジックが必要となります。
Amazon Web Services (AWS)が提供するAWS Elastic Cacheは、インメモリデータストアとキャッシュサービスを提供します。これにより、アプリケーションからのデータアクセス時間が大幅に短縮され、パフォーマンスが向上します。大量のリードトラフィックを処理するWebサーバー、スケール可能なリアルタイム分析、高速なトランザクション処理、レイテンシに敏感なユースケースに特に適しています。
MemCached
MemCacheは、AWS Elastic Cacheのサポートするキャッシュエンジンの一つで、シンプルなキー値ストアとして動作します。また、マルチスレッドに対応しているため、マルチコアプロセッサの能力を最大限に活用できます。単純なデータ構造を持つデータや小規模なデータセットに最適で、メモリ効率が非常に良いという特徴があります。
Redis
RedisもAWS Elastic Cacheのサポートエンジンで、より高度なデータ構造やトランザクションをサポートしており、さらに、リスト/ハッシュ/セット/ソート済みセットなどのリッチなデータ型にも対応しています。しかし、Redisはシングルスレッドの設計を採用しているため、一度に1つのコマンドしか実行できないという制限があります。大規模で複雑なデータ構造を持つデータセットや高いパフォーマンス要求のあるアプリケーションに適しています。
ハンズオン編
ここからは実際のコードでキャッシュを使ったパフォーマンス・チューニングのデモを行ってみます。
本記事ではローカル環境でDockerを使って動作確認を行います。キャッシュサーバーとしては、memcached
を利用します。
なお、今回はリード・アサイド戦略を用いたケースを想定します。つまり、初回読取り時(キャッシュがヒットしない場合)はDBからデータを取得し、次回以降のリクエストの場合は、キャッシュから取得します。図式化すると以下の通りです。
なお、本記事作成時における、筆者のローカルマシンの環境は以下になります。
項目 | 内容 |
---|---|
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)
// 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)
// 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)
})
ローカル編
インフラ構成図は以下の通りです。
ここから、ローカルマシン上でキャッシュを使ったクエリチューニングを実際に体感してみましょう。
まずはじめに、以下のRepositoryのクローンからお願いします。その後、README.md
を参考に環境構築をお願いします。
# クローン
$ 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秒程度のタイムラグが発生するはずです。
> which ab
/usr/sbin/ab
> curl http://localhost:8080/db/1
{"id":1,"value":"Initial Value","sleepResult":0}
Apache Benchを使った負荷テスト
次に、Apache Bench
を使ってAPIのパフォーマンスを計測してみます。なお、Macの場合はデフォルトでインストールされています。本ツールの詳細は以下の記事を参考にしてみてください。
> 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)
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秒程度かかっている事がわかります。
# 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';
キャッシュを使ったチューニング後の計測
# 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回目以降は保存されたデータを用いるため、レスポンスの遅延は発生しません。
> 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の各リソースは課金対象となるものが多く含まれています。個人利用される方は特に注意してください。発生した費用に関して一切責任を負いかねます。
事前設定
既に設定済みの方は不必要ですが、本記事ではルートに近いIAMユーザー
の準備とAWS CLI
のインストールが必要になります。Terraform
はDocker上で起動するため、インストールは不要です。
- AWS CLIのインストール
- rootに近い権限を持つIAMユーザーの作成
また、以下のRepositoryのクローンからお願いします。その後、README.md
を参考に環境構築をお願いします。
# クローン
$ 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定義
provider "aws" {
region = "ap-northeast-1"
}
(2)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といった既存リソースの取得
# ------------------------------------------------------------#
# 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"
}
# 最新の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といったネットワーク構築
# ------------------------------------------------------------#
# 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()
を使って各変数を任意の形式で出力しています。
# ------------------------------------------------------------#
# 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構築
# ------------------------------------------------------------#
# 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のヘルスチェックとは別にコンテナの起動チェックを記述することができます。
# ------------------------------------------------------------#
# 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 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起動時に実行することができます。
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 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
に転記します。
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
に転記しています。
# 省略
"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
を使います。
# スロークエリ
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.avg
とdb.SQL.Com_select.avg
について確認してみます。
# スロークエリ
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の実行をすると指標が低減しています。
リソース削除
本記事のハンズオンが完了した後は必ずリソース削除をしてください。ECR
とSSM Parameter
も必要であれば削除してください。
# コンテナを立ち上げる
docker run \
-v ~/.aws:/root/.aws \
-v $(pwd)/.infrastructure:/terraform \
-w /terraform \
-it \
--entrypoint=ash \
hashicorp/terraform:1.5
# 削除
terraform destroy