SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

後で楽できるTerraformの書き方(※ただし書くときは辛い)

はじめに

はじめまして。スマートキャンプのおにまるです。
2022年10月に入社し、SRE兼インフラエンジニアとして働いています。

今回は、あるプロダクトの再スタートにあたって新しく作った、AWSのTerraformについてお話したいと思います。

再スタートにあたってアプリケーションが大きく変わるため、インフラも再構築する必要がありました。
もともとのインフラもTerraformで管理されていたのですが、アプリケーションの変更にあわせてインフラも大きく変えなければならず、Terraformのコードも大改修が必要だと分かりました。しかしあまりにも変更する箇所が多く、その範囲も広かったので、いっそ書き直したほうがよい、ということになったのです。
汎用性の高い設計にすれば、新しいプロダクトを立ち上げるときに使い回しが効くのでは、という目論みもありました。

しかし……いやあ、大変でした……。

ゼロから作る、という判断は間違っていなかったと思います。しかし汎用性の高いものを作る、という判断は間違っていたかもしれない、と途中で弱気になるぐらいトライ&エラーを繰り返すことになったのでした。

今回はそんなトライ&エラーの結果を紹介します。
これが少しでもみなさんのお役に立てば幸いです。

ざっくりしたシステム構成の紹介

これまでのアプリケーションは、かなり複雑な構成になっていました。表向きは1つに見えるのですが、GitHubのリポジトリが2つ、AWSの環境も2つあり、裏でこの両方を繋げて動作していたのです。これが開発する際の大きな障壁になっていました。

見た目や動作を大きく変えないまま統合する、というのが再スタートの目的の1つです。壮大なリファクタリングと言えるかもしれませんが、応答速度を改善する、信頼性を上げる、といったこの先の改善業務をするためにも避けて通れない道でした。

全体の構造

まず最初に、全体のディレクトリ構成をお見せします:

.
├── environments
│   ├── _common
│   ├── lt
│   ├── mig
│   ├── prod
│   └── stg
├── modules
│   ├── acm
│   ├── alb
│   ├── ec2
│   ├── ecr
│   ├── ecs
│   ├── es
│   ├── iam
│   │   └── policy-documents
│   ├── kms
│   ├── rds
│   ├── redis
│   ├── s3
│   ├── ses
│   ├── sg
│   └── vpc
├── scripts
├── tmp
└── utils

ちなみに、過去のものはこういった感じでした:

├── terraform
│   ├── modules
│   │   ├── (app_1)
│   │   │   ├── cdn
│   │   │   ├── chatbot
│   │   │   ├── datadog
│   │   │   ├── ecs
│   │   │   ├── efs
│   │   │   ├── es
│   │   │   ├── instances
│   │   │   ├── kinesis-firehose
│   │   │   ├── load_balancers
│   │   │   ├── networks
│   │   │   ├── rds
│   │   │   ├── redis
│   │   │   ├── s3
│   │   │   ├── ses
│   │   │   └── site
│   │   └── (app_2)
│   │       ├── cdn
│   │       ├── ecs
│   │       ├── efs
│   │       ├── instances
│   │       ├── load_balancers
│   │       ├── networks
│   │       ├── rds
│   │       ├── redis
│   │       ├── s3
│   │       └── site
│   └── workspaces
│       ├── (app_1)-prod
│       ├── (app_1)-test
│       ├── (app_2)-prod
│       ├── (app_2)-test
│       └── tf-constant
└── terraform-aws-ecs
    ├── cluster
    └── service_load_balancing

ご覧のようにモジュールが app_1app_2 で分けて管理されていて、重複しているものもありました。

ECSの部分が別ディレクトリにあるのも気になります。どうしてこういう形になったのかは、すでに退職された方が書いたものなので、はっきりとは分かりません。app_1app_2 が別々に管理されていたのが原因かもしれません。

次に、新しく作った方の environments ディレクトリの中身について説明したいと思います。
ファイルはこういった構成になっています:

environments
├── README.md
├── _common
│   ├── main.tf
│   └── variables.tf
├── lt
│   ├── locals.tf
│   ├── main.tf -> ../_common/main.tf
│   └── variables.tf -> ../_common/variables.tf
├── mig
│   ├── locals.tf
│   ├── main.tf -> ../_common/main.tf
│   └── variables.tf -> ../_common/variables.tf
├── prod
│   ├── locals.tf
│   ├── main.tf -> ../_common/main.tf
│   └── variables.tf -> ../_common/variables.tf
└── stg
    └── locals.tf

