跳转至

计算和负载均衡

一句话

把 VM 当成可以随时替换的零件:镜像不写死、配置交给 Ansible(或打进镜像)、前面放 Load Balancer(LB),一台重启或替换时服务不中断。 另外要记住:Terraform 的 plan 只告诉你"OCID 变不变",不告诉你"会不会重启"。

本页整理自 Phase 2、Phase 3、Phase 4、Phase 5a、Phase 9 的实验。网络和登录方式(Bastion)见 网络设计。

1. 先认识这些组件

OCI AWS 一句话
Shape Instance Type VM 的规格。名字带 .Flex 的可以自己指定 OCPU 数和内存
OCPU (约 2 个 vCPU) OCI 的 CPU 单位 = 1 个物理核。x86 上 1 OCPU = 2 vCPU;Arm(A1)没有超线程,1 OCPU = 1 vCPU
Image AMI 操作系统镜像。Oracle Linux、Ubuntu 等,按 shape(x86 / Arm)区分
Boot Volume 根 EBS 卷 系统盘。最小 50GB
Availability Domain(AD) Availability Zone region 里独立的数据中心。大阪只有 1 个 AD
Fault Domain(FD) (近似)Placement Group 的分散 AD 里的机架分组,每个 AD 有 3 个。分到不同 FD = 不会同时因为同一台物理机坏掉
Oracle Cloud Agent SSM Agent VM 里的代理程序,负责监控、Bastion 连接等(按 plugin 开关)
Instance Metadata Service(IMDS) 同左 VM 内部访问 169.254.169.254 拿到自己的信息(OCID、SSH 公钥、cloud-init 数据…)
Flexible Load Balancer ALB / NLB 负载均衡器。带宽可以指定(10Mbps 是免费的)
Listener / Backend Set / Backend Listener / Target Group / Target LB 的三层结构,见第 5 节

2. 原理:VM 的属性分三类

Terraform 改 VM 的属性时,结果有三种。plan 里的符号只能区分其中两种:

