ORACLE Cloud の「24GB RAM + 4CPU + 200GBストレージのLinuxサーバーを無料で手にいれる方法」を terraform で

# 概要
- 先日、興味深い記事がバズってました
  https://qiita.com/shota0616/items/d0dfa1f70af073aa9554
- ORACLE Cloud ( OCI ) では、永年無料で 24GB RAM + 4CPU + 200GBストレージ の Linux サーバが使えるそう。これはぜひ欲しいです
- わがまま条件
  - 再現性が必要なので terraform で
  - アウトバウンドの パブリック IP は固定したい
  - 認証はキーレスで
- では、やっていきましょう

# ORACLE Cloud 無料枠
美味しい話には裏があるのではないか確認する
https://docs.oracle.com/ja-jp/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm#Details_of_the_Always_Free_Compute_instance__a1_flex


# Terraform

OCI での Terraform は、下記のチュートリアルがわかりやすい
https://oracle-japan.github.io/ocitutorials/intermediates/terraform/



## 認証情報
この後で登場する認証をまとめとく。全部で 3 つ。

### terraform 用
 - terraform の認証情報の設定方法は、上記のチュートリアルに書かれてる
 - 事前に手動で、アカウントに紐づく API Key を追加して、鍵とフィンガープリントを取得する

### oci コマンド 用
 - VM 起動時に 起動スクリプト ( cloud-init ) で oci コマンド実行する必要がある
 - 鍵を不要にしたいので、identity_dynamic_group を使う
   ( おそらく、Azure の Workload Identity, AWS の sts:AssumeRole みたいなやつぽい )
 - identity_dynamic_group を設定しとけば、VM 上で、oci コマンドが実行可能になる
 - terraform で設定する

### ユーザ 用
 - 手動でキーペアを作成
 - terraform で、公開鍵を VM に設定
 - 秘密鍵を使って、VM にログイン

# 他社クラウドとの違い

 - OCI 用語
   - コンパートメント
      - Azure での リソースグループ のようだ
      - AWS には、その概念はない
 - パブリック IP について
   - パブリック IP を指す URL を、OCI は DNS に公開してくれてない
   - しかし、DNS は有料
   - VM の IP を固定したいので、「予約済パブリック IP」を作成する
   - (AWS と比較して)「予約済パブリック IP」を VM にアタッチすることが、信じられないくらい、面倒臭い
     上記のための手順とスクリプトを ORACLE が公開しており、それを参考にする
   - パブリック IP リソース側で ローカル IP と紐付ける必要がある、マジで意味不明
     ( ローカル IP は VM 起動後じゃないとわからない )

# 実装


provider.tf
```tf
terraform {
  required_providers {
    oci = {
      source = "oracle/oci"
    }
  }
}

provider "oci" {
  region           = var.region
  tenancy_ocid     = var.tenancy_ocid
  user_ocid        = var.user_ocid
  private_key_path = var.private_key_path
  fingerprint      = var.fingerprint
}
```

variables.tf
```tf
variable "region" {
  description = "OCIのリージョン"
  default     = "ap-tokyo-1"
}

variable "tenancy_ocid" {
  description = "OCIのテナンシー OCID"
  type        = string
  default     = " ocid1.tenancy.oc1.. をここに記載 "
}

variable "user_ocid" {
  description = "OCIのユーザー OCID"
  type        = string
  default     = " ocid1.user.oc1.. をここに記載 "
}

variable "fingerprint" {
  description = "APIキーのフィンガープリント"
  type        = string
  default     = " xx:xx:xx:xx:... 的な文字列をここに記載 "
}

variable "private_key_path" {
  description = "terraform 用 秘密鍵 のパス"
  type        = string
  default     = " .pem のパスをここに記載 "
}

variable "ssh_authorized_keys" {
  description = "VM ログイン 用 公開鍵 のパス"
  type        = string
  default     = " .pub のパスをここに記載 "
}
```

