過去に手動で作成されたAWSリソースをTerraformに書き起こす

この記事はトリスタinsideで書かれた記事です。
現在トリスタinsideはBOOK☆WALKER Tech Blogに統合されました。

サービス開発部バックエンド開発グループのフサギコ(髙﨑)です。

トリスタが運営しているニコニコ漫画と読書メーターにおいて、AWSとTerraformによるサービスインフラとRuby on Railsによるサーバサイドアプリケーションを主に担当しています。

本記事では、過去に手動で作成されたAWS上のリソースをTerraform管理下へ取り込む手順を、過去の実例の再現を交えてお話ししたいと思います。

読書メーターとは

読書メーターは、2008年5月に開設された日本最大級の読書コミュニティサイトです。 単純な読書記録、感想投稿に留まらず、ユーザ間で交流ができることも特徴的な点です。

読書メーターのサービスインフラ

読書メーターのサービスインフラは主に

  • Ruby on Railsで書かれたWebフロントエンド
  • Ruby on Railsで書かれたスマートフォンアプリ向けAPI
  • Ruby on Railsで書かれた管理画面
  • Scalaで書かれたバックエンド
    • メインDB(Aurora MySQL)
  • Grape(Ruby)で書かれたサブバックエンド
    • サブDB(Aurora MySQL)
  • 一部生き残っている旧環境

で構成されています。

旧環境以外は2016年11月ごろの全面リニューアルに向けて、あるいはそれ以降に構築されたものですが、その際にはAWSマネジメントコンソールから手動で作成されていました。
そのため、私が異動で読書メーターの担当になった2019年7月時点ではいずれのAWSリソースもコード化されておらず、どのようなインフラ構造をしているかの把握が困難でした。

そこで、2019年8月下旬より読書メーターの各種AWSリソースをTerraformに書き起こし始めました。

当時、手動で作成したAWSリソースをTerraformへ書き起こすツールとしてTerraformingが存在していました。 しかし、Terraformingはリソースの種類ごとに一気に書き起こしてしまいます。

私はまずdev環境を書き起こし、その差分がなくなった時点でprod環境をimportし、dev環境とprod環境の差分を把握しながら必要に応じて修正するという段階を踏みたかったため採用しませんでした。

また、現在Googleが公開しているTerraformerは当時公開されていませんでした。

手動で作成されたAWSリソースをTerraformに書き起こす

書き起こし完了後の構造を大まかに考える

まず、書き起こし完了後のディレクトリ構造を大まかに考えます。

Terraformのディレクトリ構造はどのような規模でも通用する単一のベストプラクティスはなく、プロダクトの規模や書き始めた人によって様々なやり方が存在します。

読書メーターの場合は下記のようにmoduleディレクトリ以下へ用途別のディレクトリを作成し、それらを各環境名のディレクトリ内からmoduleとして参照する構造を採っています。1

┬ dev ───┬ modules.tf
│        └ config.tf
├ module ┬ network ───────┬ vpc.tf
│        │                ├ subnet.tf
│        │                ├ route_table.tf
│        │                └ variables.tf
│        ├ security_group ┬ frontend.tf
│        │                ├ backend.tf 
│        │                ├ variables.tf
│        │                … 
│        ├ backend ───────┬ rds.tf
│        │                ├ parameter_store.tf 
│        │                ├ variables.tf
│        │                … 
│        …
└ prod ──┬ modules.tf
         └ config.tf

tfstateの格納先バケットを作成し、configを書く

AWSリソースをTerraformへ書き起こす前に幾つか作業があります。

まず、tfstate2を格納するS3バケットを作成しましょう。tfstateをS3を通じて共有することで、他の開発者と共同で作業できるようになります。

このS3バケットもTerraformで管理するスタイルの人もいますが、私は面倒なのでこれだけは手動で作成したままになっています。3

# /dev/config.tf
terraform {
  required_version = "0.15.0"

  backend "s3" {
    bucket  = "【tfstateの格納先にするs3バケット名】"
    key     = "dev.tfstate"
    region  = "ap-northeast-1"
    profile = "【AWSアクセスキーのprofile名】"
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.37.0"
    }
  }
}

provider "aws" {
  region  = "ap-northeast-1"
  profile = "【AWSアクセスキーのprofile名】"
}

また、devのmodules.tfは下記のようにnetworkモジュールを参照するだけにしておきます。

# /dev/modules.tf
module "network" {
  source = "../module/network"
}

取り込むリソースの受け皿を記述する

さて、いよいよ実際に書き起こしていきます。

最初に書き起こすリソースはrequiredな引数がcidr_blockの1つだけで依存するリソースもない、VPCがおすすめです。

