インフラ担当のいないチームがTerraformを導入して良かったこと

今更ながらTerraformを導入したので、良かったこと、やったことや反省を書きます。

## 前提
まず、弊社のインフラ運用状況は下記のようになっていました。

- 2つの自社サービスをAWSで運用している
  - サービスごとにproduction, staging, devの環境がある
  - 1つのアカウントで管理されている

- インフラ専属のエンジニアはおらず、分かる人(というよりは意欲のある人)が開発と並行で触っている
  - 片手間での運用のため、必要になったときだけ弄る形になっており継続的なメンテナンスが行われていない

- 構成に一般的なwebサービスと大きな違いはない
  - ただしwebサーバーが冗長化されていなかったためECSに置き換えたい(staging環境は乗り換え済み)

## 良かったこと
### AWSのリソースの関連、依存関係について詳しくなれた
コンソール上から何となく設定していたリソースがコード上で明記されるようになったことで、どの他リソースに依存しているのか、1対1なのか1対多なのかといったことを意識せざるを得なくなり、結果として理解が深まりました。
特に後述のUdemyの講座での図解がわかりやすく、真似して他書籍を読む際にも作図したことでイメージを掴みやすくなったと感じています。

### インフラに詳しくない人も安心して作業ができるようになった
コスト削減の名目で環境に共通したリソースが存在していたため、どのリソースが本番に影響を与えるのかがわからない状態になってしまっていました。
後述のディレクトリ構成にしたことで、環境に閉じたリソース、共通したリソースが明らかになり、詳しくない人でも安心して変更可能な状態になりました。

### AIの支援をうけることができるようになった
詰まったときも設定をAIに投げることができるようになり、トラブルシューティングの難易度がかなり下がりました。
詳しい人はCopilotの支援を受けて効率化、詳しくない人もAIに教えてもらいつつドキュメントを確認、ということができるようになったので全メンバーに恩恵があると感じています。

### インフラ変更を既存の開発フローに載せることができるようになった
導入までは、インフラ変更が発生したときには関連IssueやPRに作業結果をコメントしておくという運用になっており、インフラに関する変更を追いにくい状況でした。
導入に伴いインフラを管理するリポジトリを作成したことで情報が集約され、既存のPR作成->レビューという開発フローに載せることができるようになりました。
新たな体制を作ることなく、レビューによる知識の伝播や変更チェックなど多くの恩恵が得られるようになりました。

### コメントを残せるようになった
いつか直したいところや、なぜこの設定になっているのか、というようなことがコメントの形で残せるようになりました。
リポジトリに情報を集約しているといっても、実際変更するとき真っ先に見るところへ注釈をつけられるのは安心感が違いますね。
またメンテナンスモードに切り替えるためALBの設定を弄る必要があるとき、今まではWikiに記された手順をコンソール上から実行していましたが、設定をコメントアウトしておき、切り替え時にコメントインしてApplyする形にしたことで、オペミスが無くなりました。