main.tf
```tf
locals {
  # 表示名 ポストフィックス
  display_name = "202503"

  # VCN とサブネットの CIDR ブロック
  cidr_block-vcn    = "10.0.0.0/16"
  cidr_block-subnet = "10.0.0.0/24"

  memory_in_gbs           = 24
  ocpus                   = 4
  boot_volume_size_in_gbs = 200
}

locals {
  # VMイメージのOCID
  # 24.04 (OCI CLI インストール スクリプトが未対応)
  # image_ocid = "ocid1.image.oc1.ap-tokyo-1.aaaaaaaax3arbzsjtoqovmp56hfba3q2qyxljbci6ejkuagmkjql6ej3mj6q"
  # 22.04
  image_ocid = "ocid1.image.oc1.ap-tokyo-1.aaaaaaaadcb75lsmgfzxtrirj6l7rb7ecd4ivz7fwzi2py2kk2molifncsia"
}

resource "oci_identity_compartment" "main" {
  compartment_id = var.tenancy_ocid
  name           = "compartment-${local.display_name}"
  description    = "Compartment for development resources"
}

data "oci_identity_availability_domains" "ads" {
  compartment_id = oci_identity_compartment.main.id
}

# VM に権限を付与するための動的グループを作成
resource "oci_identity_dynamic_group" "main" {
  compartment_id = var.tenancy_ocid
  name           = "dynamic_group-${local.display_name}"
  description    = "Dynamic Group for instance principal auth"
  matching_rule  = "ALL {instance.compartment.id = '${oci_identity_compartment.main.id}'}"
}

resource "oci_identity_policy" "main" {
  compartment_id = var.tenancy_ocid
  name           = "policy-${local.display_name}"
  description    = "Allow dynamic group to read VCN resources"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.main.name} to manage instance-family in tenancy",
    "Allow dynamic-group ${oci_identity_dynamic_group.main.name} to manage virtual-network-family in tenancy",
    "Allow dynamic-group ${oci_identity_dynamic_group.main.name} to manage public-ips in tenancy",
    "Allow dynamic-group ${oci_identity_dynamic_group.main.name} to use subnets in tenancy",
    "Allow dynamic-group ${oci_identity_dynamic_group.main.name} to use vnics in tenancy"
  ]
}

resource "oci_core_vcn" "main" {
  compartment_id = oci_identity_compartment.main.id
  cidr_block     = local.cidr_block-vcn
  display_name   = "vcn-${local.display_name}"
}

resource "oci_core_subnet" "main" {
  compartment_id = oci_identity_compartment.main.id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "subnet-${local.display_name}"

  availability_domain = null
  cidr_block          = local.cidr_block-subnet

  route_table_id    = oci_core_route_table.main.id
  security_list_ids = [oci_core_security_list.main.id]
}

resource "oci_core_route_table" "main" {
  compartment_id = oci_identity_compartment.main.id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "route_table-${local.display_name}"

  route_rules {
    description       = null
    destination       = "0.0.0.0/0"
    destination_type  = "CIDR_BLOCK"
    network_entity_id = oci_core_internet_gateway.main.id
    route_type        = null
  }
}

resource "oci_core_internet_gateway" "main" {
  compartment_id = oci_identity_compartment.main.id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "internet_gateway-${local.display_name}"
}

resource "oci_core_security_list" "main" {
  compartment_id = oci_identity_compartment.main.id
  vcn_id         = oci_core_vcn.main.id
  display_name   = "security_list-${local.display_name}"

  egress_security_rules {
    description = null
    destination = "0.0.0.0/0"
    protocol    = "all"
    stateless   = false
  }
  ingress_security_rules {
    description = null
    protocol    = "1" # ICMP
    source      = local.cidr_block-vcn
    stateless   = false
    icmp_options {
      code = -1
      type = 3
    }
  }
  ingress_security_rules {
    description = null
    protocol    = "1" # ICMP
    source      = "0.0.0.0/0"
    stateless   = false
    icmp_options {
      code = 4
      type = 3
    }
  }
  ingress_security_rules {
    description = null
    protocol    = "6" # TCP
    source      = "0.0.0.0/0"
    stateless   = false
    tcp_options {
      max = 22
      min = 22
    }
  }
}

resource "oci_core_public_ip" "main" {
  compartment_id = oci_identity_compartment.main.id
  lifetime       = "RESERVED"
  display_name   = "public_ip-${local.display_name}"

  # cloud-init で設定する
  private_ip_id = ""
  lifecycle {
    ignore_changes = [private_ip_id]
  }

  # 予約済みのパブリックIPを VM にアタッチするのは OCI では相当面倒臭い
  # https://docs.oracle.com/ja/learn/oci-attach-reserved-ip/index.html#task-1-set-up-a-terraform-script
}

resource "oci_core_instance" "main" {
  compartment_id      = oci_identity_compartment.main.id
  availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
  display_name        = "instance-${local.display_name}"
  shape               = "VM.Standard.A1.Flex"

  shape_config {
    memory_in_gbs = local.memory_in_gbs
    ocpus         = local.ocpus
  }

  create_vnic_details {
    subnet_id        = oci_core_subnet.main.id
    assign_public_ip = true
  }

  source_details {
    source_type             = "image"
    source_id               = local.image_ocid
    boot_volume_size_in_gbs = local.boot_volume_size_in_gbs
  }

  metadata = {
    ssh_authorized_keys = file(var.ssh_authorized_keys) # SSH公開鍵を指定
    user_data           = base64encode(file("./cloud-init.sh"))
  }

  # We will pass Reserved Public IP OCID and Private Subnet OCID as a freeform tags to the instance
  freeform_tags = {
    publicIP = oci_core_public_ip.main.id
    SubnetId = oci_core_subnet.main.id
  }

  preserve_boot_volume = false
}
```

