コンテンツにスキップ

Phase 4:3 層アーキテクチャ

目標

本番環境に近い構成を作ります:VM にパブリック IP を持たせない、入口はロード・バランサ(Load Balancer、LB)だけ、DB はマネージド・サービスを使う。

構成

flowchart LR
  PC[ローカル PC my_ip] -->|:80| LB
  CS[Cloud Shell] -->|SSH| BAS[OCI 要塞]
  subgraph VCN["VCN 10.4.0.0/16"]
    subgraph PUB["public 10.4.1.0/24"]
      LB["LB flexible 10Mbps<br/>lb-nsg"]
    end
    subgraph APP["app 10.4.2.0/24(パブリック IP なし)"]
      VM1["app-1<br/>app-nsg"]
      VM2["app-2<br/>app-nsg"]
      BAS
    end
    NAT[NAT ゲートウェイ]
    SGW[サービス・ゲートウェイ]
  end
  LB -->|:80| VM1 & VM2
  BAS -->|:22| VM1 & VM2
  VM1 & VM2 -->|0.0.0.0/0| NAT --> Internet((Internet))
  VM1 & VM2 -->|Oracle Services| SGW --> ADB[(Autonomous DB<br/>Always Free)]

4 段階に分けて apply します:① ネットワーク → ② VM + 要塞(Bastion)→ ③ LB → ④ ADB。各段階で検証してから次に進みます。

ルーティングとアクセス制御の一覧

通信 経路 制御
ローカル PC → Web Internet → LB(public)→ VM:80 lb-nsg(my_ip)→ app-nsg(ソース = lb-nsg)
運用 SSH Cloud Shell → 要塞 → VM:22 app-nsg(ソース = 要塞のプライベート IP)+ セッションの公開鍵
VM → Internet rt-private 0.0.0.0/0 → NAT ゲートウェイ 送信のみ、受信不可
VM → ADB / OCI の dnf リポジトリ rt-private Oracle Services → サービス・ゲートウェイ ADB の ACL(VCN OCID)

① ネットワーク:NAT ゲートウェイとサービス・ゲートウェイ

# プライベート・サブネット(Subnet)用のルート表(Route Table)
route_rules {
  destination       = "0.0.0.0/0"
  destination_type  = "CIDR_BLOCK"
  network_entity_id = oci_core_nat_gateway.this[0].id
}
route_rules {
  destination       = data.oci_core_services.all[0].services[0].cidr_block
  destination_type  = "SERVICE_CIDR_BLOCK"     # 宛先は CIDR ではなく「サービス」
  network_entity_id = oci_core_service_gateway.this[0].id
}
  • NAT ゲートウェイ(NAT Gateway):プライベートな VM から Internet へ、内側から外側への接続だけができる(≒ AWS の NAT Gateway)
  • サービス・ゲートウェイ(Service Gateway):Internet を経由せず、Oracle の内部ネットワーク経由で OCI のサービスにアクセスする(≒ AWS の VPC Endpoint)
    • 宛先は all-kix-services-in-oracle-services-network のようなサービス名で、実際の IP 範囲は OCI が管理する(kix = 大阪のリージョン・キー)
    • All ... Services In Oracle Services Network を選ぶ(もう一方の選択肢はオブジェクト・ストレージ(Object Storage)だけで、ADB には使えない)
  • 2 つのルールの両方に一致する場合は、より具体的なほうが使われる(最長プレフィックス一致)→ Oracle サービス向けの通信はサービス・ゲートウェイを通る

② VM ×2 + 要塞

  • NSG(ネットワーク・セキュリティ・グループ)は層ごとに作成:2 台の app VM で 1 つの app-nsg を共用。module は nsg_ids を受け取るだけ
  • SSH ルールのソースは IP を直書きせず、要塞の属性を参照する:

    source = "${oci_bastion_bastion.this.private_endpoint_ip_address}/32"
    
  • VM で要塞プラグイン(Oracle Cloud エージェント(Oracle Cloud Agent))を有効にする:

    agent_config {
      plugins_config {
        name          = "Bastion"
        desired_state = "ENABLED"
      }
    }
    
  • 要塞の client_cidr_block_allow_list = ["0.0.0.0/0"]:Cloud Shell の IP は固定されないため。セキュリティは IAM + セッションの公開鍵で担保する

  • セッションは Terraform で管理しない:最長 3 時間で期限切れになるので、管理下に置くと毎回 plan で差分が出る → 使うときに Cloud Shell で作成する

要塞経由でログインする(Cloud Shell)

ssh-keygen -t rsa -b 4096 -f ~/.ssh/bastion_key -N ""

oci bastion session create-managed-ssh \
  --bastion-id <Bastion OCID> --target-resource-id <VM OCID> \
  --target-os-username opc --ssh-public-key-file ~/.ssh/bastion_key.pub \
  --session-ttl 3600 --wait-for-state SUCCEEDED \
  --query 'data.resources[0].identifier' --raw-output     # 1〜2 分かかる