### 各サービス、環境のコストが大まかにわかるようになった
各サービス、環境を1アカウントで運用していたため、どの程度コストがかかっているかということがAWSのサービスごとでしか分からずコスト削減が場当たり的になっていました。
Infracostというサービスを使用することで、tfstateファイルから各サービス環境ごとのコストの概算が分かるようになり、大まかな予算に合わせたコスト削減ができるようになりました。
[infracost を使って インフラコストの見える化をする](https://qiita.com/mziyut/items/4140880e2b4315b37089)

タグ付けとCostExplorerを使った実際のコスト比較などは今後の課題としています。
似たアプリケーションに[terraform-cost-estimation](https://github.com/antonbabenko/terraform-cost-estimation)というのもあったのですが、対応リソースが少なかったためあまり参考になりませんでした。

## やったこと
育休中の合間出勤で行ったため、下記手順を他作業と並行しながら2ヶ月程度で行いました。

1. Terraformについて調べ、ハンズオンを行う
2. Terraformを実際に導入、運用した記事を読む
3. 自社向けに調整した運用案を考える
4. 既存のリソースをひたすらimport
5. 依存関係の整理
6. 無駄な部分のカット
7. ECSへの移行
8. チームへの周知とハンズオン

### Terraformについて調べ、ハンズオンを行う
Udemyで評価が高く、10時間程度で済むことから下記講座を受講しました。
[AWS と Terraformで実現するInfrastructure as Code](https://www.udemy.com/course/iac-with-terraform/)
動画教材は学習時間を見積もりやすいのでファーストステップに利用することが多いです。

- 一般的な冗長化されたwebサービスの作り方を解説してくれており、実践性が高い
- リソースの依存関係を図で説明してくれるのがわかりやすくて良い
- アプリケーションサーバーはEC2のオートスケーリンググループで設定するので、そこだけ想定と違っていた
- お名前ドットコムを使って自分でドメイン取らないといけないのが面倒(.xyzとかなら無料だがよく読まずに契約してしまいレンタルサーバー代を取られた)
- 微妙にNodeのバージョンが古かったりしてそのままだと動かないが、FAQ見ると大体解決されている
- こういうインフラ系のハンズオンは終了後に削除するのが面倒だが、terraform destroyで全部消せるので感動する

また、受講後には[実践Terraform　AWSにおけるシステム設計とベストプラクティス](https://nextpublishing.jp/book/10983.html)を読みました。

- Udemyと違いECSでの運用が書かれているためそれ目当てで読んだ
- ハンズオン部分は各パラメータについて説明不足感はあるが、Udemyの講座が詳しいので合わせて読めば問題ない
- CI/CDはCodePipelineで書かれている
- ベストプラクティスや運用に関する知見が多く書かれており実践のための知識の穴埋めとして役立った

### Terraformを実際に導入、運用した記事を読む
読んだ記事と参考にした部分を軽くまとめました。

#### 2022年の開発を振り返る｜りょうま
https://note.com/ryoma_430/n/n008bb8552f25#ed1b72b0-18d8-42b7-bb6f-efd8aef7284c

- 共有リソースはデータソースを使って依存関係を排除する
- Data-Only Modulesとしてmoduleにラップしてカプセル化する
- 環境分けはディレクトリで対応

- CIの運用
  - PR作成時にplanジョブを起動。terraformのフォーマット、planによるドライランを実行し、差分結果をGithubのコメントに残す
    - コメントを残すのはtcfmtで実現できるらしい
    - 指定ディレクトリ以下のファイルに差分がある時のみパイプラインを走らせる
  - PR作成以降のコミットのたびに上記のチェックを走らせる
  - PRマージ時にapplyジョブを起動し、インフラを更新

#### ベストな Terraform ディレクトリ構成を考察してみた
https://speakerdeck.com/harukasakihara/besutona-terraform-deirekutorigou-cheng-wokao-cha-sitemita

下記2パターンのディレクトリの切り方を考察している

1. 環境ごとにルートディレクトリを切る
2. リソースごとのディレクトリ内に環境ごとのディレクトリを作る

1だとapply回数は1回で済むが、tfstateが大きくなるので各コマンドの実行は遅くなる
また、1だと同環境内では別作業を同時に行うことができない、などロックの問題が発生する

メンバー数が多かったり、大規模なインフラ構成であれば2が良さそう。

#### 「それ、どこに出しても恥ずかしくないTerraformコードになってるか？」
https://speakerdeck.com/yuukiyo/terraform-aws-best-practices

S3でstateファイルを管理するときはDynamoDBと合わせてロックしたほうがよい
最低でもenvごとにディレクトリを切った状態から始める
terraform, provider固定しないと面倒なことになるらしい

#### Terraform ベストプラクティスを整理してみました。
https://dev.classmethod.jp/articles/terraform-bset-practice-jp/

> provider が定義されているモジュールは for_each, count, despends_on の引数が使えません。また、ほかのモジュールに呼び出される共有モジュールはproviderブロックを定義しないことをお勧めします。代わりにルートモジュールのrequire_providersブロックに必要な最小限のバージョン制限を定義します

> アンダーバー（_）を区分記号として使い、名前は英語小文字にする
> リソース名にはリソースタイプを繰り返さないようにする
>   唯一なリソースタイプのリソース名は参照を単純化するためにリソース名をmainにする
> 同じタイプのリソースを区別するために意味があるリソース名（例：primary_\~~~とsecondary_\~~~）にする
> 単一値の変数やタイプは単数名詞に指定し、listやmapの場合は複数名詞に指定する
> 変数とアウトプットの説明を定義する

> 各リソースにはタグを設定する

default_tagsを使って統一的にタグ付けができる
https://dev.classmethod.jp/articles/terraform-aws-provider-default-tags/

#### Terraform を使用するためのベストプラクティス
https://cloud.google.com/docs/terraform/best-practices-for-terraform?hl=ja

> 各環境ディレクトリには、次のファイルが含まれている必要があります。
> backend.tf ファイル。Terraform のバックエンド状態の場所（通常は Cloud Storage）を宣言します。
> サービス モジュールをインスタンス化する main.tf ファイル

#### tfstateをAWS S3で管理する
https://zenn.dev/thefirstpenguin/articles/1143c137e681b8

S3 + DynamoDBでtfstateファイルを管理する

# 解きながら学ぶ学習コンテンツ terraform-practice
https://fintan.jp/page/7192/

> providerブロックもどこに書いても良いですが、versions.tfファイルに書くのがよいでしょう。

- variable.tfで宣言してterraform.tfvarsで設定
- default_tagsは既存のリソースに対しては適用されない
- default_tagsを設定してるときにtags ={}の差分が出てくるときは`terraform refresh`で最新化すると消える

> countの場合、planの結果でもわかる通り各リソースはリソースタイプ.リソース名[インデック番号]で作成されます。
> countで注意するのはインデックス番号が変わるとリソースも作り直しになる点です。
> 作成するリソースの個数が変わるようなリソースの場合countはやめた方がいいです。なので、ループを書く時は次に解説するfor_eachを使うようにしましょう。

#### Terraform 1.5 で追加される import ブロックの使い方
https://zenn.dev/kou_pg_0131/articles/tf-import-block

```terraform
import {
  id = "terraform-import-example" # リソースの識別子
  to = aws_s3_bucket.main         # import 先
}

 resource "aws_s3_bucket" "main" {
   bucket = "terraform-import-example"
 }
```

上記のようにimportブロックで指定してapplyするとImportコマンドを叩かなくてもtfstateファイルにimportできる
終わったらImportブロックは削除してしまって構わない

#### 俺の考えた最弱のTerraformコーディング規約
https://miraitranslate-tech.hatenablog.jp/entry/2023/03/10/120000

> data source と terraform_remote_state を使って複数の infrastructure module を結合する（前項の通り）

バケット名とkeyを使ってリモートのtfstateファイルを見に行ける
参照される側ではoutputを使って明示的に値を吐き出す必要がある

共通基盤(VPC)などはcommonみたいなところにまとめてあげる必要がありそう

> tags 引数は引数の末尾、depends_on / lifecycle の手前に記述する

> output 名
> 通常の命名よりも、より説明的な名称を使う
> {name}_{type}_{attribute} の書式が良い
> どれ程自明な場合でも必ず description を書く

#### Amazon S3 バケットの terraform destroy に注意
https://zenn.dev/toshikish/articles/190fe076cc63f4

S3バケットをterraform destroyするときは、`force destroy`を指定するか、あらかじめすべてのオブジェクトを削除しておく必要がある。
特にパブリックアクセスブロックはバケットより先に削除されるので、バケットが公開されてしまうタイミング、ケースがある。

### 自社向けに調整したところ
#### ディレクトリ構成

```
/
├─ remote_state
├─ common
├─ office
├─ {service_name1}
│  ├─ common
│  ├─ development
│  ├─ staging
│  └─ production
└─ {service_name2}
   ├─ common
   ├─ development
   ├─ staging
   └─ production
```

ディレクトリの切り方には3パターンほどあるようですが、環境間の依存関係を明確にする、というのが一番の目的だったので各サービス下に環境ごとのディレクトリを設け、main.tfを配置する形にしました。
共通リソースはcommonに置き、stagingからdevを参照する、というようなことはしないルールにしました。
common下のリソースは例えば共通ドメインの`aws_route53_zone`などが入っています。
これにより各リソース変更時の影響範囲を明確にし、common下を変更するときは特に気をつけよう、という意識を作ることができたと思います。
最終的にはcommonに入っているリソースを可能な限り少なくしたいです。

他のディレクトリは下記のようになっています。

##### /remote_state
tfstateファイル管理用のS3バケットとDynamoDBを置いています。
tfstateファイルの置き場自体をterraform管理下に置くのは良くないらしいので、Applyした後のtfstateはローカルで破棄し、どういう構成になっているかだけ示したいのでtfファイルだけ残しています

##### /office
各メンバーのIAMやインフラリポジトリ自体のGithubActionsを動かすためのOIDCの置き場所がなかったため作成しましたが、命名が良くなかったなと思ってます。

#### ファイル
ベストプラクティスの中から下記だけ変更しました。

- main.tf
  - どれも短かったのでprovider, version, backendは分けずにmain.tfにすべて載せてしまいました
  - 外注先でもAWS CLIを使っているというメンバーがいたため、profileを明示的に指定するようにしました

- outputs.tf
  - /commonディレクトリにのみ作成し、他リソースからの参照が一覧できるようにしました

ファイルの切り方は基本的にはAWSのサービス単位(`ec2.tf`, `opensearch.tf`など)で作成しました。
例外として`vpc`, `security_group`などをまとめた`network.tf`、ECSの関連リソースをまとめた`ecs.tf`などがあります。
このあたりはリソースの目的に沿ったファイルを作成し、そこにまとめる(`web_app.tf`や`search.tf`)方が後から削除、変更するときには良いのかなと思いましたが、キレイに分けられる自信がなくごちゃっとした構成になってしまいました。

#### CI/CDと運用
CI/CDにはGithubActionsを使用し下記のような運用にしました。

- PR作成、プッシュ、マージ時に各ディレクトリで`terraform plan`を行い、差分があるときにFailureを出す
- マージによる自動Applyはやらない
- 後でTerraform側を追従させるという条件付きで手動変更も容認する
- Moduleはほぼ使わない

きちっとした管理から外れてしまっていますが、同時に触る人数が少ないことや、メンバーのインフラへの苦手意識を少しでも抑え、ある程度試行錯誤を許容したいという気持ちから一旦ファジーな形にしました。
メンバーのインフラへの理解度が向上し、リソースが整理されたら厳格なプロセスにしたいところです。

### 既存のリソースをひたすらimport
これはかなりしんどかったです。
1から作り直すことも考えましたが、リファクタリングという観点に立ってみると、一度すべてコードに起こしてしまったほうがデグレは少なそうと判断しました。

#### Import中気づいたこと
- 単にImportするだけでは競合するパラメータがある
  - 例えばaws_lb_listenerをimportするとき、`default_action[0].target_group_arn`と`forward[0].target_group[0].arn`が競合する
    - forwardの方は冗長なので消してしまって構わないとのことだった
    - 実際に試したところ、コンソール上から確認できる変更はなかった

- Terraform側でresourceとして定義されていたとしても、AWS側ではアクションのような扱いになっていてImportできない場合がある 
  - `aws_opensearch_package_association`や`aws_lb_target_group_attachment`など
  - 新しく書き直しても置き換えは行われないことのほうが多かった

ほか、ここでは書きませんがセキュリティ上良くない設定も結構あったので棚卸しして良かったと思いました…

### チームへの周知とハンズオン
ちょっと長めの時間を設けて、上述のterraform導入の目的やメリット、運用を説明し簡単なハンズオンを行いました。
ハンズオンには[Terraform Practice](https://github.com/cnc4e/terraform-practice)のstep2までを行い、別途既存リソースのImportを実演しました。
(Terraform Practiceですが、流れはわかりやすいものの、ベストプラクティスから外れている部分が結構多いので注釈付きで行う必要があります)
また、Import中に気づいた既存インフラの問題点と対応方針を共有しました。

## 反省と今後に向けての改善点
### そもそもサービスごとにAWSアカウントを分けたほうが良かった
ローテーションはあるものの、サービスごとに触るメンバーがある程度固定されているので、アカウントを分ければ良かったです。
本来であれば環境ごとにも分割すべきですね…

### アクセスキーの管理方法が良くない
GPG鍵を使用してterraformでのアクセスキー発行をセキュアに行うことができるようにしました。
しかし、他メンバーのアクセスキーも見られてしまう、そもそもアクセスキー発行自体が時代遅れ、などの理由で煩雑な割にメリットがあまりないなと思いました。

### 手動変更を許容しているので、差分が発生しているときがあった
トラブルシュート中Applyしようとしたときに差分が発生していて困ったことがありました。
そういうことが発生しうる運用にしてしまったので仕方ないかなと思っています。週1ぐらいで差分が出ているときにSlackへ通知すると良さそうです。
でも、他のメンバーがちゃんと直してくれたのは嬉しかったです。(自分は育休中で触れなかった)

### 各リソースに適切なタグを付与する
本来であればInfracostのような頼るまでもなく、各リソースに適切なタグを付与すればCost Explorerを使ったコストの可視化ができるはずなので。
ちょっとそこまでは手が回りませんでいた。

## 最後に
中々大変でしたが、インフラが整備され、AWSへの理解も深まりチームメンバーにも恩恵があるということでやって良かったです。
小さいチームではインフラ専属の人がいない、という環境もあるかもしれません。
そういうときこそIaCを導入するメリットがあると感じました。