类别 plan 的符号 例子(本仓库实际碰到的)
改了要重建(OCID 变) -/+(# forces replacement) 镜像(source_id)、cloud-init(user_data)
原地修改,但 VM 会重启 ~ 传输中加密(is_pv_encryption_in_transit_enabled,文档写明会重启)
原地修改,不影响运行 ~ 标签(freeform_tags)、显示名

in-place(~)≠ 不停机

Phase 5a 给两台 app VM 同时改了 IMDS 和传输中加密(一次 apply 改了两项,分不出是哪一项导致重启),plan 是 0 to add, 2 to change(in-place)。 apply 后两台 VM 同时重启,LB 的健康检查全部失败,访问返回 502 Bad Gateway,几十秒后才恢复。 → 会不会重启要看 provider 文档,或先在测试环境试。正式环境一台一台改(-target 分两次 apply,或先从 LB 摘掉)。

3. 初始化和配置:谁负责什么

一台 VM 从"建好"到"能提供服务",可以由不同的工具负责:

Terraform cloud-init Ansible
负责 VM、网络等云上的资源 第一次启动时的最小初始化(时区、用户) OS 里的中间件安装、配置、之后的变更
什么时候执行 apply 时 只在第一次启动时执行一次 什么时候都能反复执行
改了之后 — 已经在跑的 VM 不会反映;而且改 user_data 会让 VM 重建 再执行一次就反映

本仓库的做法:一开始用 cloud-init 装 nginx,后来改成 cloud-init 只做最小初始化,nginx 交给 Ansible。

Ansible 的关键性质是幂等:同一个 playbook 执行多次,结果一样。实际验证:第 1 次 changed=3,第 2 次以后 changed=0。

另一种思路:Immutable(不可变)

不在运行中的 VM 上改配置,而是把配置好的环境打包成镜像(Packer)或容器,要改就换新镜像、重建 VM。 Kubernetes 就是这个思路(见 Kubernetes 页)。

4. 镜像:不写死 OCID,但也不自动换

# 按条件查最新的镜像(Oracle Linux 9 + 这个 shape 能用的)
data "oci_core_images" "ol9" {
  compartment_id           = var.compartment_ocid
  operating_system         = "Oracle Linux"
  operating_system_version = "9"
  shape                    = var.shape
  sort_by                  = "TIMECREATED"
  sort_order               = "DESC"
}

resource "oci_core_instance" "this" {
  source_details {
    source_type = "image"
    source_id   = data.oci_core_images.ol9.images[0].id   # 最新的那个
  }
  lifecycle {
    ignore_changes = [source_details[0].source_id]        # 已经建好的 VM 不跟着换
  }
}
  • 不写死 OCID:镜像 OCID 每个 region 不一样,而且旧镜像会下架
  • ignore_changes:没有这一行的话,Oracle 每次发布新镜像,下次 plan 就会要求重建所有 VM
  • 结果:新建的 VM 用最新镜像,已有的 VM 不动。想换镜像时,有意识地对那台 VM 执行 terraform apply -replace=...

5. Load Balancer:三层结构

flowchart TB
  C["客户端"] -->|":80"| L["Listener<br/>对外的端口和协议"]
  L --> BS["Backend Set<br/>ROUND_ROBIN<br/>健康检查 GET / = 200"]
  BS --> B1["Backend<br/>app-1 :80"]
  BS --> B2["Backend<br/>app-2 :80"]
  • 健康检查:LB 每 10 秒访问一次每台 VM 的 GET /,不是 200 的 VM 就不再分配流量。全部都不健康时返回 502(就是第 2 节那次)
  • LB 放 public subnet,VM 放 private subnet:VM 不需要公网 IP(见 网络设计)
  • Backend 的 IP 从 module 的输出取,不手写:

    backends = { for k, m in module.app : k => m.private_ip }   # { app-1 = "10.4.2.x", app-2 = "10.4.2.y" }
    

    for_each 的 key(app-1 / app-2)在 plan 时就确定了,value(IP)可以是 apply 后才知道的值

  • Always Free 的条件写进代码:带宽 10Mbps 以外就收费,所以用 validation 锁住

    variable "bandwidth_mbps" {
      type    = number
      default = 10
      validation {
        condition     = var.bandwidth_mbps == 10
        error_message = "Always Free 的 Flexible LB 只能是 10Mbps。"
      }
    }
    

这里的 LB 是 L7(HTTP),OKE 的是 L4(TCP)

本页的 Listener 协议是 HTTP:LB 看得懂 HTTP,健康检查也按 HTTP 判断。 Kubernetes 的 Service type=LoadBalancer 建出来的同一种 LB,Listener 是 TCP(只转发连接)。区别见 Kubernetes 页。

6. VM 本身的安全设置

详细的安全设计在"安全"页,这里只列和 VM 直接相关的三条:

设置 做法 为什么
不给公网 IP assign_public_ip = false,放 private subnet 公网 IP + 开着的端口 = 攻击面
只开 IMDS v2 are_legacy_imds_endpoints_disabled = true v1 不需要 token 就能读元数据。应用有 SSRF 漏洞时,攻击者能借它读到 VM 的信息(和 AWS 的 IMDSv2 是同一个问题)
boot volume 传输中加密 is_pv_encryption_in_transit_enabled = true 存储本身默认加密;这个是 VM ↔ boot volume 之间的传输也加密

传输中加密:建 VM 时设了,实际却没生效

Phase 9 代码里写了 true,apply 成功,但再 plan 出现了差异 false -> true;用 API 查,VM 和 boot volume attachment 的实际值都是 false。 文档说建好之后也能打开(会重启)→ 第 2 次 apply(in-place update)后才变成 true,再 plan No changes。 apply 成功不代表设置生效:apply 后一定要再 plan,并用 API 确认实际状态。

7. Always Free 的计算资源

资源 免费额度 本仓库的用法
A1(Arm)Flex 合计 4 OCPU / 24GB 每台 1 OCPU / 6GB,最多 4 台
E2.1.Micro(x86) 最多 2 台(1/8 OCPU、1GB) 没用(太小)
Block Volume boot volume 也算在内,合计 200GB boot volume 最小 50GB → 实际最多 4 台 VM
Flexible LB 1 个,10Mbps Phase 4 用了
  • 只能建在 Home Region(本仓库是大阪)
  • A1 经常容量不足:Phase 11 建 OKE 的 node 时报 Out of host capacity。第一次和立刻重试都失败;之后每 10 分钟重试一次,第 3 次(约 25 分钟后)才成功。免费的 Arm 资源抢的人多,要有"等一等再试"的准备
  • 几个 Phase 同时跑会超额度 → 下一个 Phase 要用之前,先 destroy 上一个

8. 设计决策一览

决策 选了 没选 理由
shape A1.Flex 1 OCPU / 6GB E2.1.Micro 免费额度内性能最好;Micro 只有 1GB 内存
镜像 data source 查最新 + ignore_changes 写死 OCID / 自动跟随最新 新 VM 用新镜像,已有 VM 不被意外重建
初始化 cloud-init 只做最小初始化 cloud-init 装所有东西 改 user_data 会重建 VM;已有 VM 改不了
配置管理 Ansible(幂等) 手动 SSH 进去改 可重复、可预览(--check --diff)、留在代码里
台数 module + for_each,加一条就多一台 复制粘贴 resource 台数变化只改一处
入口 LB(HTTP、健康检查、ROUND_ROBIN) 直接访问 VM 一台坏了 / 重启时还有另一台
免费条件 validation、写死 只靠记忆 plan 阶段就报错,不会建出收费资源

9. 验证

要证明的事 怎么做 结果
LB 真的在分流 连续访问 6 次 app-1 / app-2 交替返回 → ROUND_ROBIN 生效
Ansible 是幂等的 执行两次 playbook 第 2 次 changed=0
nginx 的配置生效了 看响应头 Server: nginx 不带版本号(server_tokens off)
安全设置真的生效了 再 plan + 用 API 查 VM 和 boot volume attachment 的实际值 第 1 次 apply 后再 plan 有差异、API 是 false;第 2 次 apply 后才是 true

10. 坑

现象 原因 对策
in-place(~)apply 后 LB 返回 502 改的属性会让 VM 重启,两台同时重启 一台一台改
改了 cloud-init,VM 被重建 user_data 是 # forces replacement 配置交给 Ansible
新镜像发布后 plan 要求重建 VM data source 查到了新镜像 ignore_changes = [source_details[0].source_id]
传输中加密在新建时没生效(再 plan 出现 false -> true) 建 VM 时的请求被忽略 第 2 次 apply;用 API 确认
If LaunchOptions is provided, NetworkType must be specified 写了 launch_options 就必须写 network_type network_type = "PARAVIRTUALIZED"(和镜像默认值一致)
checkov 说没开传输中加密,但其实开了 checkov 只认 launch_options 里的写法 按 checkov 的写法改
Out of host capacity 免费的 A1 资源暂时不够 隔一段时间重试
同一个 LB 的多个变更 apply 很慢 LB 的变更排队执行,每个十几到三十秒 正常现象

11. 自测问题

1. plan 显示 ~(in-place),可以放心在白天 apply 吗?

不一定。~ 只表示 OCID 不变,有些属性改了 VM 会重启(例:传输中加密)。要查文档或先在测试环境试;多台的话一台一台改。

2. 想给已经在运行的 VM 加一个软件,改 cloud-init 可以吗?

不好。cloud-init 只在第一次启动时执行,已有的 VM 不会反映;而且改 user_data 会让 Terraform 重建 VM。应该用 Ansible。

3. 为什么镜像用 data source 查,又要 ignore_changes?

用 data source 是为了不写死 OCID、新 VM 用最新镜像;ignore_changes 是为了新镜像发布时,已经在跑的 VM 不被重建。换镜像要有意识地做。

4. LB 返回 502,最可能是什么原因?

所有 backend 的健康检查都失败了:VM 停了 / 重启中、nginx 没起来、OS 防火墙没开端口、NSG 没放行 LB 来的流量。

5. Always Free 最多能建几台 VM?瓶颈是什么?

A1 合计 4 OCPU / 24GB,boot volume 合计 200GB 且每台最小 50GB → 最多 4 台(每台 1 OCPU)。瓶颈往往是 Block Volume。

6. IMDS v1 有什么风险?

不需要 token 就能读元数据。应用有 SSRF 漏洞时,攻击者可以让服务器替他请求 169.254.169.254,读到 VM 的信息。只开 v2(要带 token 头)就能挡住这类攻击。

7. 代码里写了传输中加密 = true,apply 成功了,可以认为已经加密了吗?

不能。本仓库实际碰到过:建 VM 时这个设置被忽略,API 查到的是 false。要用 API 确认实际值。

完整代码

04-3tier/modules/compute/main.tf(VM)
# ------------------------------------------------------------
# compute module:VM 1 台(Phase 4 版)
#   要建几台由调用方决定(env 里用 for_each 调用这个 module)
# 和 03 的区别:
#   - NSG 不在 module 里建了 → 调用方按"层"建 NSG(app-nsg 等),用 nsg_ids 传进来
#     (同一层的 VM 共用一个 NSG,规则只写一次)
#   - 可以打开 Oracle Cloud Agent 的 Bastion plugin(Bastion 的 Managed SSH session 要用)
# ------------------------------------------------------------

locals {
  is_flex = endswith(var.shape, ".Flex")
}

data "oci_identity_availability_domains" "ads" {
  compartment_id = var.compartment_ocid
}

data "oci_core_images" "ol9" {
  compartment_id           = var.compartment_ocid
  operating_system         = "Oracle Linux"
  operating_system_version = "9"
  shape                    = var.shape
  sort_by                  = "TIMECREATED"
  sort_order               = "DESC"
}

resource "oci_core_instance" "this" {
  compartment_id      = var.compartment_ocid
  availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
  display_name        = var.name
  shape               = var.shape

  dynamic "shape_config" {
    for_each = local.is_flex ? [1] : []
    content {
      ocpus         = var.ocpus
      memory_in_gbs = var.memory_in_gbs
    }
  }

  source_details {
    source_type             = "image"
    source_id               = data.oci_core_images.ol9.images[0].id
    boot_volume_size_in_gbs = 50 # 最小值。Block Volume 合计 200GB 免费
  }

  create_vnic_details {
    subnet_id        = var.subnet_id
    assign_public_ip = var.assign_public_ip
    hostname_label   = var.name
    nsg_ids          = var.nsg_ids
  }

  # boot volume 和 VM 之间的数据传输也加密(存储本身默认就加密)(checkov CKV_OCI_4)
  #   顶层也有同名属性,但 checkov 只看 launch_options 里的这个
  launch_options {
    is_pv_encryption_in_transit_enabled = true
  }

  # 关掉旧版 Metadata 服务(IMDS v1):v1 不要 token 就能读元数据,SSRF 攻击时容易被利用(checkov CKV_OCI_5)
  #   只留 v2(/opc/v2/,请求要带 Authorization: Bearer Oracle 头)。Oracle Cloud Agent / cloud-init 都支持 v2
  instance_options {
    are_legacy_imds_endpoints_disabled = true
  }

  # Oracle Cloud Agent 的 plugin 设定:这里只写要改的 plugin,没写的保持默认
  agent_config {
    plugins_config {
      name          = "Bastion"
      desired_state = var.enable_bastion_plugin ? "ENABLED" : "DISABLED"
    }
  }

  # user_data 是 null 时不写这个 key(merge 一个空 map)
  metadata = merge(
    { ssh_authorized_keys = var.ssh_public_key },
    var.user_data == null ? {} : { user_data = base64encode(var.user_data) }
  )

  preserve_boot_volume = false

  freeform_tags = var.freeform_tags

  # 新镜像发布后 data source 会拿到新的 OCID → 不加这行的话 plan 会要求重建 VM
  lifecycle {
    ignore_changes = [source_details[0].source_id]
  }
}
04-3tier/modules/lb/main.tf(LB)
# ------------------------------------------------------------
# lb module:Flexible Load Balancer 一套(HTTP)
#   LB ─ Listener(:80)─ Backend Set(ROUND_ROBIN + Health Check)─ Backend ×N
#   Always Free:Flexible LB 1 台、带宽 10Mbps(min = max = 10)
# ------------------------------------------------------------

resource "oci_load_balancer_load_balancer" "this" {
  compartment_id = var.compartment_ocid
  display_name   = var.name

  # "flexible" + 带宽固定 10Mbps = Always Free 的条件(variables.tf 里有 validation)
  shape = "flexible"
  shape_details {
    minimum_bandwidth_in_mbps = var.bandwidth_mbps
    maximum_bandwidth_in_mbps = var.bandwidth_mbps
  }

  subnet_ids                 = var.subnet_ids
  is_private                 = false # 公网 LB(分配公网 IP)
  network_security_group_ids = var.nsg_ids

  freeform_tags = var.freeform_tags
}

# Backend Set = 后端 VM 的组 + 分配方式 + 健康检查
resource "oci_load_balancer_backend_set" "this" {
  load_balancer_id = oci_load_balancer_load_balancer.this.id
  name             = "${var.name}-bs"
  policy           = "ROUND_ROBIN" # 轮流分配

  # 健康检查:LB 定期访问每台 VM 的 GET / ,不是 200 的 VM 就不再分配流量
  health_checker {
    protocol          = "HTTP"
    port              = var.backend_port
    url_path          = "/"
    return_code       = 200
    interval_ms       = 10000
    timeout_in_millis = 3000
    retries           = 3
  }
}

# Backend = 组里的每台 VM(用私有 IP + 端口登记)
#   for_each 的 key(app-1 等)在 plan 时就要确定;value(IP)可以是 apply 后才知道的值
resource "oci_load_balancer_backend" "this" {
  for_each = var.backends

  load_balancer_id = oci_load_balancer_load_balancer.this.id
  backendset_name  = oci_load_balancer_backend_set.this.name
  ip_address       = each.value
  port             = var.backend_port
}

# Listener = LB 对外接受连接的端口,收到的请求交给哪个 Backend Set
resource "oci_load_balancer_listener" "http" {
  load_balancer_id         = oci_load_balancer_load_balancer.this.id
  name                     = "${var.name}-http"
  default_backend_set_name = oci_load_balancer_backend_set.this.name
  port                     = var.listener_port
  protocol                 = "HTTP"
}
02-compute/ansible/site.yml(nginx 的配置)
# ------------------------------------------------------------
# Phase 2.5:用 Ansible 管理 nginx(在 Cloud Shell 里执行)
#   ansible-playbook site.yml --check --diff   # 先预览(相当于 terraform plan)
#   ansible-playbook site.yml                  # 执行
# 再执行一次,所有 task 都应该是 ok(changed=0)→ 这就是幂等
# ------------------------------------------------------------
- name: Configure nginx web server
  hosts: web
  become: true # 用 sudo 执行

  vars:
    page_message: "Hello from Ansible on OCI"

  tasks:
    - name: Install nginx
      ansible.builtin.dnf:
        name: nginx
        state: present

    - name: Deploy index.html
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /usr/share/nginx/html/index.html
        mode: "0644"

    # 配置文件有变化时才 reload nginx(handler)
    - name: Hide nginx version in response headers
      ansible.builtin.copy:
        dest: /etc/nginx/conf.d/security.conf
        content: "server_tokens off;\n"
        mode: "0644"
      notify: Reload nginx

    - name: Ensure nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    # Oracle Linux 默认开着 firewalld,OS 内部也要放行 80
    # Cloud Shell 的 Ansible 是 2.9,不认识 ansible.posix.firewalld(FQCN),所以用短名
    - name: Allow HTTP in firewalld
      firewalld:
        service: http
        permanent: true
        immediate: true
        state: enabled

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded