【AWS】AWS CLIとECS CLIを使用したECS Webアプリ構築 ~バッチ化まで~の記事では、ECSのリソースをバッチで作成しました。
しかし、異常系を考慮できていないことやリソース管理が難しいという問題があります。
そこで、設定ファイルを作成することで作成・更新・削除などの管理を簡略化できるTerraformを利用します。
また、Terraformの実行環境についてもコンテナ化しています。この記事では説明を省略しますので、ご興味のある方は詳細はこちらの記事でご確認ください。
AWS構成図
Terraformの作成
各リソースの定義をtfファイルで作成します。
それぞれモジュールという単位で分割して、メインから呼び出す設計です。
terraform
フォルダの直下にあるmain.tf
が最初に呼び出されるメインファイル、terraform.tfvars
はグローバルな変数を定義するファイルです。
それぞれにあるvariables.tf
は、各場所での変数を定義するファイルです。
また、output.tf
はリソースを作成した結果で得られる値を変数として定義するファイルです。ここで変数にしておくと、その後の他のリソース作成の際に参照したりできます。
app
フォルダにはコンテナ化されたWebアプリケーションを配置してください。
docker-compose.yml
の使用を想定しています。
ファイル数が多いので、重要な部分と分かりづらい部分のみ説明を書きます。
└─terraform-container
│ .env
│ Dockerfile
│
└─web
├─app
│ └─ docker-compose.yml
│
└─terraform
│ main.tf
│ terraform.tfvars
│ variables.tf
│
└─modules
├─alb
│ alb.tf
│ outputs.tf
│ tg.tf
│ variables.tf
│
├─cloudwatch
│ logs.tf
│ outputs.tf
│ variables.tf
│
├─ecr
│ dockerbuild.sh
│ ecr.tf
│ outputs.tf
│ variables.tf
│
├─ecs
│ cluster.tf
│ outputs.tf
│ service.tf
│ task.tf
│ task_definition.json
│ variables.tf
│
├─iam
│ outputs.tf
│ role.tf
│ variables.tf
│
├─network
│ ig.tf
│ outputs.tf
│ rt.tf
│ subnet.tf
│ variables.tf
│ vpc.tf
│
└─sg
outputs.tf
sg.tf
variables.tf
メイン
main.tf
がメインとなるファイルです。
このファイルから各モジュールを呼び出します。
terraform
でTerraformのバージョンを指定します。また、provider
で使用するプロバイダーを指定します。
module
で後述するモジュールを呼び出します。具体的なモジュールの場所はsource
で指定します。
その他は、モジュール内で使用したい変数を渡すために設定しています。ここでは、各リソースで同じ値を使用するものをterraform.tfvars
で定義して、それを変数として参照して渡しています。
${}
を使用して他のモジュールで作成したリソースの値を使用することもできます。ただし、使用できるのは参照先のモジュールのoutput
で登録している値のみです。
# Terraform のバージョン指定
terraform {
required_version = "~> 1.0.0"
}
# プロバイダーを指定
provider "aws" {}
# ECR
module "ecr" {
source = "./modules/ecr"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
account_id = var.account_id
}
# IAM
module "iam" {
source = "./modules/iam"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
}
# Network
module "network" {
source = "./modules/network"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
}
# Security Group
module "sg" {
source = "./modules/sg"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
vpc_id = "${module.network.vpc_id}"
sg_ingress_ip_cidr = var.sg_ingress_ip_cidr
}
# Cloud Watch
module "cloudwatch" {
source = "./modules/cloudwatch"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
}
# ALB
module "alb" {
source = "./modules/alb"
name_prefix = var.name_prefix
region = var.region
tag_name = var.tag_name
tag_group = var.tag_group
vpc_id = "${module.network.vpc_id}"
public_a_id = "${module.network.public_a_id}"
public_c_id = "${module.network.public_c_id}"
sg_id = "${module.sg.sg_id}"
}
# ECS
module "ecs" {
source = "./modules/ecs"
name_prefix = var.name_prefix
region = var.region
webapp_port = var.webapp_port
tag_name = var.tag_name
tag_group = var.tag_group
# Service
logs_group_name = "${module.cloudwatch.logs_group_name}"
tg_arn = "${module.alb.tg_arn}"
public_a_id = "${module.network.public_a_id}"
public_c_id = "${module.network.public_c_id}"
sg_id = "${module.sg.sg_id}"
# Task
ecr_repository_uri = "${module.ecr.repository_uri}"
execution_role_arn = "${module.iam.execution_role_arn}"
}
sg_ingress_ip_cidr
は、セキュリティグループのインバウンドルールで使用します。
自身のIPアドレスを定義しておくとWebアプリにアクセスできるようになります。
webapp_port
はWebアプリの公開ポートを指定してください。
tag_name = "xxxx-name"
tag_group = "xxxx-group"
name_prefix ="qiita"
region = "ap-northeast-1"
account_id = "xxxxxxxxxxxx"
sg_ingress_ip_cidr = "xxx.xxx.xxx.xxx/32"
webapp_port = xxxx
# Global
variable region {}
variable name_prefix {}
variable webapp_port {}
# Tags
variable tag_name {}
variable tag_group {}
# ECR
variable "account_id" {}
# SG
variable "sg_ingress_ip_cidr" {}
ECR
resource
でプロバイダーに存在するリソース名とその中の設定値を指定します。
リソース名をnull_resource
とすることで、shellスクリプトを実行したりもできます。ここでは、dockerコンテナのビルドからECRのプッシュまでをdockerbuild.sh
にまとめて実行しています。
resource "aws_ecr_repository" "default" {
name = "${local.repository_name}"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Name = "${var.tag_name}-repository"
group = "${var.tag_group}"
}
}
resource "null_resource" "default" {
provisioner "local-exec" {
command = "sh ${path.module}/dockerbuild.sh"
environment = {
AWS_REGION = var.region
AWS_ACCOUNT_ID = var.account_id
REPO_URL = aws_ecr_repository.default.repository_url
CONTAINER_NAME = "${local.container_name}"
DOCKER_DIR = "${local.docker_dir}"
}
}
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
# ECR
variable "account_id" {}
locals {
repository_name = "${var.name_prefix}-repository"
container_name = "${var.name_prefix}-container"
docker_dir = "/web/app/docker-compose.yml"
}
output "repository_uri" {
value = "${aws_ecr_repository.default.repository_url}"
}
#!/bin/bash
# Docker login
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
# Build image
export CONTAINER_NAME=$CONTAINER_NAME
# docker build -t $CONTAINER_NAME $DOCKER_DIR
docker-compose -f $DOCKER_DIR build --no-cache
# Tag
docker tag $CONTAINER_NAME:latest $REPO_URL:latest
# Push image
docker push $REPO_URL:latest
IAM
data "aws_iam_policy_document" "default" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "default" {
name = "${local.role_name}"
assume_role_policy = data.aws_iam_policy_document.default.json
tags = {
Name = "${var.tag_name}-repository"
group = "${var.tag_group}"
}
}
resource "aws_iam_role_policy_attachment" "default" {
role = aws_iam_role.default.name
policy_arn = "${local.ecs_task_execution_role_policy_arn}"
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
locals {
role_name = "${var.name_prefix}-role"
ecs_task_execution_role_policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
output "execution_role_arn" {
value = "${aws_iam_role.default.arn}"
}
Network
resource "aws_internet_gateway" "default" {
vpc_id = aws_vpc.default.id
tags = {
Name = "${var.tag_name}-repository"
group = "${var.tag_group}"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.default.id
tags = {
Name = "${var.tag_name}-route-table"
group = "${var.tag_group}"
}
}
resource "aws_route" "public" {
route_table_id = aws_route_table.public.id
gateway_id = aws_internet_gateway.default.id
destination_cidr_block = "0.0.0.0/0"
}
resource "aws_route_table_association" "public_a" {
route_table_id = aws_route_table.public.id
subnet_id = aws_subnet.public_a.id
}
resource "aws_route_table_association" "public_c" {
route_table_id = aws_route_table.public.id
subnet_id = aws_subnet.public_c.id
}
resource "aws_subnet" "public_a" {
cidr_block = "10.0.1.0/24"
vpc_id = aws_vpc.default.id
availability_zone = "ap-northeast-1a"
tags = {
Name = "${var.tag_name}-subnet-a"
group = "${var.tag_group}"
}
}
resource "aws_subnet" "public_c" {
cidr_block = "10.0.2.0/24"
vpc_id = aws_vpc.default.id
availability_zone = "ap-northeast-1c"
tags = {
Name = "${var.tag_name}-subnet-c"
group = "${var.tag_group}"
}
}
resource "aws_vpc" "default" {
cidr_block = local.vpc_cidr
tags = {
Name = "${var.name_prefix}-vpc"
group = "${var.tag_group}"
}
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
# Internet Gateway
locals {
vpc_cidr = "10.0.0.0/16"
}
output "vpc_id" {
value = "${aws_vpc.default.id}"
}
output "public_a_id" {
value = "${aws_subnet.public_a.id}"
}
output "public_c_id" {
value = "${aws_subnet.public_c.id}"
}
Security Group
セキュリティグループはインバウンドルールとアウトバウンドルールを一つずつ作成します。
内部の通信を許可したい場合は、インバウンドルールで自身のセキュリティグループを設定することを忘れないで下さい。
resource "aws_security_group" "default" {
name = "${local.sg_name}"
vpc_id = "${var.vpc_id}"
tags = {
Name = "${var.tag_name}-cluster"
group = "${var.tag_group}"
}
}
resource "aws_security_group_rule" "ingress_http_myip" {
from_port = "80"
to_port = "80"
protocol = "tcp"
security_group_id = aws_security_group.default.id
type = "ingress"
cidr_blocks = ["${var.sg_ingress_ip_cidr}"]
}
resource "aws_security_group_rule" "ingress_sg_all" {
from_port = 0
to_port = 0
protocol = "-1"
security_group_id = aws_security_group.default.id
source_security_group_id = aws_security_group.default.id
type = "ingress"
}
resource "aws_security_group_rule" "egress_all_all" {
from_port = 0
to_port = 0
protocol = "-1"
security_group_id = aws_security_group.default.id
type = "egress"
cidr_blocks = ["0.0.0.0/0"]
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
# SG
variable sg_ingress_ip_cidr {}
variable vpc_id {}
locals {
sg_name = "${var.name_prefix}-sg"
}
output "sg_id" {
value = "${aws_security_group.default.id}"
}
Cloud Watch
resource "aws_cloudwatch_log_group" "default" {
name = "${local.logs_group_name}"
retention_in_days = "${local.retention_in_days}"
tags = {
Name = "${var.tag_name}-logs"
group = "${var.tag_group}"
}
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
locals {
logs_group_name = "/ecs/${var.name_prefix}-service"
retention_in_days = 30
}
output "logs_group_name" {
value = "${local.logs_group_name}"
}
ALB
resource "aws_lb" "default" {
name = "${local.alb_name}"
load_balancer_type = "application"
internal = false
idle_timeout = 60
enable_deletion_protection = false
subnets = [
"${var.public_a_id}",
"${var.public_c_id}",
]
security_groups = [
"${var.sg_id}"
]
tags = {
Name = "${var.tag_name}-alb"
group = "${var.tag_group}"
}
}
resource "aws_alb_listener" "default" {
load_balancer_arn = aws_lb.default.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.default.arn
}
}
depends_on
でリソース間の依存関係を明示的に書くこともできます。
resource "aws_lb_target_group" "default" {
name = "${local.tg_name}"
vpc_id = "${var.vpc_id}"
target_type = "ip"
port = 80
protocol = "HTTP"
deregistration_delay = 300
health_check {
path = "/"
healthy_threshold = 5
unhealthy_threshold = 2
timeout = 5
interval = 30
matcher = 200
port = "traffic-port"
protocol = "HTTP"
}
depends_on = [aws_lb.default]
}
# Global
variable region {}
variable name_prefix {}
# Tags
variable tag_name {}
variable tag_group {}
# ALB
variable public_a_id {}
variable public_c_id {}
variable sg_id {}
# Target Group
variable "vpc_id" {}
locals {
alb_name = "${var.name_prefix}-alb"
tg_name = "${var.name_prefix}-tg"
}
output "dns_name" {
value = "${aws_lb.default.dns_name}"
}
output "tg_arn" {
value = "${aws_lb_target_group.default.arn}"
}
ECS
ECSはクラスター・サービス・タスク定義をそれぞれリソースとして作成します。
resource "aws_ecs_cluster" "default" {
name = "${local.ecs_cluster_name}"
tags = {
Name = "${var.tag_name}-cluster"
group = "${var.tag_group}"
}
}
resource "aws_ecs_service" "default" {
name = "${local.service_name}"
cluster = aws_ecs_cluster.default.id
task_definition = aws_ecs_task_definition.default.arn
desired_count = "${local.service_count}"
launch_type = "${local.task_requires_compatibilities}"
load_balancer {
target_group_arn = "${var.tg_arn}"
container_name = "${local.service_name}"
container_port = "${var.webapp_port}"
}
network_configuration {
subnets = [
"${var.public_a_id}",
"${var.public_c_id}",
]
security_groups = [
"${var.sg_id}"
]
assign_public_ip = true
}
}
data
で外部のファイルなどを参照専用で読み込むことが出来ます。
ここでは、タスク定義のJSONファイルを読み込んでいます。vars
で変数を定義してJSONファイル内で${}
を用いることで、置換変数を使用できます。
data "template_file" "default" {
template = file("${local.task_definitions_filepath}")
vars = {
SERVICE_NAME = "${local.service_name}"
ECR_ARN = "${var.ecr_repository_uri}"
LOGS_GROUP_NAME = "${var.logs_group_name}"
LOG_DRIVER = "${local.task_log_driver}"
REGION = "${var.region}"
}
}
resource "aws_ecs_task_definition" "default" {
container_definitions = "${data.template_file.default.rendered}"
family = "${local.task_definitions_name}"
cpu = "${local.task_cpu}"
memory = "${local.task_memory}"
network_mode = "${local.task_network_mode}"
requires_compatibilities = ["${local.task_requires_compatibilities}"]
execution_role_arn = "${var.execution_role_arn}"
tags = {
Name = "${var.tag_name}-task"
group = "${var.tag_group}"
}
}
# Global
variable region {}
variable name_prefix {}
variable webapp_port {}
# Tags
variable tag_name {}
variable tag_group {}
# Task
variable ecr_repository_uri {}
variable execution_role_arn {}
# Service
variable logs_group_name {}
variable tg_arn {}
variable public_a_id {}
variable public_c_id {}
variable sg_id {}
locals {
ecs_cluster_name = "${var.name_prefix}-cluster"
task_definitions_filepath = "${path.module}/task_definition.json"
task_definitions_name = "${var.name_prefix}-task"
task_cpu = 256
task_memory = 512
task_log_driver = "awslogs"
task_network_mode = "awsvpc"
task_requires_compatibilities = "FARGATE"
service_name = "${var.name_prefix}-service"
service_count = 1
}
TerraformはJSONに変数渡すときは文字列に変換しますので、containerPortとhostPortのような数値は直接指定します。ここではwebapp_portと同じポートを指定してください。
[
{
"name": "${SERVICE_NAME}",
"image": "${ECR_ARN}",
"essential": true,
"portMappings": [
{
"containerPort": xxxx,
"hostPort": xxxx
}
],
"logConfiguration": {
"logDriver": "${LOG_DRIVER}",
"options": {
"awslogs-region": "${REGION}",
"awslogs-group": "${LOGS_GROUP_NAME}",
"awslogs-stream-prefix": "${SERVICE_NAME}"
}
}
}
]
動作確認
terraformフォルダの直下でterraform apply
を実行します。
terraform apply
そうすると、どのようなリソースが作成されるかが出てきます。
想定通りならばyes
を入力しましょう。
(エラーが出た場合は適宜解決しましょう)
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.alb.aws_alb_listener.default will be created
+ resource "aws_alb_listener" "default" {
+ arn = (known after apply)
+ id = (known after apply)
+ load_balancer_arn = (known after apply)
+ port = 80
+ protocol = "HTTP"
+ ssl_policy = (known after apply)
...中略
+ security_group_id = (known after apply)
+ self = false
+ source_security_group_id = (known after apply)
+ to_port = 22
+ type = "ingress"
}
Plan: 24 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
Apply complete!
とともに追加されたリソースの数、更新されたリソースの数、削除されたリソースの数が出てきます。
初回ならばadded
以外は0です。
Apply complete! Resources: 24 added, 0 changed, 0 destroyed.
terraformフォルダの直下にterraform.tfstate
ファイルが作成されますので、その中からALBのDNS名を探します。dns_name
で検索すると見つかります。
...
"customer_owned_ipv4_pool": "",
"dns_name": "xxxx-xxxx-alb-xxxxxxxxx.ap-northeast-1.elb.amazonaws.com",
"drop_invalid_header_fields": false,
...
ブラウザなどでアクセスしてWebアプリに接続できれば成功です。
カイゼン案
今回は自身がバッチで実装した処理をTerraformにリプレイスしたこともあり、いくつかカイゼンすべき点を3つまとめてみました。
1. ECRへのアプリケーションコンテナイメージプッシュとECSのサービスデプロイはその他のリソース作成と分離する
⇒インフラ構築とアプリケーションのデプロイはライフサイクルが別のため
2. tfstate(Terraformがリソースの状態管理を行っている)ファイルは、backenf.tfを作成してS3に保存する
⇒破損するとリソースを管理できなくなる可能性があるため
3. ブルーグリーンデプロイに対応するため、ターゲットグループを二つ用意して、切り替えを行えるようにする
⇒アプリのデプロイ時のダウンタイムをなくすため
さいごに
初めてインフラをコード化しましたが、次からは必ず使うだろうなと思ってしまうくらい管理が楽です。
次は、今回で学んだカイゼン案を取り込みつつ、AzureやGCPなど他のクラウドサービスでもTerraformを使ったり、Terraform以外のIaCツールを勉強して比較したりもしてみたいです。
参考
Terraform 入門
TerraformでさくっとFargateを構築する
terraformのmoduleで定義したresouseにアクセスするにはoutputしないとダメ
TerraformでAWS ECRのリポジトリを作成してpushする