跳转至

Phase 4:3 层架构

目标

做一个接近正式环境的结构:VM 不要公网 IP,入口只有 LB,DB 用托管服务。

架构

flowchart LR
  PC[本机 my_ip] -->|:80| LB
  CS[Cloud Shell] -->|SSH| BAS[OCI Bastion]
  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 GW]
    SGW[Service GW]
  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。每一步验证完再进下一步。

路由和访问控制一览

流量 路径 控制
本机 → Web Internet → LB(public)→ VM:80 lb-nsg(my_ip)→ app-nsg(来源 = lb-nsg)
运维 SSH Cloud Shell → Bastion → VM:22 app-nsg(来源 = Bastion 的私有 IP)+ session 公钥
VM → Internet rt-private 0.0.0.0/0 → NAT GW 只出不进
VM → ADB / OCI 的 dnf 仓库 rt-private Oracle Services → Service GW ADB 的 ACL(VCN OCID)

① 网络:NAT GW 和 Service GW

# Private 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 GW:Private VM 只能从里往外连 Internet(≒ AWS 的 NAT Gateway)
  • Service GW:不经过 Internet,走 Oracle 内部网络访问 OCI 的服务(≒ AWS 的 VPC Endpoint)
    • 目的地是 all-kix-services-in-oracle-services-network 这样的服务名,实际 IP 段由 OCI 维护(kix = 大阪的 region key)
    • 选 All ... Services In Oracle Services Network(另一个选项只有 Object Storage,ADB 用不了)
  • 两条规则都匹配时,走更具体的那条(最长前缀匹配)→ Oracle 服务的流量走 SGW

② VM ×2 + Bastion

  • NSG 按层建:两台 app VM 共用一个 app-nsg。module 只接收 nsg_ids
  • SSH 规则的来源写成 Bastion 的属性,不写死 IP:

    source = "${oci_bastion_bastion.this.private_endpoint_ip_address}/32"
    
  • VM 要开启 Bastion plugin(Oracle Cloud Agent):

    agent_config {
      plugins_config {
        name          = "Bastion"
        desired_state = "ENABLED"
      }
    }
    
  • Bastion 的 client_cidr_block_allow_list = ["0.0.0.0/0"]:Cloud Shell 的 IP 不固定。安全性靠 IAM + session 的公钥

  • session 不放进 Terraform:最长 3 小时就过期,放进去每次 plan 都会出差异 → 用的时候在 Cloud Shell 里建

用 Bastion 登录(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 GW 吗?

在 VM 上执行 结果 说明
curl ifconfig.me NAT GW 的公网 IP 出 Internet 走 NAT GW ✅
dnf install 成功 证明不了 NAT GW:仓库是 yum.ap-osaka-1.oci.oraclecloud.com,属于 Oracle Services,走的是 Service GW

③ LB

Listener(:80)→ Backend Set(ROUND_ROBIN、健康检查 GET / = 200)→ Backend(VM 私有 IP:80)×2
  • shape = "flexible"、min = max = 10Mbps(variable 的 validation 防止写成别的值)
  • Backend 的 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 / Backend / Listener)是排队执行的,不是并行,每个十几到三十秒

④ Autonomous DB

  • is_free_tier = true 写死,output 再确认一次是 true
  • Always Free ADB 不支持 private endpoint → public endpoint + ACL:

    whitelisted_ips = [module.network.vcn_id, var.my_ip]   # VCN OCID = 允许经 Service GW 从这个 VCN 来的流量
    
  • is_mtls_connection_required = false(设了 ACL 才能关)→ 不用 wallet,TLS 连接串直接连

  • ADMIN 密码用 random_password 生成 → state 里是明文;output 设 sensitive = true

验证:流量真的走 Service GW 吗?

在 VM 上用 Python(pip install oracledb,thin mode 不需要 Instant Client)连 ADB:

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

结果是 VM 的私有 IP(不是 NAT GW 的公网 IP)→ 流量经过 Service GW,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 后马上用 Resource Search 查,Bastion 还显示 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:加一条就多建一台(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:放 LB(走 IGW)
#   app   :放 VM(没有公网 IP。出外网走 NAT GW,访问 ADB 走 Service GW)
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 在 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 :LB
#   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
}

# 入:只允许本机的 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 只接受 Bastion 来的连接
#   Bastion 在 app Subnet 里有一个 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 来的连接(来源写 lb-nsg,不写 IP)
#   app VM 之间、Bastion → 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
    }
  }
}