コンテンツにスキップ

コンピュートとロード・バランサ

ひとことで

VM はいつでも入れ替えられる部品として扱います:イメージは固定しない、設定は Ansible に任せる(またはイメージに焼き込む)、前段にロード・バランサ(LB)を置く。こうすれば 1 台が再起動・入れ替えになってもサービスは止まりません。 もう 1 つ覚えておくこと:Terraform の plan が教えてくれるのは「OCID が変わるかどうか」だけで、「再起動するかどうか」は教えてくれません。

このページは Phase 2、Phase 3、Phase 4、Phase 5a、Phase 9 の実験をまとめたものです。ネットワークとログイン方法(要塞)は ネットワーク設計 を参照してください。

1. まず構成要素を知る

OCI AWS ひとことで
シェイプ(Shape) Instance Type VM の規格。名前に .Flex が付くものは OCPU 数とメモリを自分で指定できる
OCPU (vCPU 約 2 個) OCI の CPU 単位 = 物理コア 1 つ。x86 では 1 OCPU = 2 vCPU、Arm(A1)はハイパースレッディングがないので 1 OCPU = 1 vCPU
イメージ(Image) AMI OS イメージ。Oracle Linux、Ubuntu など。シェイプ(x86 / Arm)ごとに分かれる
ブート・ボリューム(Boot Volume) ルート EBS ボリューム システム・ディスク。最小 50GB
可用性ドメイン(AD) Availability Zone リージョン内の独立したデータセンター。大阪は AD が 1 つだけ
フォルト・ドメイン(FD) (近い)Placement Group の分散 AD 内のラックのグループで、AD ごとに 3 つ。別の FD に分ければ、同じ物理マシンの故障で同時に止まらない
Oracle Cloud エージェント(Oracle Cloud Agent) SSM Agent VM 内のエージェント。監視や要塞接続などを担当(プラグインごとにオン / オフ)
インスタンス・メタデータ・サービス(IMDS) 同左 VM の内部から 169.254.169.254 にアクセスして自分の情報(OCID、SSH 公開鍵、cloud-init のデータ…)を得る
フレキシブル・ロード・バランサ ALB / NLB 負荷分散装置。帯域を指定できる(10Mbps は無料)
リスナー / バックエンド・セット / バックエンド Listener / Target Group / Target LB の 3 層構造。5 節を参照

2. 原理:VM の属性は 3 種類に分かれる

Terraform で VM の属性を変えたときの結果は 3 通りあります。plan の記号で区別できるのはそのうち 2 通りだけです:

種類 plan の記号 例(このリポジトリで実際に遭遇したもの)
変更すると再作成(OCID が変わる) -/+(# forces replacement) イメージ(source_id)、cloud-init(user_data)
インプレース変更だが、VM が再起動する ~ 転送中の暗号化(is_pv_encryption_in_transit_enabled、ドキュメントに再起動すると明記)
インプレース変更で、稼働に影響しない ~ タグ(freeform_tags)、表示名

インプレース(~)≠ 無停止

Phase 5a で 2 台の app VM の IMDS と転送中の暗号化を同時に変更しました(1 回の apply で 2 項目を変えたので、どちらが再起動の原因かは区別できません)。plan は 0 to add, 2 to change(インプレース)でした。 apply 後、2 台の VM が同時に再起動し、LB のヘルス・チェックがすべて失敗して 502 Bad Gateway が返り、数十秒後にようやく復旧しました。 → 再起動するかどうかはプロバイダのドキュメントで確認するか、テスト環境で先に試します。本番環境では 1 台ずつ変更します(-target で 2 回に分けて apply する、または先に LB から外す)。

3. 初期化と設定:だれが何を担当するか

VM が「作成された」状態から「サービスを提供できる」状態になるまでを、ツールごとに分担できます:

Terraform cloud-init Ansible
担当 VM やネットワークなどクラウド上のリソース 初回起動時の最小限の初期化(タイムゾーン、ユーザー) OS 内のミドルウェアのインストール、設定、その後の変更
実行タイミング apply 時 初回起動時に 1 回だけ いつでも何度でも
変更した後 — 稼働中の 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 + このシェイプで使えるもの)
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 はリージョンごとに違い、古いイメージは提供終了になります
  • ignore_changes:この行がないと、Oracle が新しいイメージを公開するたびに、次の plan ですべての VM の再作成を求められます
  • 結果:新しく作る VM は最新イメージ、既存の VM はそのまま。イメージを替えたいときは、その VM に対して意識的に terraform apply -replace=... を実行します

5. ロード・バランサ:3 層構造

