前提条件
最近流行っているTerraformをとりあえず始めてみたい人向け。
実行環境のEC2インスタンスは、t2.microなAmazon Linux2を使う。
CloudFormation等でIaCの基礎的な部分を理解している方が、スムースに読み進められると思う。
インストール
とりあえず、インストールしたいバージョンを調べる。
$ curl https://releases.hashicorp.com/terraform/
HTMLで出てくるけどこれくらいなら読めるでしょ。
で、目的のバージョンをwgetで取得する。今回は0.12.25
を選択した前提。
$ wget https://releases.hashicorp.com/terraform/0.12.25/terraform_0.12.25_linux_amd64.zip
unzipすればインストール完了。超簡単。
$ sudo unzip terraform_0.12.25_linux_amd64.zip -d /usr/local/bin
あとは、実行に必要な環境変数を設定すれば準備OK。
AWS_ACCESS_KEY_ID=xxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxx
AWS_DEFAULT_REGION=ap-northeast-1
過去に作ったCloudFormationテンプレートをTerraformに移植してみる
以下の記事で、以前CloudFormationで作ったものを、Terraformで実装する。
前提は同じく、VPCやIAMロール/ポリシやECRは事前に準備しているものとする。
CloudFormationテンプレートを1からしっかり理解しながらECS on Fargateなアプリを自動構築する
まずやってみる
IaCは一息で書かないというベストプラクティスに従い、とりあえずこんな感じでtfファイルを分割してみよう。
Terraform
├── alb.tf
├── ecs.tf
├── loggroup.tf
└── main.tf
で、IaCを書いてはデバッグしていて気付いたことがある。
TerraformはCloudFormationと違って、自動でロールバックしないし、同一ディレクトリ配下においては、tfファイルが違っても一息で構築するようだ。
しかも、修正してterraform apply
しようとしても、エラーで更新できないケースがあるから一旦terraform destroy
せざるを得ない。
つまり、この構成の場合、ALBが正しく作れていても、その後でコケてしまうと、ALBもろとも破壊しないと修正できないケースがある。うーむ、これはよろしくない……。
ということで、ここまでやって「我流はマズいと思い至り、書籍を読む。
実践Terraform AWSにおけるシステム設計とベストプラクティス
素晴らしすぎてこの本さえ読んでおけばこの先の記事は不要な気がするが、一応書いておく。
tfstateの分割
まあ、↑のように分けるべきものはちゃんとtfstateが分かれるような構成にしておきましょうと。
知ってしまえば当然の内容であった。
というわけで、以下のような構成に修正した。
ちゃんとモジュール単位で分割しろよー、とか、変数をmain.tfに定義しすぎとか、色々ツッコミどころはあるけど、とりあえず今回はこれで。
Terraform
├── ECSFargate
│ ├── ecs.tf
│ ├── loggroup.tf
│ └── main.tf
└── Network
├── alb.tf
└── main.tf
さて、これで色々を始めると、Network
側で定義したALBやターゲットグループのArnをどうやって渡すかで悩むことになるのだが、その辺の話も全部『実践Terraform』には書かれていた。
今回は、ECSFargate側のmain.tfで、以下の様にデータソースを定義することにした。
variable "prefix" {
default = "ECSFargate"
}
data "aws_alb_target_group" "ecsfargate_tg" {
name = "${var.prefix}-TG1"
}
データソースとは、ポインタのようなもので、他人の作ったリソースを、自分のtfstateの範囲内で参照可能にするというもの。指定したキーにヒットしたリソースを参照可能になるようだ。
このALBターゲットグループのARNを参照したい場合は、以下のように使えば良い。
"${data.aws_alb_target_group.ecsfargate_tg.arn}"
ちなみに、変数は
variable "変数名" {
default = "デフォルト値"
}
で定義して、
"${var.変数名}"
で参照が可能。
この辺は、他人の作ったリソースの参照の敷居が非常に高いCloudFormationと比べると便利な点ではあると感じた。
出来上がったIaC
以下のようになった。main.tfのaws_vpc
とaws_subnet_ids
とaws_security_group
については、前述の「あらかじめ用意したリソース」である。
サブネットに指定しているflatten
については、リスト構造を正規化する組み込み関数。
やっぱり、作成済みのリソースにアクセスできるのが強い。
CloudFormationと違って、そのリソースを削除する際の依存関係を定義できないのがリスク(アプリの性質上仕方がないのだろうけど)。その辺は、ルールでちゃんと決めれば問題なくなるはず。
variable "prefix" {
default = "ECSFargate"
}
data "aws_vpc" "default_vpc" {
default = "true"
}
data "aws_subnet_ids" "default_subnets" {
vpc_id = "${data.aws_vpc.default_vpc.id}"
}
data "aws_security_group" "webaccess_sg" {
name = "予め用意したセキュリティグループ"
}
resource "aws_alb" "ecsfargate_alb" {
name = "${var.prefix}-ALB"
load_balancer_type = "application"
subnets = flatten(["${data.aws_subnet_ids.default_subnets.ids}",])
security_groups = [
"${data.aws_security_group.webaccess_sg.id}",
]
}
resource "aws_alb_listener" "ecsfargate_listener1" {
load_balancer_arn = aws_alb.ecsfargate_alb.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_alb_target_group.ecsfargate_tg1.arn
}
}
resource "aws_alb_target_group" "ecsfargate_tg1" {
name = "${var.prefix}-TG1"
port = "80"
protocol = "HTTP"
target_type = "ip"
vpc_id = "${data.aws_vpc.default_vpc.id}"
}
resource "aws_alb_listener" "ecsfargate_listener2" {
load_balancer_arn = aws_alb.ecsfargate_alb.arn
port = "8080"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_alb_target_group.ecsfargate_tg2.arn
}
}
resource "aws_alb_target_group" "ecsfargate_tg2" {
name = "${var.prefix}-TG2"
port = "80"
protocol = "HTTP"
target_type = "ip"
vpc_id = "${data.aws_vpc.default_vpc.id}"
}
variable "prefix" {
default = "ECSFargate"
}
variable "container_image_name" {
default = "[デプロイしたコンテナイメージ名(リポジトリ名)]"
}
variable "container_image_tag" {
default = "[デプロイしたコンテナのタグ]"
}
data "aws_caller_identity" "self" {
}
data "aws_region" "current" {
}
data "aws_alb_target_group" "ecsfargate_tg" {
name = "${var.prefix}-TG1"
}
data "aws_vpc" "default_vpc" {
default = "true"
}
data "aws_subnet_ids" "default_subnets" {
vpc_id = "${data.aws_vpc.default_vpc.id}"
}
data "aws_security_group" "webaccess_sg" {
name = "[予め用意したセキュリティグループ]"
}
data "aws_iam_role" "taskexecution_role" {
name = "[予め用意したタスク実行ロール]"
}
data "aws_ecr_repository" "application" {
name = "${var.container_image_name}"
}
resource "aws_cloudwatch_log_group" "ecsfargate_log_group"{
name = "/ecs/${var.prefix}-LogGroup"
}
resource "aws_ecs_cluster" "ecsfargate_cluster" {
name = "${var.prefix}-ECSCluster"
}
resource "aws_ecs_task_definition" "ecsfargate_taskdefinition" {
family = "${var.prefix}-Task"
task_role_arn = "${data.aws_iam_role.taskexecution_role.arn}"
execution_role_arn = "${data.aws_iam_role.taskexecution_role.arn}"
network_mode = "awsvpc"
cpu = "256"
memory = "1024"
requires_compatibilities = [
"FARGATE",
]
container_definitions = <<EOF
[
{
"name" : "${var.prefix}-Container",
"image": "${data.aws_ecr_repository.application.repository_url}:${var.container_image_tag}",
"cpu": 0,
"memoryReservation": 512,
"portMappings": [
{
"containerPort": 8080,
"hostPort": 8080,
"protocol": "tcp"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"secretOptions": null,
"options": {
"awslogs-group": "${aws_cloudwatch_log_group.ecsfargate_log_group.name}",
"awslogs-region": "${data.aws_region.current.name}",
"awslogs-stream-prefix": "ecs"
}
}
}
]
EOF
}
resource "aws_ecs_service" "ecsfargate_service" {
name = "${var.prefix}-ECSService"
cluster = "${aws_ecs_cluster.ecsfargate_cluster.id}"
launch_type = "FARGATE"
task_definition = "${aws_ecs_task_definition.ecsfargate_taskdefinition.arn}"
desired_count = 1
load_balancer {
target_group_arn = "${data.aws_alb_target_group.ecsfargate_tg.arn}"
container_name = "${var.prefix}-Container"
container_port = 8080
}
deployment_controller {
type = "CODE_DEPLOY"
}
network_configuration {
subnets = flatten(["${data.aws_subnet_ids.default_subnets.ids}",])
security_groups = [
"${data.aws_security_group.webaccess_sg.id}",
]
assign_public_ip = "true"
}
}
CodePipelineもTerraformで書いてみる。
CloudFormationでECS FargateのBlue/Greenデプロイメントについて書いた時は、CloudFormationが対応していなくて泣く泣くCLIで作った部分だが、Terraformはしっかり対応している(というか、おそらくTerraformは裏でCLIを投げているだけだと考えているので、それができるなら当然対応できるという話ではある)。
ディレクトリ構成は以下のようにする。追加したCICDPipeline以外は特に変更はしていない。
codebuild, codedeploy, s3 くらいは tf を分割しておくべきだった気がする…。
Terraform/
├── CICDPipeline ★追加
│ ├── codepipeline.tf ★追加
│ └── main.tf ★追加
├── ECSFargate
│ ├── ecs.tf
│ ├── loggroup.tf
│ └── main.tf
└── Network
├── alb.tf
└── main.tf
######################################################################
# 変数定義 #
######################################################################
variable "prefix" {
default = "ECSFargate"
}
######################################################################
# データソース(IAM) #
######################################################################
data "aws_iam_role" "codebuild" {
name = "testProject-service-role"
}
data "aws_iam_role" "codedeploy" {
name = "ecsCodeDeployRole"
}
data "aws_iam_role" "codepipeline" {
name = "CodePipelineRole"
}
######################################################################
# データソース(ALB) #
######################################################################
data "aws_lb" "ecsfargate" {
name = "${var.prefix}-ALB"
}
data "aws_lb_listener" "prod" {
load_balancer_arn = "${data.aws_lb.ecsfargate.arn}"
port = 80
}
data "aws_alb_target_group" "prod" {
name = "${var.prefix}-TG1"
}
data "aws_lb_listener" "test" {
load_balancer_arn = "${data.aws_lb.ecsfargate.arn}"
port = 8080
}
data "aws_alb_target_group" "test" {
name = "${var.prefix}-TG2"
}
######################################################################
# データソース(ECS) #
######################################################################
data "aws_ecs_cluster" "fargate" {
cluster_name = "${var.prefix}-ECSCluster"
}
data "aws_ecs_service" "fargate" {
service_name = "${var.prefix}-ECSService"
cluster_arn = "${data.aws_ecs_cluster.fargate.arn}"
}
resource "aws_s3_bucket" "artifact" {
bucket = lower("${var.prefix}-artifact-bucket")
}
resource "aws_codebuild_project" "application" {
name = "${var.prefix}-build-project"
service_role = "${data.aws_iam_role.codebuild.arn}"
source {
type = "CODEPIPELINE"
buildspec = "buildspec_container.yml"
}
artifacts {
type = "CODEPIPELINE"
}
environment {
type = "LINUX_CONTAINER"
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:3.0-19.11.26"
privileged_mode = "true"
}
cache {
type = "LOCAL"
modes = [
"LOCAL_CUSTOM_CACHE",
]
}
}
resource "aws_codedeploy_app" "application" {
name = "${var.prefix}-Application"
compute_platform = "ECS"
}
resource "aws_codedeploy_deployment_group" "application" {
deployment_group_name = "${var.prefix}-DeploymentGroup"
app_name = "${aws_codedeploy_app.application.name}"
service_role_arn = "${data.aws_iam_role.codedeploy.arn}"
deployment_config_name = "CodeDeploy.ECSCanaryHalf3minutes"
ecs_service {
cluster_name = "${data.aws_ecs_cluster.fargate.cluster_name}"
service_name = "${data.aws_ecs_service.fargate.service_name}"
}
deployment_style {
deployment_type = "BLUE_GREEN"
deployment_option = "WITH_TRAFFIC_CONTROL"
}
blue_green_deployment_config {
deployment_ready_option {
action_on_timeout = "CONTINUE_DEPLOYMENT"
wait_time_in_minutes = 0
}
terminate_blue_instances_on_deployment_success {
action = "TERMINATE"
termination_wait_time_in_minutes = "60"
}
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route {
listener_arns = ["${data.aws_lb_listener.prod.arn}"]
}
target_group {
name = "${data.aws_alb_target_group.prod.name}"
}
test_traffic_route {
listener_arns = ["${data.aws_lb_listener.test.arn}"]
}
target_group {
name = "${data.aws_alb_target_group.test.name}"
}
}
}
}
resource "aws_codepipeline" "pipeline" {
name = "${var.prefix}-Pipeline"
role_arn = "${data.aws_iam_role.codepipeline.arn}"
artifact_store {
type = "S3"
location = "${aws_s3_bucket.artifact.bucket}"
}
stage {
name = "Source"
action {
run_order = 1
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["SourceArtifact"]
configuration = {
RepositoryName = "testProject"
BranchName = "master"
}
}
}
stage {
name = "Build"
action {
run_order = 2
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["SourceArtifact"]
output_artifacts = ["BuildArtifact"]
configuration = {
ProjectName = "${aws_codebuild_project.application.name}"
}
}
}
stage {
name = "Deploy"
action {
run_order = 3
name = "Deploy"
category = "Deploy"
owner = "AWS"
provider = "CodeDeployToECS"
version = "1"
input_artifacts = [
"SourceArtifact",
"BuildArtifact",
]
configuration = {
ApplicationName = "${aws_codedeploy_app.application.name}"
DeploymentGroupName = "${aws_codedeploy_deployment_group.application.deployment_group_name}"
AppSpecTemplateArtifact = "SourceArtifact"
AppSpecTemplatePath = "appspec_container.yml"
TaskDefinitionTemplateArtifact = "SourceArtifact"
Image1ArtifactName = "BuildArtifact"
Image1ContainerName = "IMAGE1_NAME"
}
}
}
}
あと、CodeDeployの「CodeDeploy.ECSCanaryHalf3minutes」の部分は、ちょっとテキトー。
今回の設定だと、Canaryとテストポートが混在していることになる。
この場合、どうやらCodeDeployは、ProdのトラフィックをCanaryの設定を適用し、Test側のトラフィックは100%新しいアプリケーションに振り分けているように見えた。
その他
Terraformの公式リファレンスの歩き方
Terraformの公式リファレンス、全部英語ではあるものの、中身は少ないのでそんなに読むのは苦にならない。逆に、中身が少ないから「これどうしたらええねん……」みたいなことがある。
例えば、今回のパイプラインで言えば
ecs_service {
cluster_name = "${data.aws_ecs_cluster.fargate.cluster_name}"
service_name = "${data.aws_ecs_service.fargate.service_name}"
}
と
configuration = {
RepositoryName = "testProject"
BranchName = "master"
}
の2種類の書き方が登場する点。
当然、間違えるとちゃんと動いてくれない(とは言え、terraform plan
の時点でエラーになってくれるので、デバッグはそんなに苦ではないのだけど)。
これは、前者を「block」「blocks」、後者を「map」として使い分けしているっぽい。
何も書いていない場合は、前者。というか、後者を使うパターンが圧倒的に少ない。
今回の例で言えば、前者は
- ecs_service - (Optional) Configuration block(s) of the ECS services for a deployment group (documented below).
となり、後者は
- configuration - (Optional) A Map of the action declaration's configuration. Find out more about configuring action configurations in the Reference Pipeline Structure documentation.
と記載されている。