ご覧のように、environments ディレクトリ以下に環境ごとの設定を置くディレクトリがあり、その中に locals.tfmain.tfvariables.tf があります。このうち main.tfvariables.tf は、どの環境でも同じということを明示するために _common のファイルへのシンボリックリンクにしています。つまり、環境ごとに違うのは locals.tf だけです。

共通の値を使う場合でも、main.tf に値をハードコードすることは避けています。これは、環境ごとに値を変えることになっても対処しやすいようにする、という理由もありますが、社外秘情報を locals.tf に集めることで、これ以外の部分は誰に見られても大丈夫なようにしたい、と思ったからです。

設計のポイント

「はじめに」で書いたように、汎用的に使えるものを作るのが目的の1つです。どうすれば実現できるか、と考えたとき、読みやすいコードを書くのが一番大事なのでは、という考えに至りました。

汎用的に使えるといっても、完全にそのまま使えるわけではありません。違いを埋めるために書き換えが必要ですが、そうしやすいかどうか、そこが汎用的に使える、使ってもらえるときのポイントになると思ったのです。

そこで、

  • 読む人にとって、見なければならない場所を減らす
  • 読む人にとって、考えさせる場所を減らす
  • そのために書く人は(すごく)がんばる

を念頭にコーディング規約を作りました。

コーディング規約

上の階層を見に行かない

モジュールの呼び出しやファイルの読み込みのときに、親ディレクトリを参照しないようにしました。こうすることでモジュールが使っているものはすべてそのディレクトリと、そのサブディレクトリにあることが保証され、ファイルの中を見ることなく、見なければならない範囲が明らかになります。

ただし環境別の main.tf は別です。environments/prod/main.tf は、../../modules 以下のディレクトリを参照します。これ以外の例外を作りません。

(全体のディレクトリ構造は、ページの上部で説明しています)

変数名は全体でユニークにする

変数名は、どのファイルの中でも同じものを意味するようにしました。たとえばvpcモジュールの id という変数はVPCのIDなのは明らかですが、あえて vpc_id とします。こうしておくと、VPCのIDがどこでセットされ、どこで参照されているか、全文検索すれば一目でわかるようになります。

これだけだと、それほど必要ないと思われるかもしれません。しかし、たとえばログ出力のバケット名が入る変数だと効果があります。ECS用のALBなら log_bucket_alb_ecs、CloudFrontなら log_bucket_cloudfront といった名前にしておくと、log_bucket という変数を見て「中身は何だろう?」と調べるような状況にならずに済みます。

この延長で、すべてをユニークにしておくと、今後いつか役に立つ……かもしれません。

(変数の名前のつけ方はよく議論になります。すべての場面で優れているものはない、ということですが、Terraformに関してはわかりやすく、長い名前をつけることに利があるように感じます)

変数のデフォルト値は設定しない

一般に、プログラミング言語では変数にデフォルト値を代入(初期化)するものです。しかしTerraformでは、ロジックが書かれている main.tf とは別の variables.tf で変数が定義されています。

デフォルト値を使う可能性があると、変数に何が入っているか知りたいときは variables.tf を確認しなければなりません。しかし使わないと決まっていれば、variables.tf を見る必要はありません。

main, outputs, variables 以外のファイルを原則置かない

ファイルが少なく、それぞれの役目がはっきりしていれば、見なければならない場所が減ります。

モジュールは modules 以下にあり、こういったファイル構成になっています:

modules
├── acm
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
├── alb
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
:
(省略)
:
├── sg
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
└── vpc
    ├── main.tf
    ├── outputs.tf
    └── variables.tf

1つのモジュールのロジックはすべて main.tf にあり、出力は outputs.tf、入力は variables.tf にある、と決まっていれば、ロジックを知りたいときは main.tf だけ見れば済む、というのが見る前から把握できます。

また、2つ前で書いたように、変数名は全体でユニークです。main.tf に出てくる変数がどう使われているかは、 outputs.tfvariables.tf を見なくても分かるということです。

locals.tf は、原則として置きません。ローカル変数を使わざるを得ない場合は仕方ありませんが、その場合でも変数に何が入っているかはっきりわかるように書きます。

ポリシードキュメントはJSONファイルのまま管理する

Terraformを理解できる人より、JSONを理解できる人の方が多いからです。
ポリシードキュメントを変更することは多くありません。しかし、変更しなければならないときTerraformを使えない人でも編集できれば、より早く変更を反映させることができます。

templatefile という関数が用意されているのは、それを見越してのことではないかと思います。