flowchart TB
  C["クライアント"] -->|":80"| L["リスナー<br/>外向きの<br/>ポートとプロトコル"]
  L --> BS["バックエンド・セット<br/>ROUND_ROBIN<br/>ヘルス・チェック<br/>GET / = 200"]
  BS --> B1["バックエンド<br/>app-1 :80"]
  BS --> B2["バックエンド<br/>app-2 :80"]
  • ヘルス・チェック:LB は 10 秒ごとに各 VM の GET / にアクセスし、200 を返さない VM にはトラフィックを振り分けなくなります。すべてが異常なら 502 を返します(2 節のケース)
  • LB はパブリック・サブネット、VM はプライベート・サブネット:VM にパブリック IP は不要です(ネットワーク設計 を参照)
  • バックエンドの 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 のキー(app-1 / app-2)は plan 時点で確定し、値(IP)は apply 後に分かる値でも構いません

  • Always Free の条件をコードに書く:帯域が 10Mbps 以外だと課金されるので、validation で固定します

    variable "bandwidth_mbps" {
      type    = number
      default = 10
      validation {
        condition     = var.bandwidth_mbps == 10
        error_message = "Always Free のフレキシブル LB は 10Mbps のみ。"
      }
    }
    

ここの LB は L7(HTTP)、OKE のものは L4(TCP)

このページのリスナーのプロトコルは HTTP です:LB は HTTP を理解し、ヘルス・チェックも HTTP で判定します。 Kubernetes の Service type=LoadBalancer で作られる同じ種類の LB は、リスナーが TCP(接続を転送するだけ)です。違いは Kubernetes のページを参照してください。

6. VM 自体のセキュリティ設定

セキュリティ設計の詳細は「セキュリティ」のページにあります。ここでは VM に直接関係する 3 つだけを挙げます:

設定 やり方 理由
パブリック IP を付けない assign_public_ip = false、プライベート・サブネットに置く パブリック IP + 開いたポート = 攻撃対象
IMDS v2 だけを有効にする are_legacy_imds_endpoints_disabled = true v1 はトークンなしでメタデータを読める。アプリに SSRF の脆弱性があると、攻撃者がそれを使って VM の情報を読める(AWS の IMDSv2 と同じ問題)
ブート・ボリュームの転送中の暗号化 is_pv_encryption_in_transit_enabled = true ストレージ自体はデフォルトで暗号化済み。これは VM ↔ ブート・ボリューム間の転送も暗号化する設定

転送中の暗号化:VM の作成時に指定したのに、実際には効いていなかった

Phase 9 ではコードに true と書き、apply も成功しましたが、再度 plan すると差分 false -> true が出ました。API で確認すると、VM とブート・ボリューム・アタッチメントの実際の値はどちらも false でした。 ドキュメントによると作成後でも有効化できる(再起動を伴う)→ 2 回目の apply(インプレース更新)でようやく true になり、再 plan は No changes になりました。 apply の成功は設定が効いたことを意味しません:apply 後は必ず再度 plan し、API で実際の状態を確認します。

7. Always Free のコンピュート・リソース

リソース 無料枠 このリポジトリでの使い方
A1(Arm)Flex 合計 4 OCPU / 24GB 1 台あたり 1 OCPU / 6GB、最大 4 台
E2.1.Micro(x86) 最大 2 台(1/8 OCPU、1GB) 使っていない(小さすぎる)
ブロック・ボリューム ブート・ボリュームも含めて合計 200GB ブート・ボリュームは最小 50GB → 実質最大 4 台の VM
フレキシブル LB 1 つ、10Mbps Phase 4 で使用
  • 作成できるのはホーム・リージョンだけです(このリポジトリでは大阪)
  • A1 はよく容量不足になります:Phase 11 で OKE のノードを作るときに Out of host capacity が出ました。1 回目もすぐの再試行も失敗し、その後 10 分ごとに再試行して、3 回目(約 25 分後)にようやく成功しました。無料の Arm リソースは取り合いなので、「少し待って再試行する」心づもりが必要です
  • 複数の Phase を同時に動かすと無料枠を超えます → 次の Phase で枠を使う前に、前の Phase を destroy します

8. 設計判断の一覧

判断 採用 不採用 理由
シェイプ A1.Flex 1 OCPU / 6GB E2.1.Micro 無料枠の中で性能が最もよい。Micro はメモリが 1GB しかない
イメージ データ・ソースで最新を検索 + ignore_changes OCID を固定 / 常に最新に追従 新しい VM は新しいイメージ、既存の VM は意図せず再作成されない
初期化 cloud-init は最小限の初期化だけ cloud-init ですべてを入れる user_data を変えると VM が再作成される。既存の VM には反映できない
構成管理 Ansible(冪等) 手作業で SSH して変更 繰り返せる、事前確認できる(--check --diff)、コードに残る
台数 module + for_each、1 行足せば 1 台増える resource をコピー&ペースト 台数の変更は 1 か所だけ
入口 LB(HTTP、ヘルス・チェック、ROUND_ROBIN) VM に直接アクセス 1 台が故障 / 再起動してももう 1 台がある
無料の条件 validation、固定値 記憶だけに頼る plan の段階でエラーになり、課金リソースが作られない

9. 検証

証明したいこと やり方 結果
LB が本当に振り分けている 6 回続けてアクセス app-1 / app-2 が交互に返る → ROUND_ROBIN が有効
Ansible が冪等 playbook を 2 回実行 2 回目は changed=0
nginx の設定が効いている レスポンス・ヘッダーを見る Server: nginx にバージョン番号がない(server_tokens off)
セキュリティ設定が本当に効いている 再 plan + API で VM とブート・ボリューム・アタッチメントの実際の値を確認 1 回目の apply 後は再 plan で差分があり、API の値は false。2 回目の apply 後にようやく true

