コンテンツにスキップ

Terraform

ひとことで

Terraform は毎回三者比較をしています:コード(あるべき姿)↔ state(前回何を作ったか)↔ 実際のリソース(今どうなっているか)。 だから state が中心です:安全な場所に置く、ロックする、機密を入れない。変更は決まった手順で行います:plan -out → 人が確認 → その plan ファイルを apply → 再 plan + API で実際の状態を確認。

このページは Phase 1、Phase 3、Phase 5a、Phase 5b の実験と、学習全体を通じて固まった作業手順をまとめたものです。前のページで出てきた多くのハマりどころ(コンピュートの「インプレースでも再起動する」、ネットワークの「正方向しか試さなかった」)を、このページで Terraform の視点からもう一度整理します。

1. まず用語を知る

Terraform CloudFormation(AWS) ひとことで
プロバイダ(Provider) (組み込み) 特定のクラウドの API とやり取りするプラグイン。このリポジトリでは oracle/oci、hashicorp/aws
リソース(Resource) Resource Terraform に作成・管理させるもの(VCN、VM…)
データ・ソース(Data source) (直接対応するものはない) 既存の情報を読むだけで、作らない(例:最新イメージを探す、ネームスペースを調べる)
Variable / Output Parameter / Output 入力値 / 出力値
モジュール(Module) Nested Stack 再利用できるリソースのまとまり。terraform を実行するディレクトリは root module と呼ぶ
State (CloudFormation が内部で保持) 「コード上のアドレス ↔ 実際のリソースの ID」の対応表
バックエンド(Backend) — state の保存先(ローカル・ファイル、オブジェクト・ストレージ、S3…)
Plan Change Set プレビュー:何を作成 / 変更 / 削除するか
ドリフト(Drift) Drift だれかが Terraform を通さずにリソースを変え、実際の状態がコードと食い違うこと
インポート(Import) Import 手作業で作ったリソースを Terraform の管理下に入れる
ロック・ファイル(.terraform.lock.hcl) — プロバイダの正確なバージョンとハッシュを記録し、全員・毎回の CI で同じバージョンを使う
リソース・マネージャのスタック / ジョブ Stack / 実行 OCI がホストする Terraform 実行環境(8 節を参照)

2. 原理:三者比較

flowchart TB
  C["コード<br/>あるべき姿"] --> P{"plan"}
  S["state<br/>前回作ったもの"] --> P
  R["実際のリソース<br/>plan 時に読み直す"] --> P
  P --> D["差分 = 実行する操作<br/>+ / ~ / -/+ / -"]
plan の記号 意味 注意
+ 新規作成
~ インプレース変更(ID は変わらない) 再起動しないとは限らない(コンピュートとロード・バランサ を参照)
-/+ 削除して再作成(# forces replacement、ID が変わる) 見たら手を止めて、なぜ再作成になるのか確認する
- 削除

三者比較から導かれる性質:

  • リソースがまだ存在しないうちは、コードをどう変えても plan には + しか出ません:state が空で、比較対象がないからです。~ や -/+ はリソースを作った後にしか出ません
  • state を失うと、Terraform は作成済みのリソースを認識できなくなり、もう一式作ろうとします
  • ID は毎回新しくなります:destroy して apply し直すと、OCID はすべて変わります。Terraform が管理しているのは「コード上のアドレス ↔ ID」の対応で、ID そのものではありません
  • 順序は参照関係から自動で決まります:

    resource "oci_core_internet_gateway" "igw" {
      vcn_id = oci_core_vcn.main.id   # VCN の属性を参照 → 先に VCN を作り、削除は逆順
    }
    

    Phase 1 の 5 つのリソースの実際の作成順:VCN → IGW とセキュリティ・リスト(並行。どちらも VCN だけに依存)→ ルート表 → サブネット。

3. ドリフト:だれかがコンソールでリソースを変えた

Phase 1 の実験:コンソールでセキュリティ・リストに手作業でルールを 1 つ追加してから plan すると:

~ resource "oci_core_security_list" "public" {
    - ingress_security_rules {
        - protocol = "6" -> null
        - source   = "0.0.0.0/0" -> null
      }
  }
Plan: 0 to add, 1 to change, 0 to destroy.

コードにはこのルールがないので、Terraform はそれを削除して実際の環境をコードの姿に戻そうとします。対処は 2 つのうちどちらかです:

方法 やり方
手作業の変更を取り消す terraform apply(今回はこちら)
変更をコードに取り込む .tf に書く → plan が No changes になる(apply 不要)

IaC の基本原則

変更はすべてコードで行い、コンソールで手作業では変えません。手作業で変えても、次の apply でコードの内容に上書きされます。

もう 1 つ「見かけのドリフト」もあります:API が保存する値がコードの書き方と違うため、毎回 plan に差分が出るものです。このリポジトリで遭遇したのは、DevOps リポジトリの default_branch を API が refs/heads/main として保存するケースと、予算アラート・ルールのタグを API がそもそも保存しないケースです。対策は、コードを API が実際に保存する形に合わせることです(CI/CD、コストのガードレール)。

4. state をどこに置くか

ローカル・ファイルの問題

Phase 1〜2 の state はローカル PC の terraform.tfstate でした:PC が壊れれば失われ、ほかの人は操作できず、2 人が同時に apply してもロックがありません。

リモート state:オブジェクト・ストレージ + backend "oci"

# 03-modules/env/versions.tf(抜粋)
terraform {
  required_version = ">= 1.12.0"   # backend "oci" は 1.12 から
  backend "oci" {}                 # 値はここに書かず、init 時に渡す
}
# backend.hcl(Git に入れず、backend.hcl.example をコミット)
bucket    = "tflab-tfstate"
namespace = "<Object Storage namespace>"
key       = "03-modules/env/terraform.tfstate"
region    = "ap-osaka-1"
terraform init "-backend-config=backend.hcl"
  • backend ブロックでは variable を使えません → 値を別ファイルに置き、init 時に渡します(partial configuration と呼びます)
  • 1.12 より前は S3 互換 API + 顧客秘密キーが必要でしたが、今は ~/.oci/config の認証をそのまま使えます
  • ロック:だれかが apply している間は state がロックされ、ほかの人の apply は止められます(Phase 9 で実際に遭遇:前の apply がバックグラウンドでまだ動いているときにもう一度 apply を実行 → Error acquiring the state lock(412)。止められたほうは何もしていません)

state を置くバケットはだれが作る?(鶏が先か卵が先か)

state を置くバケットは、自分の state を自分の中に置けません。そこで別の bootstrap/ ディレクトリを用意し、そこだけローカル state を使って、このバケットだけを作ります:

# 03-modules/bootstrap/main.tf(抜粋)
resource "oci_objectstorage_bucket" "tfstate" {
  access_type = "NoPublicAccess"   # state には OCID や IP などが入っている
  versioning  = "Enabled"          # state が壊れても古いバージョンを取り戻せる
  lifecycle {
    prevent_destroy = true         # destroy するとエラーにして誤削除を防ぐ
  }
}

AWS も同じ構造

AWS との対比 の 10-aws-site/ は S3 バックエンドを使います。Terraform 1.10 以降は use_lockfile = true があり、ロックはバケット内の .tflock オブジェクト 1 つで済み、DynamoDB テーブルを別に作る必要はありません。

ディレクトリをまたいでほかの出力を読む

state はディレクトリごとに 1 つです。Phase 2 の VM は Phase 1 で作ったサブネットを使うので、terraform_remote_state で 01 の output を読み、OCID を手で写しません:

# 02-compute/main.tf(抜粋)
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "${path.module}/../01-network/terraform.tfstate"
  }
}
# 使うとき:data.terraform_remote_state.network.outputs.public_subnet_id

作成は 01 → 02、削除は 02 → 01 の順です。

5. モジュール:繰り返す構造を部品にする

03-modules/
├── bootstrap/   state を置くバケット(ローカル state)
├── modules/     network / compute の部品
└── env/         module を呼び出す。state はバケットに置く

