AWS初心者がECS (Fargate) を使ってアプリ環境構築をしてみた話

AWS初心者がECS (Fargate) を使ってアプリ環境構築をしてみた話:


ごあいさつ

みなさんはじめまして、dev.aokiと申します。あっQiitaはTwitterアカウントか!まぁいっか。

IT系でふらふらと仕事をしておりまして、今年9月からオプトに入社しました。

入社前まではSIのプロジェクトやWindowsデスクトップアプリケーションの開発をしておりまして、

クラウドサービスを使ったWebアプリケーションを本格的にやるのは今回初めてでした。

そんなわけで色々と学びがありましたが、

特にネット上にやりたかったことドンピシャな情報が少なく自分が苦労した部分についてまとめて、

今後類似の構成で環境構築したい方の役に少しでも立てれば幸いかなと思い、記録がてら記事を書かせて頂いております。

Qiitaは閲覧専門だったので、書く側になるのはちょっとばかし緊張しております。

内容やら書き方やらに不備がございましたらご指摘いただけると幸いです。

どうぞよろしくお願いいたします。


この記事について

この記事では、AWSについてほぼズブの素人の私が

担当プロダクトの環境構築をして学んだことをまとめています。


構築環境の構成

こんな感じの環境構成を構築します。



AWS.png


担当プロダクトがSPAということで、フロントサーバ->APIサーバというアクセスではなく、

並立するような形になっています。

GitHubにTerraformによる環境構築コードを上げてあります。
こちら

フロントエンド、API、データベースに分かれている、いわゆる三層構成ですね。

弊社のプロダクトはSPA化が進んでいるので、APIについてはフロントのサーバからではなく

ユーザからアクセスできる必要があるのでこんな感じの図になっています。

フロントからしかAPIをアクセスしない場合は、セキュリティグループで制御した方がいいですね。

今回の記事で特に取り上げたいのはECSとFargateです。

ECSはともかく、Fargateは東京リージョンに来てまだ日が浅いので(※執筆日は2018年12月)

ネット上にまとまった記事が少なく、なかなか苦戦しました。


公式のドキュメント

Amazon Elastic Container Service とは
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/Welcome.html

Amazon ECS の AWS Fargate
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/AWS_Fargate.html


ECSのうれしみ

ECSのうれしみは、個人的にはスケールしやすいことではないかと思います。

物理サーバは論外にしても、EC2インスタンスにしたって気軽にボコボコ増やせばいいってもんではなく

色々面倒ごとがあるかと思います。

その点、ECSの場合は立ち上げるインスタンス数を設定するだけで同一内容のインスタンスが

ガガガーっと増えてくれるので、急なワークロード増加にも対応しやすいと思います。


Fargateのうれしみ

で、Fargateだと更に進み、ホストマシンの設定等に心を砕く必要がないです。

コンテナだけで動いてくれるので、開発環境でコンテナ化してあれば

ローカルと同じように動作するはずです、理屈としては。

いやー、便利な時代になりましたね……インフラそんなに得意じゃないんですが

そんなに抵抗なく進められそうです。


記事の範囲

今回は自分のタスクで担当していた以下の部分について記述します。

  • 目的を実現するためのAWSサービス構成
  • 各サービスの設定内容
  • 今回の構成をデプロイするためのTerraform記述
以下の部分については記述範囲外とします。

  • AWS各サービスの説明

  • Dockerコンテナの作り方

    • 白状すると私もまだ熟知してないorz...
  • Terraformの全般的な使い方


構築手順ごとに説明

それでは私がやった構築順序に沿って進めていきましょう。


接続設定

まずAWSに接続するための情報を記述します。

main.tf
terraform { 
  required_version = ">= 0.11.0" 
 
  backend "s3" { 
    region  = "ap-northeast-1" 
    profile = "hogehoge" 
    bucket  = "fugafuga" 
    key     = "piyopiyo/terraform.tfstate" 
  } 
} 
Terraformの設定です。

特筆すべきはbackendの部分でしょうか。

何も書かなければローカルにtfstateというTerraformの管理するリソース状態を保存するファイルが

ローカルに保存されます。

個人開発ならこれで構いませんが、チームでやるとなると連携が面倒です。

今回の場合は事前にS3バケットを用意しておき、そこに保存するように設定しました。

これで他のメンバーとも設定が共有できることになります、よかったですね。

Backend
https://www.terraform.io/docs/backends/index.html

provider.tf
provider "aws" { 
  region  = "${var.region}" 
  profile = "${var.profile}" 
} 
こちらはAWSとの接続設定です。

アクセスキーとシークレットキーを直接書くこともできますが、

GitHubなどでコードを管理する場合は直接書きたくないですよね。

profile名を指定し、ローカルでのみ保持することにしています。

ちなみに${var.hogehoge}という書き方はTerraformの変数を呼んでいます。

変数はterraform.tfvarsというファイルで管理しています。

この状態でterraform initとコマンドを実行すると、Terraformでの作業ができる状態になります。

ようやくスタートラインですね、これだけでも割と苦労しました私。。。。


ネットワーク関連

さて、兎にも角にも足回りがそろわないと話になりません。

サクサク作りましょう。


VPC/サブネット

vpc.tf
resource "aws_vpc" "vpc" { 
  cidr_block           = "10.1.0.0/16" 
  enable_dns_support   = "true" 
  enable_dns_hostnames = "true" 
 
  tags = { 
    "Name"    = "${var.product}-${terraform.workspace}-vpc" 
    "Product" = "${var.product}" 
    "Env"     = "${terraform.workspace}" 
  } 
} 
subnet.tf
# Public-A 
resource "aws_subnet" "subnet-public-a" { 
  vpc_id            = "${aws_vpc.vpc.id}" 
  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block,8,1)}" 
  availability_zone = "ap-northeast-1a" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-subnet-public-a" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# Public-C 
resource "aws_subnet" "subnet-public-c" { 
  vpc_id            = "${aws_vpc.vpc.id}" 
  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block,8,2)}" 
  availability_zone = "ap-northeast-1c" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-subnet-public-c" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# Private-A 
resource "aws_subnet" "subnet-private-a" { 
  vpc_id            = "${aws_vpc.vpc.id}" 
  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block,8,65)}" 
  availability_zone = "ap-northeast-1a" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-subnet-private-a" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# Private-C 
resource "aws_subnet" "subnet-private-c" { 
  vpc_id            = "${aws_vpc.vpc.id}" 
  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block,8,66)}" 
  availability_zone = "ap-northeast-1c" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-subnet-private-c" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
今回はVPCを1つと、公開用サブネットと非公開用サブネットをそれぞれAZをap-northeast-a/cに1つずつで計4つ作成しました。

このあたりは構成図書いた時点で悩む必要もなしですね。

サブネットのCIDR定義をしているところにあるcidrsubnetっていうのがなかなか便利でして、

指定したCIDRのサブネットを自動計算してくれる機能です。

これを利用すれば、VPCのCIDRを変更したときにサブネットも自動追従してくれるようになります、便利ですね。
https://www.terraform.io/docs/configuration/interpolation.html#cidrsubnet-iprange-newbits-netnum-

余談ですが、${terraform.workspace}というのは、Terraformのワークスペース機能を用いて、

現在のワークスペース名を取得しています。

ワークスペースを使うと、開発用/ステージング用/本番用みたいな感じで同じコードを使いまわしつつ

別環境として管理することができて便利です。
https://www.terraform.io/docs/state/workspaces.html


ルーティング

先ほど、公開/非公開のサブネットと言いましたが、作成した時点でそのあたりの設定をしているわけではありません。

ルーティングをしてあげて初めて外の世界に行けるかどうかが決定します。

というわけでルートテーブルの設定をしましょう。