10. ハマりどころ

現象 原因 対策
インプレース(~)の apply 後に LB が 502 を返す 変更した属性で VM が再起動し、2 台が同時に再起動した 1 台ずつ変更する
cloud-init を変えたら VM が再作成された user_data は # forces replacement 設定は Ansible に任せる
新しいイメージの公開後、plan が VM の再作成を求める データ・ソースが新しいイメージを見つけた 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 の変更はキューで順番に実行され、1 つあたり十数秒〜30 秒 正常な動作

11. 確認問題

1. plan が ~(インプレース)を表示している。日中に安心して apply できる?

できるとは限りません。~ は OCID が変わらないことを示すだけで、変えると VM が再起動する属性もあります(例:転送中の暗号化)。ドキュメントを確認するかテスト環境で先に試し、複数台なら 1 台ずつ変更します。

2. 稼働中の VM にソフトウェアを 1 つ追加したい。cloud-init を変えればいい?

よくありません。cloud-init は初回起動時にしか実行されないので既存の VM には反映されず、しかも user_data を変えると Terraform が VM を再作成します。Ansible を使うべきです。

3. イメージをデータ・ソースで探しつつ、ignore_changes も付けるのはなぜ?

データ・ソースを使うのは OCID を固定せず、新しい VM で最新イメージを使うためです。ignore_changes は、新しいイメージが公開されたときに稼働中の VM が再作成されないようにするためです。イメージの入れ替えは意識して行います。

4. LB が 502 を返す。最も可能性が高い原因は?

すべてのバックエンドのヘルス・チェックが失敗しています:VM が停止 / 再起動中、nginx が起動していない、OS のファイアウォールでポートが開いていない、NSG が LB からの通信を許可していない。

5. Always Free で作れる VM は最大何台? ボトルネックは?

A1 は合計 4 OCPU / 24GB、ブート・ボリュームは合計 200GB で 1 台あたり最小 50GB → 最大 4 台(1 台 1 OCPU)。ボトルネックになりやすいのはブロック・ボリュームです。

6. IMDS v1 にはどんなリスクがある?

トークンなしでメタデータを読めます。アプリに SSRF の脆弱性があると、攻撃者がサーバーに 169.254.169.254 へのリクエストを代行させ、VM の情報を読めてしまいます。v2(トークン・ヘッダーが必要)だけを有効にすれば、この種の攻撃を防げます。

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 で 1 つの NSG を共用し、ルールは 1 回だけ書く)
#   - Oracle Cloud エージェントの要塞プラグインを有効にできる(要塞のマネージド SSH セッションに必要)
# ------------------------------------------------------------

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 # 最小値。ブロック・ボリュームは合計 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
  }

  # ブート・ボリュームと VM の間のデータ転送も暗号化(ストレージ自体はデフォルトで暗号化済み)(checkov CKV_OCI_4)
  #   トップレベルにも同名の属性があるが、checkov が見るのは launch_options 内のこれだけ
  launch_options {
    is_pv_encryption_in_transit_enabled = true
  }

  # 旧メタデータ・サービス(IMDS v1)を無効化:v1 はトークンなしでメタデータを読めるため、SSRF 攻撃に悪用されやすい(checkov CKV_OCI_5)
  #   v2(/opc/v2/、リクエストに Authorization: Bearer Oracle ヘッダーが必要)だけを残す。Oracle Cloud エージェント / cloud-init はどちらも v2 に対応
  instance_options {
    are_legacy_imds_endpoints_disabled = true
  }

  # Oracle Cloud エージェントのプラグイン設定:変更したいプラグインだけを書く。書かないものはデフォルトのまま
  agent_config {
    plugins_config {
      name          = "Bastion"
      desired_state = var.enable_bastion_plugin ? "ENABLED" : "DISABLED"
    }
  }

  # user_data が null のときはこのキーを書かない(空の map を merge)
  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

  # 新しいイメージが公開されるとデータ・ソースが新しい OCID を返す → この行がないと plan が VM の再作成を求める
  lifecycle {
    ignore_changes = [source_details[0].source_id]
  }
}
04-3tier/modules/lb/main.tf(LB)
# ------------------------------------------------------------
# lb module:フレキシブル・ロード・バランサ一式(HTTP)
#   LB ─ リスナー(:80)─ バックエンド・セット(ROUND_ROBIN + ヘルス・チェック)─ バックエンド ×N
#   Always Free:フレキシブル 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
}

# バックエンド・セット = バックエンド 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
  }
}

# バックエンド = グループ内の各 VM(プライベート IP + ポートで登録)
#   for_each のキー(app-1 など)は plan 時点で確定している必要がある。値(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
}

# リスナー = LB が外部からの接続を受け付けるポート。受けたリクエストをどのバックエンド・セットに渡すか
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)になるはず → これが冪等性(idempotency)
# ------------------------------------------------------------
- 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"

    # 設定ファイルが変わったときだけ nginx を reload(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