module の中には provider "oci" {} を書きません。「どのプロバイダが必要か、最低どのバージョンか」だけを書き、認証、リージョン、具体的なバージョンは呼び出し側(root module)が決めます:

# module の中(modules/network/versions.tf):下限だけを書く
required_providers {
  oci = {
    source  = "oracle/oci"
    version = ">= 9.0"
  }
}
# root module の中(env/versions.tf):どの系列を使うか決める
#   version = "~> 9.0"   → 9.x の最新。10 には自動で上がらない

for_each と count

アドレス 途中の 1 つを削除すると
for_each this["app-1"](キーで管理) その 1 つだけが削除される
count this[0]、this[1](番号で管理) 後ろの番号がすべてずれる → 再作成される

→ 同じ種類のリソースが複数あるなら for_each を使います。count は「0 個か 1 個か」にだけ使います(例:network module の count = local.create_nat ? 1 : 0。参照は this[0])。

コンピュートとロード・バランサ の app VM は for_each で module を呼び出しています:app_instances = toset(["app-1", "app-2"])。1 行足せば 1 台増えます。

6. インポート:手作業で作ったリソースを Terraform に渡す

Phase 3 では、コンソールで手作業で NSG とルールを作り、それを管理下に入れました:

  1. import ブロックを書く(「どの ID がコード上のどのアドレスに対応するか」を指定)

    import {
      to = oci_core_network_security_group.manual
      id = "<NSG の OCID>"
    }
    
  2. terraform plan "-generate-config-out=generated.tf" → resource のコードが自動生成される

  3. 生成されたコードを整える:OCID を変数や参照に置き換える、Oracle が自動で付ける defined_tags を削除する、共通のタグを付ける
  4. plan(2 to import, 1 to change)→ apply → 再 plan が No changes
  5. import ブロックを削除する

  6. import は自動で探してくれません:Terraform は OCI に何があるかを探さないので、人が指定します。コンパートメント単位でまとめてエクスポートしたいなら、リソース・マネージャのリソース検出(Resource Discovery)などのツールを使います

  7. import ID の形式はリソースごとに違います(NSG ルールは networkSecurityGroups/<NSG OCID>/securityRules/<ルール ID>)。プロバイダのドキュメントの Import の節を確認します
  8. 数に制限がある、全体で 1 つしかないリソースは import しません:OCI ではコンパートメントごとに予算は 1 つしか作れません。import すると destroy 時に削除されてしまう → データ・ソースで読みます(コストのガードレール)

7. 静的チェック:apply の前にツールに見てもらう

ツール チェックする内容
terraform fmt / validate 書式、構文、参照が成り立つか
tflint 書き方:バージョン制約、使われていない変数、description の欠落など(OCI には公式のルールセットがない → Terraform 言語自体のルールだけを使う)
checkov セキュリティ設定:暗号化、メタデータ・サービス、ネットワークの公開範囲など

Phase 4 のコードに対するチェック結果と対応:

ツール 指摘 対応
tflint module にバージョン制約がない module には下限だけを書く(5 節)
checkov 旧メタデータ・サービス(IMDS v1)が無効化されていない 無効化(コンピュート)
checkov ブート・ボリュームの転送中の暗号化が無効 有効化
checkov NSG のルールが stateless ではない 対応しない。skip コメントに理由を残す
resource "oci_core_network_security_group_security_rule" "lb_http_from_my_ip" {
  #checkov:skip=CKV_OCI_21:Low traffic lab; stateful rules are simpler and sufficient
  # ...
}

skip コメント = 「リスクを承知のうえで、あえて対応しない」という判断と理由をコードに残すこと。レビューで見えるうえ、checkov の結果にも表示されます。

静的チェックに通った ≠ 安全

checkov は、要塞の 0.0.0.0/0、ADB のパブリック・エンドポイント、LB が HTTP だけであることを指摘しませんでした。ネットワーク設計 のセキュリティ・リストの許可が広すぎる問題も指摘していません。 ツールが調べられるのは「1 つの属性の値が正しいか」だけです。設計レベルの問題は、やはり人のレビューと逆方向のテストが頼りです。

現在、これらのチェックはサイトの CI で push のたびに実行されています:fmt -check、各ディレクトリの validate、tflint(CI/CD を参照)。

8. リソース・マネージャ:OCI がホストする Terraform

概念 意味
スタック(Stack) コード(zip / Git など)+ 変数の値
ジョブ(Job) スタックに対して plan / apply / destroy / import を 1 回実行する
ローカルの Terraform リソース・マネージャ
state 自分で管理(バケットに置く) リソース・マネージャが保管し、ロックも付く
認証 API キー ジョブを実行するユーザーの ID
実行記録 ローカルの端末 ジョブのログが OCI に残る
その他 ドリフト検出、リソース検出、コンソールのフォーム(schema.yaml)

Phase 5b で観察したこと:

  • 対応する Terraform は最高 1.5.x(ローカルは 1.12+)→ コードの required_version を互換にする必要があります。スタックには backend ブロックを書かず(state はリソース・マネージャに任せる)、プロバイダにも認証を書きません
  • apply ジョブでは「ある plan ジョブが保存した計画を実行する」ことを指定できます(FROM_PLAN_JOB_ID)。ローカルの apply xxx.tfplan と同じ考え方です
  • ドリフト検出:API で VCN 名を変えると → スタックが DRIFTED、その VCN が MODIFIED と表示され、expected / actual の値を確認できます。報告するだけで、修正はしません:もう一度 plan → apply して初めて元に戻ります

9. このリポジトリの作業手順

学習の過程でハマるたびに手順が 1 つずつ増え、最終的に次の形に落ち着きました:

flowchart TB
  A["① 着手前<br/>記録を読む・現状確認<br/>費用とパラメータを調べる"] --> B["② コードを書く<br/>fmt / validate / tflint / checkov"]
  B --> C["③ plan -out<br/>人に要約を見せる"]
  C -->|"同意"| D["④ その plan ファイルを apply"]
  D --> E["⑤ 確認<br/>再 plan = 差分 0<br/>API で実際の値<br/>正方向 + 逆方向のテスト"]
  E --> F["⑥ 記録<br/>設計の理由・ハマりどころ・結果"]
手順 ポイント どの教訓から来たか
パラメータの値を推測しない 順に調べる:terraform providers schema -json → SDK のモデル / 列挙型 → 公式ドキュメント 推測が外れると apply の途中で 400 になり、作りかけが残る
plan -out → 人が確認 要約には add / change / destroy を明記し、destroy と replace は特に目立たせる
その plan ファイルを apply terraform apply x.tfplan:実行内容は確認したものと完全に一致する。使い終わったら削除(機密の実値が入っている) 直接 apply すると plan をやり直すので、確認した内容と変わる可能性がある
一部失敗 作成済みのものには触れず、まず原因を調べる。よくあるのは、作ったばかりの IAM ポリシーがまだ効いていないケース(depends_on は順序を保証するだけで、反映は保証しない)→ 少し待って plan し直す セキュリティ
再 plan terraform plan -detailed-exitcode → exit 0(No changes)
API で実際の値を確認 plan / apply の出力だけを見ない 転送中の暗号化:コードは true、apply は成功したのに、再 plan で false -> true が出て、API でも確かに false だった(コンピュート)
正方向 + 逆方向のテスト つながるべきものがつながり、拒否されるべきものが確かに拒否される セキュリティ・リストの許可が広すぎたのを、正方向だけのテストでは見逃した(ネットワーク)
記録 推測と確認済みの事実を分けて書く。実際の IP、OCID などは書かない

10. 機密を state に入れない

  • random_password で生成した値も、コードに直接書いたパスワードも、平文のまま state に残ります。sensitive = true は plan で表示しないだけです
  • plan ファイルにも実値が入っている → 使い終わったら削除。.gitignore で *.tfstate、*.tfplan、*.tfvars を除外します
  • おすすめ:パスワードはボールト自身に生成させ(OCI の enable_auto_generation)、Terraform は「枠」だけを作り、プログラムは実行時に自分の ID で取りに行きます。実験での確認は セキュリティ を参照してください

11. 設計判断の一覧

