跳转至

Phase 2:Compute + Ansible

目标

  • 在 Phase 1 的网络里建一台 Always Free 的 A1 VM,跑 nginx
  • 理解 cloud-init 和 Ansible 的分工(Phase 2.5)

架构

flowchart LR
  PC[本机浏览器] -->|HTTP :80| VM
  CS[Cloud Shell<br/>private network] -->|SSH :22<br/>Ansible| VM
  subgraph VCN["VCN 10.0.0.0/16"]
    subgraph SN["Public Subnet"]
      VM["tflab-web<br/>A1.Flex 1 OCPU / 6GB<br/>Oracle Linux 9"]
    end
  end

要点

VM 的规格

  • VM.Standard.A1.Flex:ARM(aarch64),用 shape_config 指定 OCPU 和内存
  • boot volume 50GB(最小值,Block Volume 合计 200GB 免费)
  • 镜像用 data source oci_core_images 按条件(Oracle Linux 9 + shape)查最新的,不写死 OCID
  • Subnet 的 OCID 用 data "terraform_remote_state" 从 01-network 的 state 里读 output → 两个目录之间不用手抄 OCID

NSG(Network Security Group)

  • Security List 是 Subnet 级别,NSG 是 VNIC 级别(≒ AWS 的 Security Group)
  • 两者是并集:哪个放行了都能通
  • 这个阶段 HTTP 80 用 NSG 放行

cloud-init 和 Ansible 的分工

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

cloud-init Ansible
执行时机 只在第一次启动时执行一次 什么时候都能反复执行
改了之后 已经在跑的 VM 不会反映;改 user_data 会导致 VM 重建(# forces replacement) 再执行一次就反映
适合 时区、用户等最小初始化 中间件安装、配置、之后的变更管理

一般的分工:Terraform = 基础设施 / cloud-init = 最小初始化 / Ansible = OS 和中间件配置

另一种思路:Immutable

用 Packer 把配置好的镜像打包,要改就换镜像重建 VM,不在运行中的 VM 上改配置。Phase 6 的 Kubernetes 也是这个思路。

SSH:所在网络屏蔽了 22 端口

从本机直接 SSH → Connection timed out(连 github.com:22 也不通,443 可以)。

对策:Cloud Shell + Ephemeral Private Network

  1. Console 右上角 >_ 打开 Cloud Shell
  2. 工具栏左上角 Network: Public → Ephemeral private network setup → 选 VCN 和 Subnet → Use as active network
  3. Cloud Shell 拿到 VCN 里的私有 IP,用私有 IP SSH 到 VM
  4. Security List 里放行"22 from VCN CIDR"

为什么不直接放行 Cloud Shell 的公网 IP?

Cloud Shell 的出口 IP 是 Oracle 的共用 IP,而且会变。放行它,等于对其他 OCI 用户也开放了 22 端口。

Phase 2.5:用 Ansible 配置 nginx

在 Cloud Shell 里执行(PC 的 22 端口不通)。

ansible web -m ping                          # 连通确认
ansible-playbook site.yml --check --diff     # 预览(≒ terraform plan)
ansible-playbook site.yml                    # 执行
结果
第 1 次 changed=3(index.html、security.conf、handler reload)
第 2 次以后 changed=0 → 幂等
  • handler:只有 notify 它的 task 是 changed 时才执行,在所有 task 结束后统一执行一次
  • 确认:响应头 Server: nginx 不带版本号(server_tokens off 生效)
  • Oracle Linux 默认开着 firewalld,OS 内部也要放行 80(云上的 NSG 放行了也不够)
02-compute/ansible/site.yml
# ------------------------------------------------------------
# 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

坑

Cloud Shell 的 Ansible 是 2.9

ansible.posix.firewalld 报 couldn't resolve module → 用短名 firewalld。ansible.builtin.* 可以正常用。

cloud-init status --long 报 PermissionError

/run/cloud-init/cloud.cfg 只有 root 能读 → 加 sudo。

Cloud Shell 的 Upload 只能传单个文件

把 ansible 目录打成 tar.gz 再上传:tar.exe -czf ansible.tar.gz -C 02-compute ansible。 PS 5.1 的 Compress-Archive 打出来的 zip 路径分隔符是 \,Linux 上解不开,所以不用 zip。

完整代码

02-compute/main.tf
# ------------------------------------------------------------
# Phase 2:在 01-network 建好的 Public Subnet 里建 1 台 Always Free VM
#   - 用 data source 获取 AD 和最新的 Oracle Linux 9 镜像
#   - 用 NSG 放行 HTTP(只允许本机 IP)
#   - 用 cloud-init 安装 nginx
# ------------------------------------------------------------

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

  is_flex = endswith(var.instance_shape, ".Flex")

  # 从 01-network 的 state 里取出 output 的值
  vcn_id    = data.terraform_remote_state.network.outputs.vcn_id
  subnet_id = data.terraform_remote_state.network.outputs.public_subnet_id
}

# ---------- 跨目录引用:读取 01-network 的 state ----------
# 现在 state 是本地文件,所以用 backend = "local"
# (Phase 3 会把 state 搬到 Object Storage)
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "${path.module}/../01-network/terraform.tfstate"
  }
}

# ---------- data source:读取已有信息,不会新建任何资源 ----------

# Availability Domain 列表(大阪只有 1 个 AD)
data "oci_identity_availability_domains" "ads" {
  compartment_id = var.compartment_ocid
}

# 和 shape 匹配的 Oracle Linux 9 镜像,取最新的一个
# (A1 是 Arm,会自动筛选出 aarch64 的镜像)
data "oci_core_images" "ol9" {
  compartment_id           = var.compartment_ocid
  operating_system         = "Oracle Linux"
  operating_system_version = "9"
  shape                    = var.instance_shape
  sort_by                  = "TIMECREATED"
  sort_order               = "DESC"
}

# ---------- NSG:绑定在 VNIC 上的防火墙 ----------
# Security List 作用于整个 Subnet,NSG 只作用于绑定了它的 VM,更细粒度
resource "oci_core_network_security_group" "web" {
  compartment_id = var.compartment_ocid
  vcn_id         = local.vcn_id
  display_name   = "${var.project_name}-nsg-web"

  freeform_tags = local.common_tags
}

# NSG 的规则是独立的资源(和 Security List 的写法不同)
resource "oci_core_network_security_group_security_rule" "web_http" {
  network_security_group_id = oci_core_network_security_group.web.id
  direction                 = "INGRESS"
  protocol                  = "6" # TCP
  source                    = "${var.my_ip}/32"
  source_type               = "CIDR_BLOCK"
  description               = "HTTP from my IP"

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

# ---------- VM ----------
resource "oci_core_instance" "web" {
  compartment_id      = var.compartment_ocid
  availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
  display_name        = "${var.project_name}-web"
  shape               = var.instance_shape

  # dynamic block:只有 Flex shape 才需要 shape_config(E2.1.Micro 时不生成这个块)
  dynamic "shape_config" {
    for_each = local.is_flex ? [1] : []
    content {
      ocpus         = var.a1_ocpus
      memory_in_gbs = var.a1_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        = local.subnet_id
    assign_public_ip = true
    nsg_ids          = [oci_core_network_security_group.web.id]
  }

  metadata = {
    ssh_authorized_keys = file(var.ssh_public_key_path)
    user_data           = base64encode(file("${path.module}/cloud-init.yaml"))
  }

  # destroy 时连 boot volume 一起删掉(不删的话 boot volume 会留下来,占用免费额度)
  preserve_boot_volume = false

  freeform_tags = local.common_tags
}