gateway.tf
resource "aws_internet_gateway" "main-gw" { 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-main-gw" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
外のネットワークへの出口となる、インターネットゲートウェイを作成しています。

routes.tf
# Default(Private) 
resource "aws_default_route_table" "default-route-table" { 
  default_route_table_id = "${aws_vpc.vpc.default_route_table_id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-default-route-table" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
resource "aws_route_table_association" "route-private-a" { 
  route_table_id = "${aws_default_route_table.default-route-table.id}" 
  subnet_id      = "${aws_subnet.subnet-private-a.id}" 
} 
 
resource "aws_route_table_association" "route-private-c" { 
  route_table_id = "${aws_default_route_table.default-route-table.id}" 
  subnet_id      = "${aws_subnet.subnet-private-c.id}" 
} 
 
# Public 
resource "aws_route_table" "public-route-table" { 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  route { 
    cidr_block = "0.0.0.0/0" 
    gateway_id = "${aws_internet_gateway.main-gw.id}" 
  } 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-public-route-table" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
resource "aws_route_table_association" "route-public-a" { 
  route_table_id = "${aws_route_table.public-route-table.id}" 
  subnet_id      = "${aws_subnet.subnet-public-a.id}" 
} 
 
resource "aws_route_table_association" "route-public-c" { 
  route_table_id = "${aws_route_table.public-route-table.id}" 
  subnet_id      = "${aws_subnet.subnet-public-c.id}" 
} 
はい、ここですね。

外向けのルートテーブルと、プライベートのみのルートテーブルを作成して、各サブネットを紐づけています。

厳密には、デフォルトルートテーブルという、VPCが作成されると自動的に一つ作られるテーブルをプライベートとして扱い、

特別に外に出られるルートテーブルを別途作成しています。

新たにサブネットを作成されたとき、そのサブネットはデフォルトルートテーブルに暗黙的に紐づけられるため、

何の気なしに作った新インスタンスが勝手に公開されないように、デフォルトの方を非公開にしています。

ちなみに、routeに何もつけないでおくと、デフォルトでVPC内に限りすべての通信ができるというような

ルート設定がされます。


セキュリティグループ

ルーティングで各サブネットの公開/非公開は設定できましたが、

セキュリティグループを使うともう少し細かくアクセスの制限をかけられます。

という訳でこちらも設定していきましょう。

security-groups.tf
# Default 
resource "aws_default_security_group" "security-group-default" { 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-security-group-default" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# for ALB 
resource "aws_security_group" "security-group-alb" { 
  name   = "${var.product}-${terraform.workspace}-security-group-alb" 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-security-group-alb" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# In:   All HTTP(port 80) 
resource "aws_security_group_rule" "security-group-alb-in-rule-http80" { 
  security_group_id = "${aws_security_group.security-group-alb.id}" 
  type              = "ingress" 
  from_port         = 80 
  to_port           = 80 
  protocol          = "tcp" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# In:   All HTTP(port 8080) 
resource "aws_security_group_rule" "security-group-alb-in-rule-http8080" { 
  security_group_id = "${aws_security_group.security-group-alb.id}" 
  type              = "ingress" 
  from_port         = 8080 
  to_port           = 8080 
  protocol          = "tcp" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# In:   All HTTPS 
resource "aws_security_group_rule" "security-group-alb-in-rule-https443" { 
  security_group_id = "${aws_security_group.security-group-alb.id}" 
  type              = "ingress" 
  from_port         = 443 
  to_port           = 443 
  protocol          = "tcp" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# Out:  ALL OK 
resource "aws_security_group_rule" "security-group-alb-out-rule-all" { 
  security_group_id = "${aws_security_group.security-group-alb.id}" 
  type              = "egress" 
  from_port         = 0 
  to_port           = 0 
  protocol          = "-1" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# for Frontend 
resource "aws_security_group" "security-group-front" { 
  name   = "${var.product}-${terraform.workspace}-security-group-front" 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-security-group-front" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# In:   ALB 
resource "aws_security_group_rule" "security-group-front-in-rule-alb" { 
  security_group_id        = "${aws_security_group.security-group-front.id}" 
  type                     = "ingress" 
  from_port                = 0 
  to_port                  = 0 
  protocol                 = "-1" 
  source_security_group_id = "${aws_security_group.security-group-alb.id}" 
} 
 
# Out:  ALL OK 
resource "aws_security_group_rule" "security-group-front-out-rule-all" { 
  security_group_id        = "${aws_security_group.security-group-front.id}" 
  type              = "egress" 
  from_port         = 0 
  to_port           = 0 
  protocol          = "-1" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# for API 
resource "aws_security_group" "security-group-api" { 
  name   = "${var.product}-${terraform.workspace}-security-group-api" 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-security-group-api" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# In:   ALB 
resource "aws_security_group_rule" "security-group-api-in-rule-alb" { 
  security_group_id        = "${aws_security_group.security-group-api.id}" 
  type                     = "ingress" 
  from_port                = 0 
  to_port                  = 0 
  protocol                 = "-1" 
  source_security_group_id = "${aws_security_group.security-group-alb.id}" 
} 
 
# Out:  ALL OK 
resource "aws_security_group_rule" "security-group-api-out-rule-all" { 
  security_group_id = "${aws_security_group.security-group-api.id}" 
  type              = "egress" 
  from_port         = 0 
  to_port           = 0 
  protocol          = "-1" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
 
# for RDS 
resource "aws_security_group" "security-group-rds" { 
  name   = "${var.product}-${terraform.workspace}-security-group-rds" 
  vpc_id = "${aws_vpc.vpc.id}" 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-security-group-rds" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# In:   API / MariaDB 
resource "aws_security_group_rule" "security-group-rds-in-rule-api" { 
  security_group_id        = "${aws_security_group.security-group-rds.id}" 
  type                     = "ingress" 
  from_port                = 3306 
  to_port                  = 3306 
  protocol                 = "tcp" 
  source_security_group_id = "${aws_security_group.security-group-api.id}" 
} 
 
# Out:  ALL OK 
resource "aws_security_group_rule" "security-group-rds-out-rule-all" { 
  security_group_id = "${aws_security_group.security-group-rds.id}" 
  type              = "egress" 
  from_port         = 0 
  to_port           = 0 
  protocol          = "-1" 
  cidr_blocks       = ["0.0.0.0/0"] 
} 
冒頭でも言いましたが、今回は以下のようなアクセスを許可するようにしています。

矢印が許可されるアクセスだと思って見てください。



AWS.png


セキュリティグループのIn/Out設定はサブネット単位またはセキュリティグループ単位で設定できます。

今回はセキュリティグループ単位で設定するようにしています。

ルールについては aws_security_group 内に記述することもできるのですが、

後からルールに変更があった場合に依存するインスタンスも煽りを受けて作り直しが発生したりします。

このため今回はルールごとに定義をし、付け外しの影響を受けないように作っています。


インスタンス

さて。長くなりましたが、これでようやく足回りができました。

ようやくインスタンス作りに着手しましょう。


ALB

ECSで動作するコンテナインスタンスは動的にスケーリングできます。

将来的に負荷が高まった場合にはロードバランスをしたいということもありそうなので、

事前に仕組みを作っておこうと思います。

alb.tf
# ALB 
 
resource "aws_alb" "alb" { 
  name               = "${var.product}-${terraform.workspace}-alb" 
  internal           = false 
  load_balancer_type = "application" 
  security_groups    = ["${aws_security_group.security-group-alb.id}"] 
  subnets            = ["${aws_subnet.subnet-public-a.id}", "${aws_subnet.subnet-public-c.id}"] 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-alb" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# ALB target group 
 
resource "aws_alb_target_group" "alb-target-group-front" { 
  name        = "${var.product}-${terraform.workspace}-alb-tg-front" 
  port        = 80 
  protocol    = "HTTP" 
  vpc_id      = "${aws_vpc.vpc.id}" 
  target_type = "ip" 
 
  health_check { 
    interval            = 60 
    path                = "/" 
    protocol            = "HTTP" 
    timeout             = 20 
    unhealthy_threshold = 4 
    matcher             = 200 
  } 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-alb-target-group-front" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
resource "aws_alb_target_group" "alb-target-group-api" { 
  name        = "${var.product}-${terraform.workspace}-alb-tg-api" 
  port        = 8080 
  protocol    = "HTTP" 
  vpc_id      = "${aws_vpc.vpc.id}" 
  target_type = "ip" 
 
  health_check { 
    interval            = 60 
    path                = "/api/authentication" 
    protocol            = "HTTP" 
    timeout             = 20 
    unhealthy_threshold = 4 
    matcher             = 200 
  } 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-alb-target-group-api" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
 
# ALB Listener 
resource "aws_alb_listener" "alb-listener-front" { 
  load_balancer_arn = "${aws_alb.alb.arn}" 
  port              = "80" 
  protocol          = "HTTP" 
 
  default_action { 
    target_group_arn = "${aws_alb_target_group.alb-target-group-front.arn}" 
    type             = "forward" 
  } 
} 
 
resource "aws_alb_listener" "alb-listener-api" { 
  load_balancer_arn = "${aws_alb.alb.arn}" 
  port              = "8080" 
  protocol          = "HTTP" 
 
  default_action { 
    target_group_arn = "${aws_alb_target_group.alb-target-group-api.arn}" 
    type             = "forward" 
  } 
} 
今回はフロントエンド用にHTTP 80番ポートへのアクセス、

API用にHTTP 8080番ポートへのアクセスの2つをターゲットグループとして用意しました。

ECSを使うなら、サービスディスカバリを使うという手もあります。

諸々の事情で今回は利用できませんでしたが、参考までにリンクを貼っておきます。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/service-discovery.html


RDS

コンテナベースでのアプリだと、サーバ内にデータを確保しておくわけにいかないので

永続データの確保が必要になりますね。

というわけで今回はRDSを利用する想定で立てておきます。

rds.tf
# Subnet group 
 
resource "aws_db_subnet_group" "rds-subnet-group" { 
  name       = "${var.product}-${terraform.workspace}-rds-subnet-group" 
  subnet_ids = ["${aws_subnet.subnet-private-a.id}", "${aws_subnet.subnet-private-c.id}"] 
} 
 
# Parameter group 
 
resource "aws_db_parameter_group" "rds-params" { 
  name   = "${var.product}-${terraform.workspace}-rds-params" 
  family = "mariadb10.2" 
} 
 
# Write DB 
 
resource "aws_db_instance" "rds" { 
  identifier              = "${var.product}-${terraform.workspace}-rds" 
  allocated_storage       = "${var.db_storage}" 
  storage_type            = "gp2" 
  engine                  = "mariadb" 
  engine_version          = "10.2.15" 
  instance_class          = "db.t2.micro" 
  name                    = "${var.db_name}" 
  username                = "${var.db_user}" 
  password                = "${var.db_pass}" 
  parameter_group_name    = "${aws_db_parameter_group.rds-params.name}" 
  db_subnet_group_name    = "${aws_db_subnet_group.rds-subnet-group.name}" 
  vpc_security_group_ids  = ["${aws_security_group.security-group-rds.id}"] 
  availability_zone       = "ap-northeast-1a" 
  multi_az                = false 
  backup_retention_period = 1 
  skip_final_snapshot     = true 
 
  tags { 
    Name    = "${var.product}-${terraform.workspace}-rds" 
    Product = "${var.product}" 
    Env     = "${terraform.workspace}" 
  } 
} 
あんまりややこしいことはないですね。

ユーザ名やパスワードを変数化してるぐらいでしょうか。


ECS

はい、ずいぶん後ろになってしまいました。

本題のECSの設定です。


ECR

こちらはFargateでコンテナを立ち上げるためのDockerイメージを格納するリポジトリです。

DockerHubを使うこともできますが、独自サービスを使うなら同じAWS上のこちらを使うのがいいかなと思います。

DockerHubのプライベートリポジトリからイメージをPullするのは認証とか細々要りますし。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/private-auth.html

ecr.tf
# Front 
resource "aws_ecr_repository" "repository-front" { 
  name = "${var.product}-front" 
 
  provisioner "local-exec" { 
    command = "build-image.sh front ${self.repository_url} ${self.name}" 
  } 
} 
 
# API 
resource "aws_ecr_repository" "repository-api" { 
  name = "${var.product}-api" 
 
  provisioner "local-exec" { 
    command = "build-image.sh api ${self.repository_url} ${self.name}" 
  } 
} 
定義するだけなので簡単ですね。

作成されたらコンテナイメージをプッシュする必要があります。

プッシュされていないと後段処理となるサービス立ち上げがうまくできなくなります。

これを避けるため、Provisionerを使って作成と同時にバージョン1となるイメージをプッシュしておきましょう。

私は build-image.sh というスクリプトを作成して実行させるようにしました。


IAM ロール

地味にハマりポイントだったのがこちら。

ECS上で動かすためのIAMロールを作ります。こちらはJSON形式で記述します。
.tf ファイルにベタで書く方法もありますが、見づらいので別ファイルに出してしまいましょう。

task-role.json
{ 
  "Version": "2012-10-17", 
  "Statement": [ 
    { 
      "Sid": "", 
      "Effect": "Allow", 
      "Principal": { 
        "Service": "ecs-tasks.amazonaws.com" 
      }, 
      "Action": "sts:AssumeRole" 
    } 
  ] 
} 
この定義を用いてIAM Roleを作成します。

くっつけるポリシーは今回はAWSが元々提供してくれている AmazonECSTaskExecutionRolePolicy

というものを使っています。

iam.tf
# Task role 
data "template_file" "task-role-template" { 
  template = "${file("${path.root}/iam/roles/task-role.json")}" 
} 
 
resource "aws_iam_role" "task-role" { 
  name                = "ecsTaskExecutionRole" 
  assume_role_policy = "${data.template_file.task-role-template.rendered}" 
} 
 
resource "aws_iam_role_policy_attachment" "task-role-attachment" { 
  role       = "${aws_iam_role.task-role.name}" 
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 
} 
タスク定義の項目でも話しますが、タスク自体のロールと、タスク実行ロールというものが分かれています。

ここで作っているのは、タスク実行ロールのためのロールで、タスクの管理のために必要な権限を与えています。


ECSクラスター

ECS上で動作するサービスやタスクのグループです。

EC2インスタンスやFargateのコンテナを立ち上げるための土台みたいなイメージでとらえてよさそうですね。

ちなみにタイプがEC2のものもFargateのものも共存できます。

わかりやすい名前でサクッと作りましょう。

ecs.tf
resource "aws_ecs_cluster" "ecs-cluster" { 
  name = "${var.product}-${terraform.workspace}-ecs-cluster" 
} 


タスク定義

さて、今回のメインですね。

コンテナイメージを動かすサービスのためのタスクを定義します。

こちらもJSON形式です。コンソール上でも同じものが見れますね。

やはり別ファイルに出してしまいましょう。

task-definitions/front.json
[ 
  { 
    "name" : "${product}-${env}-front-container", 
    "portMappings": [ 
      { 
        "hostPort": 80, 
        "protocol": "tcp", 
        "containerPort": 80 
      } 
    ], 
    "cpu": ${cpu}, 
    "memory": ${memory}, 
    "memoryReservation": ${memory}, 
    "image": "${image-url}:latest", 
    "command": ["exec", "nginx", "-g", "daemon off;"], 
    "environment": [ 
      { 
        "name": "HOGE", 
        "value": "${hoge}" 
      } 
    ], 
    "healthCheck": { 
      "retries": 10, 
      "command": [ 
        "CMD-SHELL", 
        "curl http://localhost/ || exit 1" 
      ], 
      "timeout": 20, 
      "interval": 60, 
      "startPeriod": 120 
    }, 
    "essential": true 
  } 
] 
docker-compose.yml で見たことあるような項目が多いですよね。

お作法はそちらに従えばだいたいOKです。

私はあまり知見がなかったため、実行時コマンドの上書きをする command で配列に入れるところでハマったりしました。

ちなみにコマンドにクォートがあるようなケースは要注意です。

シングルクォートて囲った文字列を囲ったまま渡したらエラーになりました。つら。

いよいよタスク定義を作成します。

task-definition.tf
# Frontend 
data "template_file" "front-container-template" { 
  template = "${file("${path.root}/task-definitions/front.json")}" 
 
  vars { 
    product   = "${var.product}" 
    env       = "${terraform.workspace}" 
    image-url = "${aws_ecr_repository.repository-front.repository_url}" 
    cpu       = "${var.front_cpu}" 
    memory    = "${var.front_memory}" 
    hoge      = "Nyan-path" 
  } 
} 
 
resource "aws_ecs_task_definition" "front-task-definition" { 
  family                   = "${var.product}-${terraform.workspace}-front-task" 
  container_definitions    = "${data.template_file.front-container-template.rendered}" 
  task_role_arn            = "${aws_iam_role.task-role.arn}" 
  execution_role_arn       = "${aws_iam_role.task-role.arn}" 
  network_mode             = "awsvpc" 
  requires_compatibilities = ["FARGATE"] 
  cpu                      = "${var.front_cpu}" 
  memory                   = "${var.front_memory}" 
} 
(API分は省略)

はい、出ましたIAMロール。
task_role_arn で指定するのは、実行されるタスクのロール。

例えばAPIはS3を触るよ、みたいなことがあればその許可が必要になります。

一方の execution_role_arn で指定するのは、これらタスクを管理するロールになります。

つまり、ECSでタスクを作ったり壊したり、コンテナ数をオートスケールさせたりといったことをするロールですので、

ECSに関する許可を諸々持たなくてはいけません。私はここでかなり長いことハマりました。

なーにがハマるって、作ること自体は普通にできるんですが、後段のサービスまで行ってから

「アルェー、なんでタスクが上がってこないんだ?」みたいに発覚するからです。

しかもログも出ないもんだからなかなか苦しむ苦しむ……愚痴っぽくなってきました。

またもう一つポイントなのが、 network_mode はFargateの場合、 awscpc しか選べません。

こちらも作成時は普通に通って、動かしてから発覚するタイプのエラーです。ご注意を。


ECSサービス

無事タスク定義が作れたら、サービスの立ち上げです。

ecs.tf
# Frontend 
resource "aws_ecs_service" "front-service" { 
  name                               = "${var.product}-${terraform.workspace}-front-service" 
  cluster                            = "${aws_ecs_cluster.ecs-cluster.id}" 
  task_definition                    = "${aws_ecs_task_definition.front-task-definition.arn}" 
  launch_type                        = "FARGATE" 
  desired_count                      = 1 
  deployment_minimum_healthy_percent = 100 
  deployment_maximum_percent         = 200 
  health_check_grace_period_seconds  = 60 
 
  network_configuration { 
    subnets          = ["${aws_subnet.subnet-public-a.id}", "${aws_subnet.subnet-public-c.id}"] 
    security_groups  = ["${aws_security_group.security-group-front.id}"] 
    assign_public_ip = "true" 
  } 
 
  load_balancer { 
    target_group_arn = "${aws_alb_target_group.alb-target-group-front.arn}" 
    container_name   = "${var.product}-${terraform.workspace}-front-container" 
    container_port   = 80 
  } 
} 
(API分は省略)

launch_type をFARGATEにして諸々設定してあげれば無事作成完了です。

これで正しく設定されていれば、コンソール上で元気に動くコンテナ君の姿が見られることでしょう。おめでとうございます。


やってみての感想

ハマりポイントが結構ありました。

WebコンソールだとUI側でよしなに可能な設定の絞り込み等してくれるのですが、Terraformではそこまでの親切はありません。
plan のときにエラーが出るのは文法レベルまでで、applyしてみたらエラーみたいなことも多々あります。

しかしそれもかなり親切な方で、ECSに関しては 実際にコンテナが動き出し、ヘルスチェックが通るまでは安心できない

というのが体感的なところです。

ECSで特にハマったポイントを挙げて見ます。

  1. ネットワークモードがawsvpc固定であること
  2. ↑の合わせ技で、コンテナポートへのポートマッピングがawsvpcでは使えないこと
  3. IAMロールでそもそもタスクが立ち上がらないこと
  4. コンテナそのものが上手く立ち上がらないこと (これはECSとは無関係だが、どっちの問題かしばらくわからなかった)
  5. commandで渡す値がうまく渡らなかったこと (配列しんどい)
  6. 無事立ち上がったと思ったものの束の間、ヘルスチェックに殺されてSTOPPEDのタスクが大量に溜まること

    • ちなみにヘルスチェックに殺されても、終了コードが雑に書かれて終了です、おだいじに。
  7. (今回は使わなかったが)サービスディスカバリのヘルスチェックがクセ強すぎなこと

    • 謎ですが、Route53の health_check_custom_config というプロパティに不必要でも何か設定しないと、コンテナ側の設定と同じヘルスチェックをしてくれず殺されるということがわかりました
こんなところでしょうか。もっとあったような、あった気がするな……。

愚痴っぽいことを書いていますが、実際にはこのサービスは気に入っています。

立ち上げの苦労はさておき、運用はずいぶん楽です。スケールしやすいし、ダウンタイムも短くできますからね。

そして何より環境がコード化されていることの喜びが大きいです。バージョン管理できるってすばらしい。
更新日付が鬼のように古い謎のインフラドキュメントを共有フォルダから発見して、実体との差分を細々チェックしてからようやくインフラ変更作業に取り掛かるみたいなつらみはないはずです、きっと

今回は以上です。

自分もまだAWSまともに触り始めて2,3か月ということで、日々覚えることが新鮮でとても楽しく仕事しています。

この記事も誤りやより良い方法はきっとあることと思いますので、その際はお気軽に編集リクエストをください。

よろしくお願いいたします。

コメント

このブログの人気の投稿

投稿時間:2021-06-17 22:08:45 RSSフィード2021-06-17 22:00 分まとめ(2089件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)