ssh -i ~/.ssh/bastion_key \
  -o ProxyCommand="ssh -i ~/.ssh/bastion_key -W %h:%p -p 22 <SESSION_OCID>@host.bastion.ap-osaka-1.oci.oraclecloud.com" \
  -p 22 opc@<VM のプライベート IP>

検証:送信は本当に NAT ゲートウェイを通っているか?

VM 上で実行 結果 説明
curl ifconfig.me NAT ゲートウェイのパブリック IP Internet への送信は NAT ゲートウェイ経由 ✅
dnf install 成功 NAT ゲートウェイの証明にはならない:リポジトリは yum.ap-osaka-1.oci.oraclecloud.com で Oracle Services に属するため、サービス・ゲートウェイを通っている

③ LB

リスナー(:80)→ バックエンド・セット(ROUND_ROBIN、ヘルス・チェック GET / = 200)→ バックエンド(VM のプライベート IP:80)×2
  • shape = "flexible"、min = max = 10Mbps(variable の validation でほかの値を書けないようにする)
  • バックエンドの IP は module の output から取得する:

    backends = { for k, m in module.app : k => m.private_ip }
    

    for_each の key(app-1 / app-2)は plan の時点で確定しており、value(IP)は apply 後に判明する値でもかまわない

  • NSG の相互参照(source_type = "NETWORK_SECURITY_GROUP"):app-nsg は「lb-nsg からの」80 だけを許可 → LB の IP が変わっても、VM が増減してもルールを変更する必要がない

  • 検証:6 回アクセスすると app-1 / app-2 が交互に返る → ROUND_ROBIN が効いている
  • 同じ LB に対する変更(バックエンド・セット(Backend Set)/バックエンド/リスナー(Listener))は並列ではなく順番に実行され、それぞれ十数秒〜30 秒かかる

④ Autonomous DB

  • is_free_tier = true を直書きし、output でも true であることを再確認
  • Always Free の Autonomous Database(ADB)はプライベート・エンドポイント(Private Endpoint)に対応していない → パブリック・エンドポイント + ACL:

    whitelisted_ips = [module.network.vcn_id, var.my_ip]   # VCN OCID = この VCN からサービス・ゲートウェイ経由で来る通信を許可
    
  • is_mtls_connection_required = false(ACL を設定した場合のみ無効化できる)→ wallet 不要で、TLS の接続文字列でそのまま接続できる

  • ADMIN パスワードは random_password で生成 → state(状態ファイル)には平文で保存される。output には sensitive = true を設定

検証:通信は本当にサービス・ゲートウェイを通っているか?

VM 上で Python(pip install oracledb、thin モードなら Instant Client 不要)から ADB に接続:

select sys_context('USERENV','IP_ADDRESS') from dual;

結果は VM のプライベート IP(NAT ゲートウェイのパブリック IP ではない)→ 通信はサービス・ゲートウェイを経由しており、ACL は VCN OCID で許可されている ✅

Always Free ADB のライフサイクル

7 日間使われないと自動停止し、停止から 90 日後に削除されます。

ハマりどころ

Cloud Shell は FIPS モード

ssh-keygen -t ed25519 → ED25519 keys are not allowed in FIPS mode → RSA を使う。

リソース検索(Resource Search)の遅延

destroy 直後にリソース検索で調べると、要塞がまだ ACTIVE と表示されました。get_bastion を直接呼ぶとすでに DELETED でした。 検索インデックスには遅延があるので、API で直接取得した結果を正とします。

コード全体

04-3tier/env/main.tf
# ------------------------------------------------------------
# Phase 4:3 層構成(LB → Private VM ×2 → Autonomous DB)
#   段階的に apply:① ネットワーク → ② VM + Bastion → ③ LB → ④ ADB
# ------------------------------------------------------------

locals {
  name_prefix = "tflab4"
  vcn_cidr    = "10.4.0.0/16"

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

  # app VM:1 行追加するごとに 1 台増える(Always Free の上限:A1 は合計 4 OCPU / 24GB、ブート・ボリューム(Boot Volume)は合計 200GB)
  #   現在:2 台 × (1 OCPU / 6GB / 50GB) = 2 OCPU / 12GB / 100GB
  app_instances = toset(["app-1", "app-2"])
}

# ---- ① ネットワーク ----
#   public:ロード・バランサ(Load Balancer)を配置(IGW 経由)
#   app   :VM を配置(パブリック IP なし。外向き通信は NAT ゲートウェイ(NAT Gateway)、ADB へはサービス・ゲートウェイ(Service Gateway)経由)
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.4.1.0/24", dns_label = "public", is_public = true }
    app    = { cidr = "10.4.2.0/24", dns_label = "app", is_public = false }
  }

  # SSH は VCN(仮想クラウド・ネットワーク)内部からのみ。要塞(Bastion)のプライベート・エンドポイント(Private Endpoint)が VCN 内にあるため
  ssh_allowed_cidrs = [local.vcn_cidr]

  freeform_tags = local.common_tags
}

# ---- ② app VM ×2(ネットワーク・セキュリティ・グループ(NSG)は security.tf、要塞は bastion.tf)----
module "app" {
  source   = "../modules/compute"
  for_each = local.app_instances