判断 採用 不採用 理由
state の置き場所 オブジェクト・ストレージ / S3 + ロック ローカル・ファイル 失われない、共有できる、ロックがある、バージョンがある
state バケットの作り方 独立した bootstrap ディレクトリ(ローカル state)+ prevent_destroy 業務リソースと一緒に置く 鶏が先か卵が先か。誤削除の代償が大きすぎる
バックエンドの値 backend.hcl(Git に入れない)+ partial configuration コードに書く backend ブロックでは variable を使えない。ネームスペースなどは公開しない
プロバイダのバージョン module は下限、root は ~>、ロック・ファイルを Git に入れる 制約を書かない 全員・毎回の CI で同じバージョンを使う
同じ種類の複数リソース for_each count 途中の 1 つを削除しても後ろがずれて再作成されない
手作業で作ったリソース import ブロック + -generate-config-out 削除して作り直す / 放置 サービスを止めずに管理下に入れられる
数に制限があるリソース データ・ソースで読む import import すると destroy で削除されてしまう
apply のやり方 保存した plan ファイルを apply 直接 apply 実行内容が人の確認したものと完全に一致する
静的チェックで対応しない項目 skip コメントに理由を書く チェックを無効化 / 放置 判断と理由がコードに残る
実行環境 ローカルの Terraform(主)。リソース・マネージャは体験のみ すべてリソース・マネージャ 最高 1.5.x までしか対応しておらず、backend "oci" などの新機能が使えない

12. 検証

証明したいこと やり方 結果
コードと実際が一致している apply 後にもう一度 plan No changes(-detailed-exitcode は 0)
destroy で本当にきれいに消えた terraform state list 空
Terraform が管理するのは対応関係で、ID ではない destroy → apply OCID がすべて変わった
リモート state が効いている 2 か所から順に apply 2 回目は 0 changed(1 回目の結果を読めている)
import が成功した import 後にもう一度 plan No changes
ドリフトが修正された リソース・マネージャでもう一度ドリフト検出 すべて IN_SYNC

13. ハマりどころ

現象 原因 対策
-backend-config=backend.hcl が 2 つの引数に分かれる PowerShell 5.1 が . で引数を分割する 引数全体を引用符で囲む。-out=、-generate-config-out= も同じ
No configuration files root module ではないディレクトリで実行した env/、bootstrap/ の中で実行する
count のリソースを 1 つ削除したら、後ろがすべて再作成された 番号で管理している for_each を使う
user_data を変えたら VM が再作成された / 新しいイメージの公開後に再作成を求められる # forces replacement / データ・ソースが変わった 設定は Ansible で管理。ignore_changes(コンピュート)
インプレース(~)の apply 後にサービスが止まった 変えると再起動する属性がある ドキュメントを確認し、1 台ずつ変更する
同じ apply で作ったばかりのポリシーを使うリソースが 404 になる IAM の反映に遅延がある しばらく待ってから apply し直す
毎回の plan に同じ差分が出る API が保存する値がコードと違う / そもそも保存しない コードを API が実際に保存する形に合わせる、またはその属性を書かない
NSG ルールの import ID がコンソールで見えない ルール ID が表示されない データ・ソース oci_core_network_security_group_security_rules で調べる
checkov が UnicodeDecodeError: 'cp932' を出す Windows のシステム・エンコーディングでファイルを読む $env:PYTHONUTF8 = '1'
tflint --recursive で設定が見つからない サブディレクトリに .tflint.hcl がない --config に絶対パスを指定する
checkov が失敗と判定するが、コードは正しい checkov は特定の書き方しか認識しない checkov の書き方に合わせる、またはソースを読む
(注意)リソース・マネージャが対応する Terraform のバージョンには上限がある(当時は最高 1.5.x) ローカルのバージョンのほうが新しい 先にバージョンを確認し、required_version を互換にする(このリポジトリのコードはもともと互換で、問題は出なかった)

14. 確認問題

1. コードをまだ apply していない状態で、再作成を伴う属性を変えた。plan に -/+ は出る?

出ません。+ だけです。state が空で比較対象がないので、すべて新規作成になります。

2. 同僚がコンソールでセキュリティ・リストにルールを 1 つ追加した。次にあなたが apply するとどうなる?

そのルールは削除されます。コードにないからです。取り消す(apply)か、ルールをコードに書く(plan が No changes になる)かのどちらかです。

3. state を置くバケットを別ディレクトリにして、ローカル state で管理するのはなぜ?

state を置くバケットは、自分の state を自分の中に置けないからです(鶏が先か卵が先か)。独立した bootstrap ディレクトリでこのバケットだけを作り、prevent_destroy で誤削除を防ぎます。

4. module の中に provider ブロックを書かず、バージョンも下限だけにするのはなぜ?

認証、リージョン、具体的なバージョンは呼び出し側が決めるべきだからです。module で固定すると、呼び出し側がリージョンを変えたりバージョンを上げたりできず、複数の module の制約がぶつかる可能性もあります。

5. 3 台の VM を count で作った。真ん中の 1 台を削除するとどうなる?

3 台目の番号が 2 から 1 に変わり、Terraform は this[1] の属性が変わった、this[2] がなくなったと判断します → 3 台目が再作成されます。for_each ならその 1 台だけが削除されます。

6. plan -out の後、直接 terraform apply せず、必ずそのファイルを apply するのはなぜ?

直接 apply すると plan をやり直すので、その間にだれかがリソースやコードを変えていたら、実行内容が確認したものと違ってしまいます。plan ファイルを apply すれば、実行されるのは確認したものそのものです。

7. apply は成功し、再 plan も No changes。ほかに何をする?

API で重要な属性の実際の値を確認し、正方向 + 逆方向の機能テストをします。plan は最初に refresh(実際の状態の読み直し)をするので、属性が効いていなければ普通は差分として表示されます(このリポジトリの転送中の暗号化はこうして見つかりました)。しかし plan は「設計そのものの穴」、たとえばセキュリティ・リストの許可が広すぎることには気づけません。それは逆方向のテストの役目です。

8. パスワードに sensitive = true を付けた。それでも漏れる?

漏れます。sensitive は plan の出力に表示しないだけで、state と plan ファイルには平文で残ります。パスワードはボールトに生成させ、Terraform は扱わないようにします。

9. checkov にすべて通った。この設計は安全だと言える?

言えません。checkov は 1 つの属性の値しか調べません。このリポジトリでも、要塞の 0.0.0.0/0 やセキュリティ・リストの許可が広すぎるといった設計の問題は指摘されませんでした。

コード全文

03-modules/bootstrap/main.tf(state を置くバケット)
# ------------------------------------------------------------
# Phase 3-①:Terraform の state を置くオブジェクト・ストレージのバケット
#   鶏と卵の問題:state を置くバケット自体も、だれかが作る必要がある
#   → このディレクトリ自身の state はローカル(local)に置き、バケットを作るだけにする
#   → ほかのディレクトリ(03-modules/env)の state はこのバケットに置く
# ------------------------------------------------------------

locals {
  common_tags = {
    project    = "tf-lab"
    managed_by = "terraform"
  }
}

# オブジェクト・ストレージのネームスペース(テナンシごとに 1 つ固定。バックエンドの設定で使う)
data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_objectstorage_bucket" "tfstate" {
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "${var.project_name}-tfstate"

  access_type  = "NoPublicAccess" # state には OCID や IP などが入っている。絶対に公開しない
  storage_tier = "Standard"
  versioning   = "Enabled" # state が壊れたときに古いバージョンへ戻せる

  freeform_tags = local.common_tags

  # バケットの誤削除を防ぐ(destroy するとエラーになる)。本当に削除するときは先にこの行を false にする
  lifecycle {
    prevent_destroy = true
  }
}
03-modules/env/versions.tf(バックエンドの設定)
terraform {
  required_version = ">= 1.12.0" # backend "oci" は 1.12 から

  required_providers {
    oci = {
      source  = "oracle/oci"
      version = "~> 9.0"
    }
  }

  # state は bootstrap で作ったバケットに置く
  # backend ブロックでは variable を使えない → bucket / namespace などは backend.hcl に書き、
  # init 時に `terraform init -backend-config=backend.hcl` で渡す(partial configuration)
  backend "oci" {}
}
03-modules/env/main.tf(module の呼び出し)
# ------------------------------------------------------------
# Phase 3-③:モジュール(module)を呼び出して環境を 1 セット作成(state はオブジェクト・ストレージ(Object Storage))
#   01/02 の環境(tflab-*、10.0.0.0/16)はそのまま残し、ここでは別に tflab3-*(10.1.0.0/16)を作成
# ------------------------------------------------------------

