エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。

f:id:fukubaya:20200708215919j:plain
さいたまスーパーアリーナは、埼玉県さいたま市中央区にある多目的アリーナ。本文には特に関係ありません。

最近、Terraformを書くことが多く、知見が貯まりつつあった時にちょうどディレクトリ構成に関する記事を読んでタイミングがよかったので、 今回はTerraformのディレクトリ構造の実例を晒したいと思います。

クラウド化推進

弊社の比較的新しいサービスはスタート時からAWSやGCPで構築されていますが、 歴史のあるサービスはオンプレ環境で運用しているものが多数あります。 僕がUnit4(ニュース記事、掲示板などのm3.comサイトの開発、運営)と兼務する基盤開発チーム*1では、 これらのオンプレ環境で稼動するサービスのAWSやGCPへの移行を推進しており、この9ヶ月くらいで大小合わせて10のサービスを移行させました。 その過程でTerraformの構成がなんとなく固まってきたので、このタイミングで記事にしようと思いました。

なぜ定番が決まらないのか

冒頭で紹介した記事を含めて、過去にもベストな構成を考えた記事はたくさんありますが、 これが定番だ、と言われるような構成はまだないと思います。

blog.engineer.adways.net future-architect.github.io dev.classmethod.jp

理由はいくつか考えられますが

  1. 構築対象のサービス、他に連携する社内の他のサービス、などの環境の前提
  2. 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.tfvarsterraform.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/ 内で定義しているものもありますし、別レポジトリで定義しているものもあります。

github.com

# 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_PASSWORDSecureString で作られます。

# 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_changesvalue を指定します。

# 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"
        }
      ],
      ...
    }
  ],
  ...
}

共有リソースがある場合

ここまで説明した構成だと、qa1qa2 でそれぞれALBが作られます。 一方で、RDSやElastiCacheやS3など qa1qa2 で共有したいリソースもあります。

そのため、共有リソースがある場合は以下のように共有リソースと個別リソースにさらに ディレクトリを分けて管理することにしました。

以下では 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!

エムスリーのクラウド化推進はまだまだ始まったばかりで、これからさらに難易度の高い移行が待っています。 基盤開発チームでは一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:組織構成については https://www.m3tech.blog/entry/organization-structure-2018 参照

*2:個人的には本番と検証が大幅にずれると、QAの信頼度が下がるので差がない方がいいと思います。

*3:Azureを使ったチームはまだない?