  compartment_ocid = var.compartment_ocid
  name             = "${local.name_prefix}-${each.key}" # tflab4-app-1 / tflab4-app-2
  subnet_id        = module.network.subnet_ids["app"]
  nsg_ids          = [oci_core_network_security_group.app.id]
  assign_public_ip = false

  enable_bastion_plugin = true

  ssh_public_key = file(var.ssh_public_key_path)
  user_data      = file("${path.module}/cloud-init-app.yaml")

  freeform_tags = local.common_tags
}

# ---- ③ LB:パブリック・サブネット(Public Subnet)に配置、バックエンド = app VM ×2 ----
module "lb" {
  source = "../modules/lb"

  compartment_ocid = var.compartment_ocid
  name             = "${local.name_prefix}-lb"
  subnet_ids       = [module.network.subnet_ids["public"]]
  nsg_ids          = [oci_core_network_security_group.lb.id]

  # for 式:module.app(map)→ { app-1 = "10.4.2.139", app-2 = "10.4.2.11" }
  backends = { for k, m in module.app : k => m.private_ip }

  freeform_tags = local.common_tags
}
04-3tier/env/security.tf
# ------------------------------------------------------------
# ネットワーク・セキュリティ・グループ(NSG):「層」ごとに作成し、同じ層の VM で共用
#   lb-nsg :ロード・バランサ(Load Balancer)
#   app-nsg:app VM ×2
#   ルールの「ソース/宛先」に別の NSG を指定可能(source_type = NETWORK_SECURITY_GROUP)
#   → IP を書かずに済み、VM の増減や IP 変更があってもルールの修正は不要
#
#   my_ip ──:80──→ [lb-nsg] LB ──:80──→ [app-nsg] VM ×2 ←──:22── 要塞(Bastion)
#
# checkov CKV_OCI_21(ingress ルールに stateless を要求)→ あえて対応せず、各ルールの skip コメントに理由を記載
#   stateless = 接続状態を記録しないため、戻りトラフィック用のルールが別途必要(ルール数が倍、設定ミスしやすい)。利点は大量トラフィック時の性能
#   ここはトラフィックが小さいため、デフォルトの stateful を使用
# ------------------------------------------------------------

# ---- lb-nsg ----

resource "oci_core_network_security_group" "lb" {
  compartment_id = var.compartment_ocid
  vcn_id         = module.network.vcn_id
  display_name   = "${local.name_prefix}-lb-nsg"

  freeform_tags = local.common_tags
}

# イングレス:ローカル PC の IP から LB の 80 へのアクセスのみ許可
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
  network_security_group_id = oci_core_network_security_group.lb.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "${var.my_ip}/32"
  source_type               = "CIDR_BLOCK"
  description               = "HTTP from my IP"

  tcp_options {
    destination_port_range {
      min = 80
      max = 80
    }
  }
}

# エグレス:LB → app VM の 80(リクエスト転送 + ヘルスチェック)
#   サブネット(Subnet)のセキュリティ・リスト(Security List)で全エグレスを許可済みのため、このルールがなくても通信は可能。NSG 単体で「LB は app にのみ送信」と示すために明記
resource "oci_core_network_security_group_security_rule" "lb_http_to_app" {
  network_security_group_id = oci_core_network_security_group.lb.id
  direction                 = "EGRESS"
  protocol                  = "6"
  destination               = oci_core_network_security_group.app.id
  destination_type          = "NETWORK_SECURITY_GROUP"
  description               = "HTTP to app"

  tcp_options {
    destination_port_range {
      min = 80
      max = 80
    }
  }
}

# ---- app-nsg ----

resource "oci_core_network_security_group" "app" {
  compartment_id = var.compartment_ocid
  vcn_id         = module.network.vcn_id
  display_name   = "${local.name_prefix}-app-nsg"

  freeform_tags = local.common_tags
}

# SSH は要塞からの接続のみ受け付ける
#   要塞は app サブネット内にプライベート・エンドポイント(Private Endpoint、プライベート IP)を持ち、SSH はこの IP から入ってくる
resource "oci_core_network_security_group_security_rule" "app_ssh_from_bastion" {
  #checkov:skip=CKV_OCI_21:Low traffic lab; stateful rules are simpler and sufficient
  network_security_group_id = oci_core_network_security_group.app.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "${oci_bastion_bastion.this.private_endpoint_ip_address}/32"
  source_type               = "CIDR_BLOCK"
  description               = "SSH from Bastion"

  tcp_options {
    destination_port_range {
      min = 22
      max = 22
    }
  }
}

# HTTP は LB からの接続のみ受け付ける(ソースは IP ではなく lb-nsg を指定)
#   app VM 同士、要塞 → VM の 80 は引き続き通信不可
resource "oci_core_network_security_group_security_rule" "app_http_from_lb" {
  #checkov:skip=CKV_OCI_21:Low traffic lab; stateful rules are simpler and sufficient
  network_security_group_id = oci_core_network_security_group.app.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = oci_core_network_security_group.lb.id
  source_type               = "NETWORK_SECURITY_GROUP"
  description               = "HTTP from LB"

  tcp_options {
    destination_port_range {
      min = 80
      max = 80
    }
  }
}