TerraformのAWSプロバイダのaws_vpcリソースのリファレンスを見ながら、まずはリソースをとにかく書くだけ書きます。

# /module/network/vpc.tf
resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"
}

この状態でterraform planをすると、下記のplanが出力されます。

/dev $ terraform plan
【中略】
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.network.aws_vpc.vpc will be created
  + resource "aws_vpc" "vpc" {
      + arn                              = (known after apply)
      + assign_generated_ipv6_cidr_block = false
      + cidr_block                       = "10.0.0.0/16"
      + default_network_acl_id           = (known after apply)
      + default_route_table_id           = (known after apply)
      + default_security_group_id        = (known after apply)
      + dhcp_options_id                  = (known after apply)
      + enable_classiclink               = (known after apply)
      + enable_classiclink_dns_support   = (known after apply)
      + enable_dns_hostnames             = (known after apply)
      + enable_dns_support               = true
      + id                               = (known after apply)
      + instance_tenancy                 = "default"
      + ipv6_association_id              = (known after apply)
      + ipv6_cidr_block                  = (known after apply)
      + main_route_table_id              = (known after apply)
      + owner_id                         = (known after apply)
      + tags_all                         = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

この中の7行目、 # module.network.aws_vpc.vpc will be created と書いてある中のmodule.network.aws_vpc.vpcが重要です。
これがTerraform管理下のリソースを一意に特定するパスで、既存リソースをTerraform管理下へ取り込む際にはこれが必要です。

terraform importを用いてリソースをTerraform管理下へ取り込む

先ほどでも書いたTerraformのAWSプロバイダのaws_vpcリソースのリファレンスの最下部に、importする際に指定するべきIDの種類が書いてあります。

VPCs can be imported using the vpc id, e.g. $ terraform import aws_vpc.test_vpc vpc-a01106c2

と書いてあるので、AWSマネジメントコンソール上で参照できるvpc-から始まるIDを入力すればいいことがわかります。

したがって、前項でお話ししたリソースパスと合わせて、下記のようなコマンドとなり、これを実行するとTerraform管理下にimportできます。

/dev $ terraform import module.network.aws_vpc.vpc vpc-12345678
module.network.aws_vpc.vpc: Importing from ID "vpc-12345678"...
module.network.aws_vpc.vpc: Import prepared!
  Prepared aws_vpc for import
module.network.aws_vpc.vpc: Refreshing state... [id=vpc-12345678]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

/dev $

記述を実態に合わせ、差分を解消する

しかし、そのままではplanすると下記のように差分が出てしまいます。

/dev $ terraform plan
【中略】
  # module.network.aws_vpc.vpc must be replaced
-/+ resource "aws_vpc" "vpc" {
      ~ arn                              = "arn:aws:ec2:ap-northeast-1:943210731214:vpc/vpc-12345678" -> (known after apply)
      ~ cidr_block                       = "172.12.0.0/16" -> "10.0.0.0/16" # forces replacement
      ~ default_network_acl_id           = "acl-23456789" -> (known after apply)
      ~ default_route_table_id           = "rtb-3456789a" -> (known after apply)
      ~ default_security_group_id        = "sg-456789ab" -> (known after apply)
      ~ dhcp_options_id                  = "dopt-56789abc" -> (known after apply)
      ~ enable_classiclink               = false -> (known after apply)
      ~ enable_classiclink_dns_support   = false -> (known after apply)
      ~ enable_dns_hostnames             = true -> (known after apply)
      ~ id                               = "vpc-12345678" -> (known after apply)
      + ipv6_association_id              = (known after apply)
      + ipv6_cidr_block                  = (known after apply)
      ~ main_route_table_id              = "rtb-3456789a" -> (known after apply)
      ~ owner_id                         = "012345678901" -> (known after apply)
      - tags                             = {} -> null
      ~ tags_all                         = {} -> (known after apply)
        # (3 unchanged attributes hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

これは受け皿として記述したリソースのcidr_blockが実態と相違しているからなので、下記のように実態に合わせて書き換えてあげると…

# /module/network/vpc.tf
resource "aws_vpc" "vpc" {
  cidr_block = "172.12.0.0/16"
}

無事No Changesになりました。

/dev $ terraform plan
【中略】
No changes. Infrastructure is up-to-date.

prod環境もimportし、変数化によって差分を解消する

dev環境については実態と記述を一致させられたので、prod環境にも同じものを記述し、importしていきます。

まずconfig.tfについてですが、s3 backend設定の中のkeyがtfstateの格納先なので、dev環境と異なるものを設定しましょう。

# /prod/config.tf
terraform {
  required_version = "0.15.0"

  backend "s3" {
    bucket  = "【tfstateの格納先にするs3バケット名】"
    key     = "prod.tfstate"
    region  = "ap-northeast-1"
    profile = "【AWSアクセスキーのprofile名】"
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.37.0"
    }
  }
}

provider "aws" {
  region  = "ap-northeast-1"
  profile = "【AWSアクセスキーのprofile名】"
}

そしてmodules.tfは一旦devから同じものをコピーします。

# /prod/modules.tf
module "network" {
  source = "../module/network"
}

そしてprod環境のvpcをimportすると、cidr_blockの指定がdevのVPCのものになっているので当然ですが差分が出ます。

/prod $ terraform plan
【中略】
  # module.network.aws_vpc.vpc must be replaced
-/+ resource "aws_vpc" "vpc" {
      ~ arn                              = "arn:aws:ec2:ap-northeast-1:943210731214:vpc/vpc-23456789" -> (known after apply)
      ~ cidr_block                       = "172.13.0.0/16" -> "172.12.0.0/16" # forces replacement
      ~ default_network_acl_id           = "acl-3456789a" -> (known after apply)
      ~ default_route_table_id           = "rtb-456789ab" -> (known after apply)
      ~ default_security_group_id        = "sg-56789abc" -> (known after apply)
      ~ dhcp_options_id                  = "dopt-6789abcd" -> (known after apply)
      ~ enable_classiclink               = false -> (known after apply)
      ~ enable_classiclink_dns_support   = false -> (known after apply)
      ~ enable_dns_hostnames             = true -> (known after apply)
      ~ id                               = "vpc-23456789" -> (known after apply)
      + ipv6_association_id              = (known after apply)
      + ipv6_cidr_block                  = (known after apply)
      ~ main_route_table_id              = "rtb-456789ab" -> (known after apply)
      ~ owner_id                         = "012345678901" -> (known after apply)
      - tags                             = {} -> null
      ~ tags_all                         = {} -> (known after apply)
        # (3 unchanged attributes hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

ので、cidr_blockを変数化します。

# /module/network/vpc.tf
resource "aws_vpc" "vpc" {
  cidr_block = var.vpc_cidr_block
}
# /module/network/variables.tf
variable "vpc_cidr_block" {}
# /dev/modules.tf
module "network" {
  source = "../module/network"

  vpc_cidr_block = "172.12.0.0/16"
}
# /prod/modules.tf
module "network" {
  source = "../module/network"

  vpc_cidr_block = "172.13.0.0/16"
}

これで差分が解消され、dev環境とprod環境のどちらもNo Changesになりました。

/dev $ terraform plan
【中略】
No changes. Infrastructure is up-to-date.
/prod $ terraform plan
【中略】
No changes. Infrastructure is up-to-date.

他のリソースも書き起こす

他のリソースも同様の手順で受け皿を作り、importし、差分を解消していきます。

dev環境とprod環境を同時にTerraformへ書き起こしていくことで、両環境間の差分が要不要を判断しやすい小さな単位で炙り出されます。 それら差分を取捨選択していくことで、徐々に環境の意図しない差分を減らせるでしょう。

現在の読書メーターのTerraform

今までにお話ししたような地道な書き起こしを進めたことで、他の開発案件をこなしつつも2020年2月ごろにはElastic Beanstalkで管理されていたELBとアプリケーションサーバ、旧環境を除いて、それ以外のVPC, subnet, security group, Aurora, そしてElasticacheなどがTerraform管理下になりました。

その後、管理画面とサブバックエンドについてはElastic Beanstalkを脱却し、ELBはTerraform管理下へ、アプリケーションサーバはecspresso管理のECS Fargateへ移行を果たしています。

まとめ

本記事では、過去に手動で作成されたAWS上のリソースをTerraform管理下へ取り込む手順を、過去の実例の再現を交えてお話ししました。

読書メーターやニコニコ漫画は既にほぼ全てがTerraformへ書き起こされていますが、それまで使ったことのないAWSサービスを試す際にはdev環境をAWSマネジメントコンソールから手動で作成し、この手順で書き起こしてprod環境に作成することで環境差を生まないように工夫しています。

手動で作成されたAWSリソースの環境感差分を解消するためにTerraformに挑戦してみようという方は参考にしていただけば幸いです。


  1. ニコニコ漫画のTerraformはこの構造だと支障が出るレベルの規模に成長してきたため別の構造に移行中で、その事もまた別の機会にお話しできればと思っています

  2. Terraform管理下にあるAWSリソースの情報が記録されているjsonファイル

  3. サービスインフラをTerraformに書き起こすのは変更したくなったときに変更の影響箇所や構造を把握するためですが、tfstateの格納バケットは変更する機会も必要もないので…