cloud-init.sh ( ORACLE が公開しているスクリプト(https://docs.oracle.com/ja/learn/oci-attach-reserved-ip/index.html) ) バグ修正済み
 - VM 新規作成時に、予約済み IP と エフェメラル IP を差し替える
```sh
#!/bin/bash
# coding: utf-8
# https://docs.oracle.com/ja/learn/oci-attach-reserved-ip/index.html#introduction
export PATH=$PATH:/root/bin
export OCI_CLI_AUTH=instance_principal

apt install -y jq net-tools
curl -L https://raw.githubusercontent.com/oracle/oci-cli/master/scripts/install/install.sh | bash -s -- --accept-all-defaults

function getdetails() {
  # Fetch data using instance principal authentication
    metadata=$(curl -H 'Authorization: Bearer Oracle' http://169.254.169.254/opc/v2/instance)
    instanceid=$(echo $metadata | jq -r '.id')
    compartmentid=$(echo $metadata | jq -r '.compartmentId')
    subnetid=$(echo $metadata | jq -r '.freeformTags.SubnetId')
    publicIp=$(echo $metadata | jq -r '.freeformTags.publicIP')
    primaryvnicid=$(curl -H 'Authorization: Bearer Oracle' http://169.254.169.254/opc/v1/vnics/  | jq -r '.[0].vnicId')
    privateIpId=$(oci network private-ip list --vnic-id $primaryvnicid  | jq -r '.data[].id')
    ephpublicIp=$(oci network vnic get --vnic-id $primaryvnicid | jq -r '.data."public-ip"')
    ephpublicIpId=$( oci network public-ip get --public-ip-address $ephpublicIp | jq -r '.data.id')
    if [[ -z "$subnetid" || "$subnetid" == "" || ${#subnetid} == 0 || -z "$publicIp" || ${#publicIp} == 0 ||  -z "$instanceid" || -z "$compartmentid" || -z "$privateIpId" || -z "$primaryvnicid" || -z "$ephpublicIp" || -z "$ephpublicIpId" ]];
    then
        echo "Missing some details, retrying..."
        return 1
    fi
    return 0
}
# Retry the `getdetails` function thrice
attachVNIC()
{
    attachvnic=$(oci compute instance attach-vnic --instance-id $instanceid --subnet-id $subnetid --wait)
    while [ "$(curl -s http://169.254.169.254/opc/v1/vnics/ | jq 'length')" -lt 2 ]; do sleep 5; done
}
for i in {1..3}; do
  getdetails
  if [[ $? -eq 0 ]]; then
    attachVNIC
    if [[ -n "$attachvnic" ]]; then
        secondaryvnicid=$(echo $attachvnic | jq -r '.data.id')
        echo $secondaryvnicid
        check2VNICs=$(oci compute instance list-vnics --instance-id $instanceid | jq -r '.data[].id')
        echo $check2VNICs
        gatewayIP=$(curl http://169.254.169.254/opc/v1/vnics/ | jq -c '.[] | ( select(.vnicId=="'$secondaryvnicid'" ))' | jq -r '.virtualRouterIp')
        if [[ (-n "$gatewayIP") ]]; then
            echo $gatewayIP
            # wget https://docs.oracle.com/en-us/iaas/Content/Resources/Assets/secondary_vnic_all_configure.sh
            wget https://raw.githubusercontent.com/oracle/terraform-examples/refs/heads/master/examples/oci/connect_vcns_using_multiple_vnics/scripts/secondary_vnic_all_configure.sh
            sudo chmod u+x ./secondary_vnic_all_configure.sh
            sudo ./secondary_vnic_all_configure.sh -c
            iname=$(sudo ./secondary_vnic_all_configure.sh | awk '{print $8}' | awk 'END{print}')
            echo $iname
            sudo route add default gw $gatewayIP dev $iname
            route
            oci os ns get
            delpubIp=$(oci network public-ip delete  --force --public-ip-id $ephpublicIpId --wait-for-state TERMINATED)
            checkIp=$(oci network vnic get --vnic-id $primaryvnicid | jq -r '.data."public-ip"')
            if [[ -z "$checkIp" || "$checkIp" == "null" ]]; then
                echo "Assigning Reserved IP"
                assign=$(oci network public-ip update --force --public-ip-id $publicIp --private-ip-id $privateIpId  --wait-for-state ASSIGNED)
                detach=$(oci compute instance detach-vnic --compartment-id  $compartmentid --vnic-id $secondaryvnicid --force)
                sudo ./secondary_vnic_all_configure.sh -d
                checkIp2=$(oci network vnic get --vnic-id $primaryvnicid | jq -r '.data."public-ip"')
                echo "Reserved IP $checkIp2"
                if [[ -z "$checkIp2" ]]; then
                    oci compute instance update --instance-id $instanceid --freeform-tags '{"errorStatus":"Reserved IP Not Assigned","SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
                    exit;
                fi
            else
                oci compute instance update --instance-id $instanceid --freeform-tags '{"errorStatus":"Ephemeral IP Not Deleted","SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
                exit;
            fi
            break;
        else
            oci compute instance update --instance-id $instanceid --freeform-tags '{"errorStatus":"Gateway IP not fetched","SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
            exit;
        fi
    else
        break;
    fi
  fi
done
if [[ -z "$attachvnic" ]];
then
    echo "Failed"
    oci compute instance update --instance-id $instanceid --freeform-tags '{"errorStatus":"Secondary VNIC not attached","SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
    exit
fi

checkVNICs2=$(oci compute instance list-vnics --instance-id $instanceid | jq -r '.data | length')

if [[ "$checkVNICs2" > 1 ]];
then
    echo "Detaching Secondary VNIC Failed"
    oci compute instance update --instance-id $instanceid --freeform-tags '{"errorStatus":"Detaching Secondary VNIC Failed","SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
    exit
fi
oci compute instance update --instance-id $instanceid --freeform-tags '{"SubnetId":"'$subnetid'","publicIP":"'$publicIp'"}' --force
```


## VM ログイン
以下で、つながるはず
```bash
ssh ubuntu@<public_ip> -i "VM ログイン時の SSH 公開鍵"
```

# 終わりに
 - アウトバウンドの IP を固定したかったので「予約済みパブリック IP」を使用した
 - インバウンドだけの用途で、DNS も持ってるなら、「エフェメラル (自動採番)」の IP を DNS に登録すればいいです