こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。
最近、Terraformを書くことが多く、知見が貯まりつつあった時にちょうどディレクトリ構成に関する記事を読んでタイミングがよかったので、 今回はTerraformのディレクトリ構造の実例を晒したいと思います。
結構固まってきたからうちのチームの構成も晒してみようかな。 | Terraformのディレクトリ構成の模索 - Adwaysエンジニアブログ https://t.co/31FMkcCJOo
— Yuichiro Fukubayashi (@fukubaya) July 3, 2020
クラウド化推進
弊社の比較的新しいサービスはスタート時からAWSやGCPで構築されていますが、 歴史のあるサービスはオンプレ環境で運用しているものが多数あります。 僕がUnit4(ニュース記事、掲示板などのm3.comサイトの開発、運営)と兼務する基盤開発チーム*1では、 これらのオンプレ環境で稼動するサービスのAWSやGCPへの移行を推進しており、この9ヶ月くらいで大小合わせて10のサービスを移行させました。 その過程でTerraformの構成がなんとなく固まってきたので、このタイミングで記事にしようと思いました。
なぜ定番が決まらないのか
冒頭で紹介した記事を含めて、過去にもベストな構成を考えた記事はたくさんありますが、 これが定番だ、と言われるような構成はまだないと思います。
blog.engineer.adways.net future-architect.github.io dev.classmethod.jp
理由はいくつか考えられますが
- 構築対象のサービス、他に連携する社内の他のサービス、などの環境の前提
- Terraform へのバージョンアップで以前はできなかったことができるようになって使い方が変わる
など、使う側の環境や時期によって変わるものだと考えています。 公式ドキュメントに構成が例示されていないのもそのためだと思います。
なので、今回紹介する実例も、 エムスリーの(一部の)サービスで、現在(2020年7月)のTerraformのバージョン(0.12)で作ったら こういう構成がよい、くらいの実例の1つです。
前提となる条件
本番環境と検証環境はほぼ同じ構成
本番環境と検証環境はほぼ同じ構成です。 一部で本番や検証にしか必要のないリソースはありますが、そういうものは例外的で、 本番環境と検証環境の差はパラメータとして指定できるような、リソース数とかCPUなどのみに限定されます。
本番と検証が大幅に違う構成の場合にはあまり合わないかもしれません*2。
レポジトリとtfstateの単位
これまで弊社のオンプレ環境は、全環境をSREチーム管理の巨大なAnsibleコードを1レポジトリで一元管理していましたが、 クラウド化と同じタイミングでこれらの構成管理コードを分割し、各チームがそれぞれの責任で管理することになりました。 なお、チームごとに管理するのでAWSを使うか、GCPを使うか(そもそもTerraformで管理するか)も各チームの判断で決めています*3。
この流れに合わせて、Terraformのコードもチームごとに管理しています。 Unit4では、担当サービスが多くサービス単位にすると細かすぎるので、チームで管理するレポジトリは1つにして、 その中で担当するサービスごとにディレクトリを区切り、1サービスの1環境で1つのtfsfateができるようにしています。
# Unit4の構成のイメージ m3-unit4-infra ├── service1 │ ├── 本番環境のtfstate │ └── 検証環境のtfsfate ├── service2 │ ├── 本番環境のtfstate │ └── 検証環境のtfsfate ├── service3 │ ├── 本番環境のtfstate │ └── 検証環境のtfsfate ├── service4 │ ├── 本番環境のtfstate │ └── 検証環境のtfsfate ...
一方で、他のチームではサービスごとにレポジトリを作っている場合もあります。
AWSアカウント/GCPプロジェクトの単位
AWSアカウントはチーム×環境の単位で分けています(Unit4の本番、Unit4の検証など)。 同じAWSアカウント内に複数のサービスが同居していることになりますが、tfstateはサービスごとに分かれます。
GCPの場合は、自分のチームではまだ例が少ないですが、サービス×環境(Aサービスの本番PJ、Aサービスの検証PJなど)で分けています。
実例
AWSのECSで稼動するサービスをALBでロードバランスして提供する構成の例です。
全体構成
myservice ├── .terraform-version ├── prod │ ├── app-env.auto.tfvars │ ├── backend.tf │ ├── main.tf -> ../shared/main.tf │ ├── provider.tf -> ../shared/provider.tf │ ├── terraform.tfvars │ └── variables.tf -> ../shared/variables.tf ├── qa1 │ ├── app-env.auto.tfvars │ ├── backend.tf │ ├── main.tf -> ../shared/main.tf │ ├── provider.tf -> ../shared/provider.tf │ ├── terraform.tfvars │ └── variables.tf -> ../shared/variables.tf ├── qa2 │ ├── app-env.auto.tfvars │ ├── backend.tf │ ├── main.tf -> ../shared/main.tf │ ├── provider.tf -> ../shared/provider.tf │ ├── terraform.tfvars │ └── variables.tf -> ../shared/variables.tf └── shared ├── main.tf ├── modules │ ├── alb │ │ ├── ... │ │ ├── outputs.tf │ │ └── variables.tf │ ├── ecr_user │ │ ├── ... │ │ └── variables.tf │ ├── ecs │ │ ├── ... │ │ ├── outputs.tf │ │ └── variables.tf │ ├── ecs_user │ │ ├── ... │ │ └── variables.tf │ └── metrics_alarm │ ├── ... │ └── variables.tf ├── provider.tf └── variables.tf
この構成ではAWSアカウントは本番環境用1つと検証環境用1つ使います。 また、本番環境1つ、検証環境が2つ作るので、tfsfateは3つできます。 なお、同じサービスの別々の開発が並行することもあるので検証環境は2つあります。
役割としては環境ごとのディレクトリ (prod
, qa1
, qa2
) と
インフラ本体の定義を置く共通コードのディレクトリ (shared
) に分かれます。
オブジェクト指向っぽく説明をすると、共通コードのディレクトリにはclassの定義が、
環境ごとのディレクトリにはclassのインスタンスを作る際に渡す実際の値が定義されていると
イメージすると分かりやすいと思います。
Terrarformのバージョン指定
Terraform自体のバージョンを固定するために tfenv
を使用しているので
.terraform-version
でバージョンを指定し、このファイルはサービスの直下に置きます。
$ cat .terraform-version 0.12.25
環境ごとのディレクトリ
環境ごとにディレクトリを作りますが、環境ごとに作るファイルは3つだけで、
他は shared
以下のファイルへのシンボリックリンクです。
3つのファイルはそれぞれ、tfsfateの保存先、インフラ環境自体の設定、アプリケーションの環境変数の設定です。
環境ごとに変わる設定はすべてこれらのファイルだけに含まれます。
各環境のterraform apply
はこのディレクトリで実行します。
prod/ ├── app-env.auto.tfvars ├── backend.tf ├── main.tf -> ../shared/main.tf ├── provider.tf -> ../shared/provider.tf ├── terraform.tfvars └── variables.tf -> ../shared/variables.tf
tfstateの保存先
backend.tf
にはこの環境(prod
)のtfsfateの保存先を設定します。
以下の例ではS3のバケット terraform-state-teamname-prod
に、
terraform-state-myservice-prod.tfsfate
のkeyで保存する設定です。
なお、このS3バケットだけは先に作成しておく必要があります。
またTerraform適用時にロックするためのDynamoDB Tableもここで定義します。
terraform { # backend の設定では variable を使えないので直書きする backend "s3" { region = "ap-northeast-1" bucket = "terraform-state-teamname-prod" key = "terraform-state-myservice-prod.tfstate" dynamodb_table = "terraform-state-lock-myservice-prod" } } resource "aws_dynamodb_table" "tfstate_lock" { name = "terraform-state-lock-myservice-prod" read_capacity = 10 write_capacity = 10 hash_key = "LockID" attribute { name = "LockID" type = "S" # S = String } }
インフラ環境自体の設定
terraform.tfvars
はインフラ環境自体の設定です。
ECSのタスク数とかCPU、メモリなど環境によって変わる設定です。
env_name = "prod" ecs_task_cpu = 1024 ecs_task_memory = 1024 ecs_task_desired_count = 2
アプリケーションの環境変数の設定
app-env.auto.tfvars
はECSで実行するアプリケーションに渡す環境変数の設定です。
terraform.tfvars
と同じファイルに書いてもよいのですが、
インフラ環境自体の変数とアプリケーションの環境変数を明確に分けるため、わざとファイルを分けています。
この内容はレポジトリに残るので秘密にしなくてもよいものだけを設定します。 DBのパスワードなど、秘密にすべき環境変数はここでは設定しません。
# アプリケーションで使用する環境変数の値 env_variables = { PLAY_ENV = "prod" DB_URL = "jdbc:postgresql://xxxxx/xx_db" DB_USER = "xx_db_user" API_ENDPOINT = "http://some.host/api/v1" JAVA_OPTS = "-Xms512m -Xmx512m" }
共通コードのディレクトリ
インフラ本体のコードは shared
以下に置き、各環境のディレクトリからは、
provider.tf
, variables.tf
, main.tf
だけをシンボリックリンクで参照します。
shared/ ├── main.tf ├── modules │ ├── alb │ │ ├── ... │ │ ├── outputs.tf │ │ └── variables.tf │ ├── ecr_user │ │ ├── ... │ │ └── variables.tf │ ├── ecs │ │ ├── ... │ │ ├── outputs.tf │ │ └── variables.tf │ ├── ecs_user │ │ ├── ... │ │ └── variables.tf │ └── metrics_alarm │ ├── ... │ └── variables.tf ├── provider.tf └── variables.tf
providerの指定
使用するproviderはここで設定します。以下ではAWSとHTTPを使っています。
terraform { required_version = "0.12.25" } provider "aws" { region = "ap-northeast-1" version = "2.67" } provider "http" { version = "1.2" }
GCPの場合はプロジェクトIDも設定しないといけないので、変数が埋められることもあると思います。
provider "google" { version = "3.21.0" project = "${var.application_name}-${var.env_name}" region = "asia-northeast1" }
変数の定義
variables.tf
には各環境からどのような変数を受け取るかを定義します。
app-env.auto.tfvars
や terraform.tfvars
で設定する変数の定義はこの中で行います。
variable "application_name" { type = string description = "リソース名などに使用する名前" default = "myservice" } variable "env_name" { description = "リソース名などに使用する環境名(qaなど)" type = string } variable "ecs_task_cpu" { description = "ECSタスクCPU (256, 512, 1024(=1vCPU), 2048, 4096)" type = number default = 256 } variable "ecs_task_memory" { description = "ECSタスクメモリ (512, 1024, 2048, 3072, 4096,...)" type = number default = 512 } variable "ecs_task_desired_count" { description = "ECSタスクの必要数" type = number default = 3 } variable "env_variables" { description = "ECSタスクの環境変数" type = object({ PLAY_ENV = string DB_URL = string DB_USER = string API_ENDPOINT = string JAVA_OPTS = string }) # defaultは設定しない }
なお、アプリケーションの環境変数は env_variables
として1つの変数にまとめ、
型をobject({<ATTR NAME> = <TYPE>, ...})
の形式で指定し、default
を設定しないことで、
設定抜けがあった場合にエラーになるようにしています。
ほかの変数でも型は細かく設定した方が、間違っていた場合に事前にエラーが発生して気づけるので、 極力設定した方がよいと思います。 このあたりは一般的な型付き言語でプログラミングするのと同じです。
インフラ本体
main.tf
がインフラ本体ですが、
このファイル内では resource
は書かず、各moduleに変数を渡すだけです。
なお、moduleは shared/modules/
内で定義しているものもありますし、別レポジトリで定義しているものもあります。
# main.tf locals { application_env_name = "${var.application_name}-${var.env_name}" ecr_repository_name = "${var.application_name}-${var.env_name}" ecs_cluster_name = "${var.application_name}-${var.env_name}" ssm_name_prefix = "/${var.application_name}-${var.env_name}" docker_deployment_image_tag = "latest" } # 社内の別レポジトリで定義する共通インフラ定義(VPCやサブネット、オンプレのIPなど) # 何もリソースは作らない。読み取り専用。 module "infra" { source = "git::ssh://git@xxxxx:xxx/xxxx/terraform-modules.git//infra" } # ECR module "repository" { source = "github.com/m3dev/m3-terraform-modules//ecr_repository_template?ref=984987" repository_name = local.ecr_repository_name repository_tags = { group = local.application_env_name } } # ECSとタスク module "ecs" { source = "../shared/modules/ecs" application_env_name = local.application_env_name vpc_id = module.infra.vpc_id private_subnet_ids = module.infra.private_subnet_ids alb_target_group_arn = module.alb.target_group.arn alb_listener_arns = module.alb.listener_arns ecs_cluster_name = local.ecs_cluster_name ecs_service_name = var.application_name ecs_task_desired_count = var.ecs_task_desired_count family = local.application_env_name cpu = var.ecs_task_cpu memory = var.ecs_task_memory port_mapping = { host_port = 9000 container_port = 9000 protocol = "tcp" } docker_image_name = "${module.repository.repository_url}:${local.docker_deployment_image_tag}" ssm_name_prefix = local.ssm_name_prefix env_variables = var.env_variables ssm_env_vars = { DB_PASSWORD = { type = "SecureString" description = "DBのパスワード" } } } # ALB module "alb" { source = "../shared/modules/alb" vpc_id = module.infra.vpc_id ... } # ECSを操作するためのIAM User module "ecs_user" { source = "../shared/modules/ecs_user" application_env_name = local.application_env_name ecs_cluster_arn = module.ecs.cluster.arn ecs_service_arn = module.ecs.service.id ecs_task_execution_role_arn = module.ecs.task_execution_role.arn ecs_task_role_arn = module.ecs.task_role.arn ecs_task_definition_family = module.ecs.task_definition.family } # ECRにpushするためのIAM User module "ecr_user" { source = "../shared/modules/ecr_user" application_env_name = local.application_env_name ecr_arn = module.repository.repository_arn } # CloudWatch Alarm module "metrics_alarm" { source = "../shared/modules/metrics_alarm" application_env_name = local.application_env_name alb_arn = module.alb.alb.arn alb_target_group_arn = module.alb.target_group.arn ecs_cluster_name = module.ecs.cluster.name ecs_service_name = module.ecs.service.name th_cpu_utilization = 50 th_memory_utilization = 75 th_alb_5xx_count = 1 }
モジュール
modules/
以下に main.tf
から呼ぶモジュールを置きます。
モジュールの単位はどれくらいの大きさで切るかは特にルールはないですが、
「他で再利用しやすい」程度の単位になっていればよいと思います。
「他で再利用しやすい」は、会社やチームや他のサービスの状況によって違うので、 一意に決めることはできないと思います。 他のどのサービスでもほぼ同じような構成ならばECSもECRもALBも1モジュールに全部入れてしまってもいいかもしれませんし、 サービスに必要なリソースが変わる(あるサービスではRDSが別途必要、別のサービスではDynamoDBが必要、など)ならば、 それらは別のモジュールになっていた方がよいと思います。
モジュールのファイル構成はあまりこだわりがありません。
モジュールの入力となる変数定義の variables.tf
と
出力の定義となる outputs.tf
はそれぞれ定義しますが、
それ以外は1つのtfファイルにまとまっていてもいいし、分けて書いてもいいと思います。
大きすぎると探すのが大変なので、リソース名をファイル名にしています。
ecs ├── aws_cloudwatch_log_group.tf ├── aws_ecs_cluster.tf ├── aws_ecs_service.tf ├── aws_ecs_task_definition.tf ├── aws_iam_role.tf ├── aws_security_group.tf ├── aws_ssm_parameters.tf ├── outputs.tf └── variables.tf
# aws_ecs_cluster.tf resource "aws_ecs_cluster" "main" { name = var.ecs_cluster_name tags = { group = var.application_env_name } }
ECSの環境変数
DBなどのパスワードは秘密情報なのでコード上には書かず、 System Managerのパラメータストアに定義して、ECSには環境変数として渡します。
Terraformでは、パラメータストアの定義とECSタスク定義にその値を渡すことだけを定義します。
ecs
モジュールは、 ssm_env_vars
として環境変数の定義を受けとり、
この定義に従って、パラメータストアを作ります。
# modules/ecs/variables.tf ... variable "ssm_name_prefix" { type = string description = "SSM設定値の接頭辞" } variable "ssm_env_vars" { type = map(object({ type = string description = string })) description = "SSMでECSに渡す環境変数の定義" }
以下の例では DB_PASSWORD
を定義します。
本番環境の場合 /myservice-prod/DB_PASSWORD
が SecureString
で作られます。
# main.tf locals { ... ssm_name_prefix = "/${var.application_name}-${var.env_name}" ... } # ECSとタスク module "ecs" { ... ssm_name_prefix = local.ssm_name_prefix ssm_env_vars = { DB_PASSWORD = { type = "SecureString" description = "DBのパスワード" } } }
ecs
モジュール内で実際にパラメータストアを作成します。
値まではTerraformで管理しないので、value
にはダミーの文字列を設定しておき、
実際にはAWSコンソールなどから手動で設定します。
そのため ignore_changes
に value
を指定します。
# modules/ecs/aws_ssm_parameters.tf resource "aws_ssm_parameter" "env_vars" { for_each = var.ssm_env_vars name = "${var.ssm_name_prefix}/${each.key}" type = each.value.type description = each.value.description value = "dummy" overwrite = true lifecycle { # 手動で設定するので value の変更は無視する ignore_changes = [value, ] } }
ECSのタスク定義では、パラメータストアで設定した秘密にする環境変数と、
app-env.auto.tfvars
で指定した環境変数をそれぞれ設定します。
# modules/ecs/aws_ecs_task_definition.tf resource "aws_ecs_task_definition" "app" { ... container_definitions = jsonencode([ local.container_definition ]) requires_compatibilities = [ "FARGATE" ] } locals { container_definition = { ... environment = concat([for k in sort(keys(var.env_variables)) : { name = k, value = var.env_variables[k] }]) secrets = [for k in sort(keys(aws_ssm_parameter.env_vars)) : { name = k, valueFrom = aws_ssm_parameter.env_vars[k].name }] ... } }
実際にタスク定義には以下のように設定されます。
{ ... "containerDefinitions": [ { ... "environment": [ { "name": "JAVA_OPTS", "value": "-Xms512m -Xmx512m" }, { "name": "DB_URL", "value": "jdbc:postgresql://xxxxx/xx_db" }, { "name": "DB_USER", "value": "xx_db_user" }, { "name": "API_ENDPOINT", "value": "http://some.host/api/v1" }, { "name": "PLAY_ENV", "value": "prod" } ], "secrets": [ { "valueFrom": "/myservice-prod/DB_PASSWORD", "name": "DB_PASSWORD" } ], ... } ], ... }
共有リソースがある場合
ここまで説明した構成だと、qa1
と qa2
でそれぞれALBが作られます。
一方で、RDSやElastiCacheやS3など qa1
と qa2
で共有したいリソースもあります。
そのため、共有リソースがある場合は以下のように共有リソースと個別リソースにさらに ディレクトリを分けて管理することにしました。
以下では redis
を共有リソースとして作ります。
myservice ├── .terraform-version ├── common_resource # 共有リソース │ ├── prod │ │ ├── ... │ │ └── ... │ ├── qa │ │ ├── ... │ │ └── ... │ └── shared │ ├── main.tf │ ├── modules │ │ └── redis │ │ ├── ... │ │ └── variables.tf │ ├── provider.tf │ └── variables.tf └── individual_resource # 個別リソース ├── prod │ ├── ... │ └── ... ├── qa1 │ ├── ... │ └── ... ├── qa2 │ ├── ... │ └── ... └── shared ├── main.tf ├── modules │ ├── alb │ │ ├── ... │ │ └── variables.tf │ ├── ecr_user │ │ ├── ... │ │ └── variables.tf │ ├── ecs │ │ ├── ... │ │ └── variables.tf │ ├── ecs_user │ │ ├── ... │ │ └── variables.tf │ └── metrics_alarm │ ├── ... │ └── variables.tf ├── provider.tf └── variables.tf
共通リソース common_resource/qa
で作ったredisを
個別リソース individual_resource/qa1
で利用するには、
terraform.tfvars
で作ったリソースをidで指定し、data
で参照します。
# individual_resource/qa1/terraform.tfvars # 共通リソースで指定したIDを渡す redis_group_id = "redis-myservice-qa"
# individual_resource/shared/main.tf # 共通リソースで作ったredisをdataで参照する data "aws_elasticache_replication_group" "redis" { replication_group_id = var.redis_group_id } # 必要な箇所で共通リソースのredisを参照する env_variables = merge( var.env_variables, { "REDIS_HOST" = data.aws_elasticache_replication_group.redis.primary_endpoint_address } )
We are hiring!
エムスリーのクラウド化推進はまだまだ始まったばかりで、これからさらに難易度の高い移行が待っています。 基盤開発チームでは一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。
弊社、ソフトウェアエンジニア全然足りてない。
— Yuichiro Fukubayashi (@fukubaya) July 7, 2020
*1:組織構成については https://www.m3tech.blog/entry/organization-structure-2018 参照
*2:個人的には本番と検証が大幅にずれると、QAの信頼度が下がるので差がない方がいいと思います。
*3:Azureを使ったチームはまだない?