変数で処理を変える仕組みを極力使わない

使わない resourcecount = 0 にして使わないようにする方法があります。

たとえば本番環境とステージング環境、どちらか片方でしか使わない resource を、もう片方ではそもそも作らないようにする、といった使い方をすることが多いようです。

これはとても便利で、使わない理由はありません。ですが resource に渡すものによって違うものを作る、違う動作をする、ということだと話は別です。渡される変数がどういう値なのか、モジュールを呼び出している側を意識しなければ大事に至る可能性があるからです。

値のハードコードをためらわない

値をハードコードするのは悪だと、プログラマーは教わってきたと思います。私もそうですし、普通のコードを書くときはハードコードを避けています。
しかし今回のTerraformでは、状況によってはハードコードするようにしました。

具体的には、以下の条件をすべて満たす場合です。

  1. 環境ごとで値が変わらない
  2. 普段の運用で変更する可能性が(ほぼ)ない
  3. 他のプロダクトで使うときでも変更しない可能性が高い

たとえばALBでは、

  • ターゲットグループの、ヘルスチェック対象のパスやステータスコード
  • SSLポリシー
  • リスナーのポート番号

などがその一例です。

これらを環境ごとの locals.tf で定義すると膨大な数になってしまいます。前の項目で書いたように、variables.tf を意識せずに済むよう、変数のデフォルト値を使わないようにしましたが、その代わりに main.tf の中で(ハードコードして)定義しているわけです。設定を変えることが滅多にないなら問題にならないでしょう。

Webアプリケーションの場合、似たようなインフラ構成になることが多いと思います。新しくWebアプリケーションを作るときは、設計段階でインフラを考慮してもらえればいいと考えています。(もちろん、無理して寄せなくてもいいことをちゃんと伝えたうえで、です)

コードが冗長であることをためらわない

参照する場所を減らすということは、それだけ共通化をしないということです。どうしても冗長になるところが出てきますが、ある程度は仕方ないと割り切ります。

これは、読む人に、ほんの少し違うコードを同じものと思わせてしまう危険があります。それでもTerraformでは見る場所を減らす方が有効だと、私は考えています。そういった箇所にはコメントを書くことで、危険をかなり抑えることができるからです。

残っている課題

AWSアカウント単位でしか用意しないものの扱い

ECRは、環境毎に分けてもいいですが、別にそうしなくても問題ありません。Dockerイメージのキャッシュを活かす点では共有した方がいいような気がします。しかしTerraformでは、環境をまたいで共有するのはそれなりに面倒ですし、完全に分かれていた方が見かけもスマートな気もします。

今のところ環境別に分けていますが、今後どうすべきか、まだ悩んでいます。

ECSのタスク定義の扱い

ECSのタスク定義をTerrafromとアプリケーションのどちらで管理するか、という部分はいまだに悩んでいます。コンテナインスタンスの管理という点では、Terraformで扱うのが普通のような気がします。しかしアプリケーションの動作を変えるため、よく変更するものでもあるので、そのたびにapplyするのは、あまり合理的ではないようにも感じます。

今はすべてTerraformで管理するようにしていますが、頻繁に変更する部分だけはアプリケーションで管理し、全体のベースとなるものはTerraformで管理する、というのを試してみようと思っています。ただ、それがベストかどうかは自信が持てないので、この先も試行錯誤が続くでしょう。

最後に

Terraformで管理しているインフラは、手でいじってしまうと管理下に戻すのが大変だ、と感じている方は多いと思います。ですから運用中に手でいじってしまいたくなる要素を、Terraformでどれだけ簡単に変えられるか、と考えながら設計するのがとても大事だと感じました。

見通しがよく、シンプルで、他人がメンテナンスしやすいものが理想的、というのは一般的なプログラミング言語と変わりありません。
しかしTerraformはインフラを扱う分、いろいろな部分で「これはなんなんだ…!」と驚かされる、理不尽にも思える制約に振り回されます。変数やモジュールの呼び出しなどはその一例で、制約の理由が分からないままだと力業で解決しがちで、結果として読みにくいコードになってしまいます。そうならないよう、コーディング規約をきちんと作る、読む人が楽になるよう書く人が面倒をする、ということを普段より強く意識する必要があると感じました。

理想を目指すのは大変です。しかし逆に言うと腕の見せ所でもありますし、やり甲斐を感じられる部分でもあります。

今回の記事が、少しでも皆さんの参考になれば幸いです。フィードバックも大歓迎です!