locals {
  name_prefix = "tflab3"
  vcn_cidr    = "10.1.0.0/16"

  common_tags = {
    project    = "tf-lab"
    managed_by = "terraform"
    phase      = "3"
  }

  # 作成する VM:1 行追加するごとに 1 台増える(Always Free の上限に注意:A1 は合計 4 OCPU / 24GB、ブート・ボリューム(Boot Volume)は合計 200GB)
  instances = {
    web = { subnet = "public", assign_public_ip = true }
  }
}

module "network" {
  source = "../modules/network"

  compartment_ocid = var.compartment_ocid
  name_prefix      = local.name_prefix
  vcn_cidr         = local.vcn_cidr
  vcn_dns_label    = local.name_prefix

  subnets = {
    public  = { cidr = "10.1.1.0/24", dns_label = "public", is_public = true }
    private = { cidr = "10.1.2.0/24", dns_label = "private", is_public = false }
  }

  # 利用中のネットワークで 22 番ポートがブロックされている → VCN(仮想クラウド・ネットワーク)内部(Cloud Shell の Private Network)のみ許可
  ssh_allowed_cidrs = [local.vcn_cidr]

  freeform_tags = local.common_tags
}

# module でも for_each が使える:local.instances の件数分だけ呼び出される
#   state 上は module.compute["web"].oci_core_instance.this のようなアドレスになる
module "compute" {
  source   = "../modules/compute"
  for_each = local.instances

  compartment_ocid = var.compartment_ocid
  name             = "${local.name_prefix}-${each.key}"
  vcn_id           = module.network.vcn_id
  subnet_id        = module.network.subnet_ids[each.value.subnet]
  assign_public_ip = each.value.assign_public_ip

  ssh_public_key     = file(var.ssh_public_key_path)
  user_data          = file("${path.module}/cloud-init.yaml")
  http_allowed_cidrs = ["${var.my_ip}/32"]

  freeform_tags = local.common_tags
}
03-modules/env/manual.tf(import した NSG)
# ------------------------------------------------------------
# コンソールで手作業で作り、後から import したリソース
#   `terraform plan -generate-config-out=generated.tf` で生成し、整えてからここに移した:
#   - 直書きの OCID → variable / ほかのリソースの参照に変更
#   - defined_tags(Oracle-Tags.CreatedBy など OCI が自動で付けるタグ)→ 削除(書かなくても差分は出ない)
#   - freeform_tags = {} → local.common_tags に変更(apply でインプレース更新され、タグが付く)
# ------------------------------------------------------------

resource "oci_core_network_security_group" "manual" {
  compartment_id = var.compartment_ocid
  vcn_id         = module.network.vcn_id
  display_name   = "tflab3-manual-nsg"

  freeform_tags = local.common_tags
}

resource "oci_core_network_security_group_security_rule" "manual_https" {
  network_security_group_id = oci_core_network_security_group.manual.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = local.vcn_cidr
  source_type               = "CIDR_BLOCK"
  stateless                 = false
  description               = "manual rule"

  tcp_options {
    destination_port_range {
      min = 443
      max = 443
    }
  }
}
04-3tier/.tflint.hcl
# tflint の設定(Phase 5)
#   実行:04-3tier ディレクトリで `tflint --init` → `tflint --recursive --config "$PWD/.tflint.hcl"`
#   OCI 専用のルールセットは公式版がない → Terraform 言語自体のルール(書き方、未使用の変数など)だけを使う

plugin "terraform" {
  enabled = true
  preset  = "recommended" # 推奨ルールセット("all" はさらに厳しい)
}