先日、Terraformを使って、ECSのクラスタを構築する機会がありましたので、やったことをメモとして残したいと思います。開始当初の知識レベルは「EC2完全に理解した(※)」「VPC?なにそれおいしいの」という状態です。Terraformは存在自体知りませんでした。従って、間違ってること言ってたらご指摘頂けるとありがたいです。
tl;dr
書かないこと
ECSやTerraform自体の説明や導入手順については参考にしたサイトを貼るだけに留めて割愛します。
- https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
- https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- https://qiita.com/NewGyu/items/9597ed2eda763bd504d7
- https://buildersbox.corp-sansan.com/entry/2020/01/28/110000
- https://hub.docker.com/r/zenika/terraform-aws-cli
Terraform準備
TerraformはCLIコマンドを実行したディレクトリの.tfファイルを参照してそこに書いてあるリソースを作成します。自動的に参照されるのはコマンドを実行したディレクトリの.tfファイルのみで、上位はもちろん、サブディレクトリも明示的に呼ばない限りは読み込まれません。今回は小難しい設定はなしにして最低限の初期設定だけ行います。
provider "aws" {
region = "ap-northeast-1"
}
まずはプロバイダです。これでAWS用のプラグインが読み込まれ、AWSのリソースを作成できるようになります。
terraform {
required_version = ">= 0.12"
}
こちらは実行するバージョンを限定できます。上記で"0.12より上のバージョンでのみ動作する"ということを明示します。
作り込むと実行結果(作成されたリソースのARNとか)をS3に保存してリモートステータスとして参照とかできますが、割愛します。
2つのファイルを作成したらterraform init
を実行してみましょう。実行するために必要なファイルが色々作成されます。
ECRリポジトリ
Terraformの準備が整ったところで、作成するリソースをゴリゴリ書いていきます。まずはECRのリポジトリから作ります。ECRは、DockerHubのようにAWSが提供しているコンテナイメージのレジストリです。DockerHubのイメージそのまま使ってもいいのですが、せっかくなのでこちらにイメージを作ってそこからpullしたいと思います。
リポジトリ作成
早速、コンテナイメージを格納するリポジトリをECRにを作成します。
resource "aws_ecr_repository" "nginx" {
name = "nginx"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
image_tag_mutability
は一度作成したTagの上書きの可否です。
image_scanning_configuration.scan_on_push
はイメージをプッシュした際に、脆弱性を診断してくれます。
リポジトリを作成したらイメージをプッシュします。AWS CLIを使うとECRへのログインコマンドを作ってくれますので、以下でECRにログインします。
aws ecr get-login --region ap-northeast-1 --no-include-email | bash
あとはDockerイメージをビルドしてプッシュするだけです。イメージ名は作成したECRリポジトリのURIにしてください。
URI="************.dkr.ecr.[region].amazonaws.com/[image]"
docker build -t $URI .
docker push $URI:latest
VPCエンドポイント作成
次に、VPCエンドポイントを作成します。これがないと、VPCの中からAWSのサービスを参照できませんので、ECRからイメージをpullできません。
...
resource "aws_vpc_endpoint" "ecr" {
vpc_id = aws_vpc.main.id
subnet_ids = [aws_subnet.main.id]
service_name = "com.amazonaws.ap-northeast-1.ecr.dkr"
vpc_endpoint_type = "Interface"
security_group_ids = [
aws_security_group.allows_tls.id
]
policy = <<EOF
{
"Statement": [
{
"Action": "*",
"Effect": "Allow",
"Resource": "*",
"Principal": "*"
}
]
}
EOF
}
...
更に、ECRはイメージの実体をS3に保存してますので、イメージをダウンロードするためにS3のVPCエンドポイントも必要です。
...
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.ap-northeast-1.s3"
vpc_endpoint_type = "Gateway"
policy = <<EOF
{
"Statement": [
{
"Action": [
"s3:GetObject"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::prod-region-starport-layer-bucket/*"
],
"Principal": "*"
}
]
}
EOF
}
...
この時、リソース名を制限する場合はarn:aws:s3:::prod-region-starport-layer-bucket/*
を許可するようにしてください。
ECSタスク定義
タスク定義は、ECS上でどんなコンテナを動かすのかを決めるところです。docker-compose.yamlを作るイメージに近いと思います。
タスク定義
resource "aws_ecs_task_definition" "nginx" {
family = "nginx"
container_definitions = templatefile("container_definition.json", {
repository_url = aws_ecr_repository.nginx.repository_url
log_group_name = aws_cloudwatch_log_group.nginx_log.name
})
task_role_arn = aws_iam_role.task.arn
execution_role_arn = aws_iam_role.task_execution.arn
requires_compatibilities = ["EC2"]
network_mode = "awsvpc"
}
family
がタスク定義の名前です。container_definition
はどんなコンテナを動かすのかをJSONで書きます(後述)。
分かりづらいところで、ロールの割り当てがtask_role_arn
とexecution_role_arn
の2つがあります。execution_role_arn
はタスクを実行するプロセスに割り当てられるロールで、ECRにあるイメージを使う場合はECRへの参照権限が必要です。基本的にはデフォルトで用意されているAmazonECSTaskExecutionRolePolicy
がアタッチされてるロールであれば問題ないと思います。task_role_arn
は実行されたタスクに割り当てられるロールです。例えば、起動したコンテナの中で稼働するアプリが、S3にファイルを書き込んだりしてる場合は、S3への書き込み権限を持ったロールを割り当てる必要があります。
requires_compatibilities
でこのタスク定義がどの起動タイプで起動できるかを設定します。今回はEC2でのみ起動可能なタスクとして設定してます。
network_mode
はタスクのネットワークドライバを指定します。docker-compose.yamlによく書かれてるのはbridge
ですが、今回はawsvpc
を使います。awsvpc
にすると、タスクに直接ENIがアタッチされ、タスクにサブネットのIPアドレスが振られます。注意点として、EC2を使う場合、インスタンスタイプ毎のENI上限に引っかからないようにする必要があります。具体例として、t2.small
のインスタンスタイプを使ってるEC2インスタンス上の場合、上限は2なので、3つ目のタスクを起動しようとするとコンテナの起動に失敗します。この上限はECSのアカウント設定で緩和できるので忘れずにしておきましょう。以下のコマンドで設定できます。
aws ecs put-account-setting --name awsvpcTrunking --value enabled --region a-^northeast-1
緩和後の上限などについては以下がわかりやすいです。
https://dev.classmethod.jp/articles/ecs-eni-limit-add/
コンテナ定義
[
{
"name": "nginx",
"image": "${repository_url}",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"memoryReservation": 512,
"logConfiguration": {
"logDriver": "awslogs",
"secretOptions": null,
"options": {
"awslogs-group": "${log_group_name}",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
}
},
"essential": true
}
]
image
は使用するイメージです。今回はECRのリポジトリなので、ECRのURLを書きます。DockerHubからpullする場合はイメージの名前だけで大丈夫です。
logConfiguration
を設定するとdocker logs
で出力される内容が、設定した場所に送信されるようになります。今回はCloudwatchのロググループに書き込むようにしてます。
portMappings
は基本的にはコンテナのポートだけ記載すれば問題ありません。awsvpc
を使う時はホストのポートが使われることはありません。bridge
等を使う場合も動的ポートマッピングを使ったほうがいいと思われますので、ホストのポートを指定しなくていいです。
また、コンテナの定義が配列なのでお察しかと思いますが、複数のコンテナを定義できます。同一タスク上のコンテナ同士はlocalhost
で通信可能です。
もっと詳しく設定内容について知りたい方は以下のリンクをご参照ください。
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition
ECSクラスタ
次にクラスタを作成していきます。クラスタ自体の設定内容はほぼないのですが、EC2を使う場合EC2インスタンス関連の設定が必要になります。Faegate使う場合途中から読み飛ばしても問題ないです。
クラスタ
locals {
cluster_name = "test-cluster"
}
resource "aws_ecs_cluster" "test" {
name = local.cluster_name
capacity_providers = [aws_ecs_capacity_provider.test.name]
default_capacity_provider_strategy {
capacity_provider = aws_ecs_capacity_provider.test.name
weight = 1
base = 0
}
}
capacity_providers
でこのクラスター内で使用できるキャパシティープロバイダーを指定します。キャパシティープロバイダーについての説明は後述します。
default_capacity_provider_strategy
でスケールイン・アウト時のタスクの配分を決められます。この数字が変わるとどうなるのかは、以下の記事の"Capacity Provider strategyによるタスク配分戦略"の項がわかりやすかったです。
https://dev.classmethod.jp/articles/regrwoth-capacity-provider/
ちなみにFargate使う場合こちらで用意する必要ありません。これだけでいいです。
locals {
cluster_name = "test-cluster"
}
resource "aws_ecs_cluster" "test" {
name = local.cluster_name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
capacity_provider = "FARGATE"
weight = 1
base = 0
}
}
キャパシティープロバイダー
起動タイプにEC2を使う場合、どんなEC2インスタンスをどのように起動して、どれくらい使うかという面倒な設定が必要になります。それがこのキャパシティープロバイダーです。
resource "aws_ecs_capacity_provider" "test" {
name = "ec2"
auto_scaling_group_provider {
auto_scaling_group_arn = aws_autoscaling_group.test.arn
managed_termination_protection = "ENABLED"
managed_scaling {
maximum_scaling_step_size = 1
minimum_scaling_step_size = 1
status = "ENABLED"
target_capacity = 100
}
}
}
EC2インスタンス自体の設定やスケールイン・アウトの設定はEC2のオートスケーリンググループを使います。managed_termination_protection
はコンテナがまだ起動してるのに、オートスケーリンググループによってEC2インスタンスを停止させられてしまうことをECSの方で防いでくれるようになります。
managed_scaling.target_capacity
はこのオートスケーリンググループによって起動されたEC2インスタンスのリソースを、どれくらいECSのタスクに割くかを設定します。上記だと100%ECSが専有します。
maximum|minimum_scaling_step_size
というのは一度のスケールイン・アウトでいくつコンテナを起動・終了させるかです。
オートスケーリンググループ
こちらはECSの機能ではないので、詳しい説明は省いて、ECSに関わる部分の注意点のみ説明します。
resource "aws_autoscaling_group" "test" {
name = "test"
launch_template {
id = aws_launch_template.test.id
version = "$Latest"
}
protect_from_scale_in = true
max_size = 1
min_size = 1
}
ポイントは2つです。
-
protect_from_scale_in
をtrueにすること。 -
launch_configuration
ではなくlaunch_template
を使うこと。
protect_from_scale_in
はキャパシティープロバイダーのmanaged_termination_protection
を有効にする場合はtrue
にする必要があります。launch_template
についてはこの後説明します。
起動テンプレート
これもECSの機能ではないので、詳しい説明は省きます。
resource "aws_launch_template" "test" {
name = "test"
image_id = data.aws_ssm_parameter.amzn2_for_ecs_ami.value
instance_type = "t2.micro"
ebs_optimized = true
user_data = base64encode(templatefile("./userdata.sh",
{
cluster_name = local.cluster_name
}))
block_device_mappings {
device_name = "/dev/sda1"
ebs {
volume_size = 40
volume_type = "gp2"
}
}
}
data "aws_ssm_parameter" "amzn2_for_ecs_ami" {
name = "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id"
}
こちらもポイントは2つです。
- AMIにECSに最適化されたイメージを使う。
-
user_data
でECSの設定を行う。
AMIにはECSに最適化されたイメージを使います。以下のページでどんなイメージがあるか確認できます。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs-ami-versions.html
user_data
の方ですが、まずテンプレートで読み込んでるファイルは以下の通りです。
#!/bin/bash
echo 'ECS_CLUSTER=${cluster_name}' >> /etc/ecs/ecs.config
何をやってるかというと、ECSのエージェントに自分が所属すべきクラスターの名前を教えています。これがないと、ECSのエージェントは所属するクラスター名をdefault
と判断します。作成したクラスター名はdefault
ではないので、これを設定しないと、起動したEC2インスタンスがクラスターに登録されず、コンテナを起動できません。オートスケーリンググループの作成時にlaunch_configuration
ではなくlaunch_template
を使ったのはこのuser_data
を設定する必要があるためです。
ECSサービス
ようやくクラスター内で稼働するサービスを作成します。
サービス
resource "aws_ecs_service" "nginx" {
name = "nginx"
cluster = aws_ecs_cluster.test.id
task_definition = aws_ecs_task_definition.nginx.arn
desired_count = 1
deployment_minimum_healthy_percent = 100
load_balancer {
target_group_arn = aws_lb_target_group.test.arn
container_name = "nginx"
container_port = 80
}
network_configuration {
subnets = [aws_subnet.main.id]
security_groups = [aws_security_group.allows_http]
}
}
desired_count
で稼働を維持させるタスクの数を指定します。
deployment_minimum_healthy_percent
は、サービスを更新してタスクをアップグレードする際、維持させるタスクの数をdesired_count
に対するパーセンテージで指定します。上記だと100%なので、1個はタスクを維持したまま新しいタスクを起動します。
WEBサービスの場合はロードバランサを指定します。同時に、ロードバランサと繋がるコンテナと、コンテナの通信を受け付けるポートを指定します。
タスク定義で設定したnetwork_mode
がawsvpc
の場合は、network_configuration
でタスクが所属するサブネットや適用するセキュリティグループ、パブリックIPをタスクに割り当てるかどうかを指定します。
オートスケーリンググループ
「オートスケーリンググループはさっき作ったじゃん。ボケた?」と思った方、安心してください。まだボケてません。
さっき作成したのはEC2インスタンスのオートスケーリンググループで、こちらはECSのサービスによって稼働するタスクのオートスケーリンググループです。
resource "aws_appautoscaling_target" "ecs_service" {
max_capacity = 2
min_capacity = 1
resource_id = "service/${aws_ecs_cluster.test.name}/${aws_ecs_service.nginx.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
そのままなので大体わかるかと思いますが、max|min_capacity
で稼働を維持するタスクの最低数と、スケールアウトする最大数を指定します。コンピューティングリソースではなく、別のメトリクスでスケールイン・アウトする場合はその設定も必要になります。
終わり
以上です。WEBサービスの場合は、この後ロードバランサーの設定やらRoute53やらCertificateやら色々やってインターネットからアクセスできるようにする必要が出てきます。AWSやインフラの知識がなかったので結構色んな罠にハマりました。ただ、ハマったおかげで色々知識として吸収できたのでそこは良かったです。次はEKSにチャレンジしたい。