コンテンツにスキップ

Phase 7:サイトの公開 + CI/CD(OCI DevOps)

目標

  • この学習ノートのサイト(MkDocs)をオブジェクト・ストレージ(Object Storage)で公開する
  • push したら自動でチェック → ビルド → 公開する(CI/CD)
  • API キーを発行しない:ビルドはリソース・プリンシパル(Resource Principal)(サービス自身の ID)を使う

方針

決定 理由
GitHub Pages を使わない 無料プランではリポジトリを public にする必要がある → リポジトリ全体と Git の履歴は公開したくない。サイトに埋め込んだコードが見えるのは許容
オブジェクト・ストレージで公開 Always Free の範囲内で、Terraform の練習も続けられる
CI/CD は OCI DevOps 当初はプライベートな GitHub リポジトリ + GitHub Actions の予定だったが、利用中のネットワークでブラウザから GitHub がブロックされていた。コマンドラインからはつながるが、回避はしない(ブロックされているのは、ネットワークの管理者が許可していない可能性があるため)
Terraform が管理するのはバケットだけ サイトのファイルはビルドのたびに変わるため、Terraform に入れると plan に毎回差分が出る → コンテンツはスクリプトでアップロードし、state には入れない

構成

flowchart LR
  pc["ローカル PC<br/>git push"] -->|HTTPS + 認証トークン| repo["コード・リポジトリ<br/>(プライベート)"]
  repo -->|"トリガー<br/>PUSH main"| bp["ビルド・パイプライン<br/>(マネージド・ビルド)"]
  bp -->|"リソース・プリンシパル"| bucket["バケット tflab-site<br/>(公開)"]
  bucket --> user["ブラウザ"]
07-site/
├── main.tf        バケット(ObjectReadWithoutList)
├── devops.tf      DevOps 一式 + 動的グループ / ポリシー
└── outputs.tf     サイトの URL、リポジトリの URL
build_spec.yaml    ビルドの手順(リポジトリ直下)
tools/upload_site.py  site/ → バケットへの同期(Python SDK)

ビルドの手順(build_spec.yaml):

ステップ 内容 失敗したとき
Setup Python venv + pip install -r requirements.txt oci
Terraform checks fmt -check -recursive → 各ルート・モジュールで validate → tflint 公開しない
Check ja code copies check_ja_code.py(日本語版のコードのコピーが元ファイルと一致しているか) 公開しない
Build site mkdocs build --strict(警告があれば失敗) 公開しない
Upload upload_site.py --auth resource_principal

オブジェクト・ストレージを静的サイトとして使う

制約 対策
ディレクトリにアクセスしても index.html を返さない mkdocs.yml で use_directory_urls: false → phases/06-oke/ が phases/06-oke.html になる
Content-Type はアップロード時に指定する(指定しないと HTML がダウンロード扱いになる) アップロード・スクリプトで拡張子ごとに固定(Windows の mimetypes はレジストリを読むため、マシンによって結果が変わる)
404 はオブジェクト・ストレージの JSON で、MkDocs の 404.html は表示されない 対策なし
URL に必ずネームスペース(namespace)が含まれる サイトを公開する以上は隠せない。state のバケットは NoPublicAccess なので、ネームスペースがわかっても読めない
  • access_type = "ObjectReadWithoutList":匿名でファイルは読めるが、一覧はできない(ObjectRead だとファイル名の一覧まで公開される)
  • アップロード・スクリプト tools/upload_site.py:
    • MD5 で変更されたファイルだけを送る(オブジェクト・ストレージが返す md5 は base64)→ API リクエストを節約(Always Free は月 5 万回)
    • site/ にないオブジェクトは削除する(ページ名を変えても古いページが残らない)
    • Cache-Control:HTML は no-cache(公開後すぐに反映)、それ以外は 1 時間キャッシュ
  • curl で匿名アクセスして確認:ページは 200 text/html; charset=utf-8、一覧は 404

権限:リソース・プリンシパル

動的グループ:ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '<tf-lab>'}

Allow dynamic-group tflab-site-build to manage objects in compartment tf-lab where target.bucket.name = 'tflab-site'
Allow dynamic-group tflab-site-build to read devops-repository in compartment tf-lab
Allow dynamic-group tflab-site-build to use ons-topics in compartment tf-lab
  • 「tf-lab 内のビルド・パイプライン」自体に権限がある → 保管すべき鍵がない(GitHub Actions なら API キーを Secrets に入れる必要がある)
  • できるのは tflab-site 内のオブジェクトの読み書き・削除、コード・リポジトリの読み取り、ビルド通知の送信だけです。state のバケット、バケット自体、ほかのリソースには触れません
  • スクリプトでは oci.auth.signers.get_resource_principals_signer() を使います(ローカル PC では ~/.oci/config。--auth で切り替え)

git push の認証

利用中のネットワークでポート 22(SSH)がブロックされている → HTTPS しか使えません。

項目 内容
ユーザー名 <テナンシ名>/<ユーザー名>(Default ドメインならドメイン名は不要)
パスワード 認証トークン(Auth Token)(コンソールのパスワードではない)
保存 初回の push のあと、Git Credential Manager が Windows の資格情報マネージャーに保存する

認証トークンは Terraform で作らない

oci_identity_auth_token でも作成できますが、トークンの値がそのまま state に入ります。API キーと同じく、パスワード類は Terraform を通さず、コンソールで手動作成します(マイ・プロファイル → トークンおよびキー)。 1 ユーザーにつき最大 2 つで、値は作成時に 1 回だけ表示されます。

手順と観察

# 起動 結果 内容
1 push FAILED(20 秒) build_spec.yaml を読めない(ポリシーは read repos)
2 手動 FAILED use repos → 同じ
3 手動 FAILED(ランナー 2 分) 一時的に manage devops-family → build_spec は読めた。pip が PyPI につながらない
4 push FAILED 診断出力を追加 → 原因はランナー・イメージの pip の設定
5 push SUCCEEDED(約 2.5 分) PIP_CONFIG_FILE=/dev/null → アップロード成功
6 手動 FAILED manage repos → 同じ → リソース・タイプが間違っていた
7 手動 SUCCEEDED read devops-repository
8 push SUCCEEDED 最終的なポリシーで push → 自動ビルド
9 push SUCCEEDED(約 4 分) Terraform のチェックを追加(fmt / 7 つのルート・モジュールの validate / 12 ディレクトリの tflint)
  • ランナーが起動する前に失敗したもの(1、2、6)は課金されません
  • 2 回目以降の MD5 同期:コンテンツが変わっていなければ uploaded 0 / unchanged 72
  • ビルドのログは ログ(Log)(service = devops)がないとコンソールやログ検索(Logging Search)で見られません。ビルド終了から約 1 分後に検索できるようになります
  • ランナーには Python 3.11、OCI CLI 3.58 があり、terraform / tflint はない(CI で公式サイトから固定バージョンをダウンロード)

ハマりどころ

repos は DevOps のコード・リポジトリではない

read repos / use repos / manage repos はどれも Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound になりました。 repos はコンテナ・レジストリ(OCIR)のリソース・タイプです。DevOps のコード・リポジトリは devops-repository です。

  • 調べ方:一時的に manage devops-family in compartment tf-lab を付与 → 成功 → 動的グループ / ポリシー自体は正しい → 問題はリソース・タイプ
  • エラー・メッセージの例は manage devops-family in tenancy ですが、範囲が広すぎるのでそのままは使いませんでした

ランナーで pip が PyPI につながらない:ProxyError ... Name or service not known

プロキシの環境変数はないのに、ランナー・イメージの pip のグローバル設定に Oracle 社内のプロキシが書かれており(www-proxy-hqdc.us.oracle.com)、ランナー内では名前解決できません。 → PIP_CONFIG_FILE=/dev/null(pip がどの設定ファイルも読み込まなくなる)。

  • 推測で直さず、まず診断出力(pip config list、getent hosts pypi.org など)を追加してから修正しました

最初の push が拒否される(non-fast-forward)

OCI がリポジトリ作成時に空の「Initial Commit」(ファイルなし)を自動で作るため、ローカルの履歴と共通の祖先がありません。 → git push -u --force oci main(その空のコミットだけを上書き)。

default_branch = 'main' だと plan のたびに差分が出る

API は refs/heads/main として保存します。コードにも refs/heads/main と書きます。

Git Credential Manager が固まる:auto-detection of host provider took too long

GCM が「GitHub なのか Azure なのか」を判定する処理がタイムアウトしています。 → git config credential.https://devops.scmservice.ap-osaka-1.oci.oraclecloud.com.provider generic

ローカル PC にはやはり OCI CLI をインストールできない(公式インストール・スクリプトでもだめ)

Phase 5b と同じく Windows のパス長の上限が原因です。アップロードは Python SDK のスクリプトに切り替えました。 教訓:始める前に自分のノートを見返すこと。

コード全体

07-site/main.tf
# ------------------------------------------------------------
# Phase 7-1:オブジェクト・ストレージ(Object Storage)で学習ノートのサイト(MkDocs の site/)を公開
#   Terraform が管理するのはバケットだけ(インフラ)
#   サイトのファイル(コンテンツ)はビルドのたびに変わる → tools/upload_site.py でアップロードし、state には入れない(7-2 で OCI DevOps に任せる)
# ------------------------------------------------------------

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

# オブジェクト・ストレージのネームスペース(URL で使う)
data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_objectstorage_bucket" "site" {
  #checkov:skip=CKV_OCI_10:Public static website; objects are readable but listing is disabled
  #checkov:skip=CKV_OCI_8:Content is rebuilt from Git on every deploy; old versions are not needed
  #checkov:skip=CKV_OCI_9:Public content; Oracle-managed key is sufficient (CMK needs Vault)
  #checkov:skip=CKV_OCI_7:No event consumers; enable when an event rule is added
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "${var.project_name}-site"

  # ObjectRead            :ファイルを読めるうえ、バケット内の全ファイル名も一覧できる
  # ObjectReadWithoutList :ファイルは読めるが、一覧はできない ← サイトには読み取りだけで十分
  # NoPublicAccess        :公開を停止(var.site_public = false。08-guard の Function もこれに変更する)
  access_type  = var.site_public ? "ObjectReadWithoutList" : "NoPublicAccess"
  storage_tier = "Standard"
  versioning   = "Disabled" # コンテンツはいつでも Git から再ビルドできるので、古いバージョンは不要

  freeform_tags = local.common_tags
}
07-site/devops.tf
# ------------------------------------------------------------
# Phase 7-2:OCI DevOps による自動公開(push → ビルド → アップロード)
#   ローカル PC で git push(HTTPS + 認証トークン)→ コード・リポジトリ(Code Repository)
#   → トリガー(main)→ ビルド・パイプライン(build_spec.yaml:チェック → mkdocs build → アップロード)
#   認証:リソース・プリンシパル(Resource Principal、ビルド・パイプライン自身の ID)→ API キーは発行しない
#   GitHub を使わない理由:利用中のネットワークでブラウザから GitHub がブロックされているため
# ------------------------------------------------------------

# tf-lab の親 = テナンシ(動的グループはテナンシ直下にしか作れない)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# DevOps プロジェクトには通知トピックの指定が必須(ビルドの成功 / 失敗などのイベントが届く。サブスクライブしなくてもよい)
resource "oci_ons_notification_topic" "devops" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site-devops"
  description    = "DevOps project events for ${var.project_name}-site"

  freeform_tags = local.common_tags
}

resource "oci_devops_project" "site" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site"
  description    = "Build and publish the MkDocs study site"

  notification_config {
    topic_id = oci_ons_notification_topic.devops.id
  }

  freeform_tags = local.common_tags
}

# ビルドのログ:ログを作らないとコンソールでビルドの出力が見られない
resource "oci_logging_log_group" "devops" {
  compartment_id = var.compartment_ocid
  display_name   = "${var.project_name}-site-devops"

  freeform_tags = local.common_tags
}

resource "oci_logging_log" "devops" {
  log_group_id       = oci_logging_log_group.devops.id
  display_name       = "${var.project_name}-site-devops"
  log_type           = "SERVICE"
  is_enabled         = true
  retention_duration = 30 # 日(最短 30)

  configuration {
    source {
      category    = "all"
      resource    = oci_devops_project.site.id
      service     = "devops"
      source_type = "OCISERVICE"
    }
  }

  freeform_tags = local.common_tags
}

# プライベートな Git リポジトリ(このリポジトリ全体をここに push する)
resource "oci_devops_repository" "site" {
  project_id      = oci_devops_project.site.id
  name            = "oci-terraform"
  description     = "OCI x Terraform study repo (code + MkDocs site)"
  repository_type = "HOSTED"
  default_branch  = "refs/heads/main" # "main" と書くと API が refs/heads/main として保存する → plan のたびに差分が出る

  freeform_tags = local.common_tags
}

resource "oci_devops_build_pipeline" "site" {
  project_id   = oci_devops_project.site.id
  display_name = "${var.project_name}-site-build"
  description  = "check -> mkdocs build -> upload to ${oci_objectstorage_bucket.site.name} and AWS S3"

  # Phase 10-4:build_spec.yaml の「Upload to AWS」で使う(ビルド時に環境変数として渡される)
  build_pipeline_parameters {
    items {
      name          = "AWS_SITE_BUCKET"
      default_value = var.aws_site_bucket
    }
    items {
      name          = "AWS_DISTRIBUTION_ID"
      default_value = var.aws_distribution_id
    }
    items {
      name          = "AWS_CREDS_SECRET_ID"
      default_value = oci_vault_secret.aws_deployer.id
    }
  }

  freeform_tags = local.common_tags
}

# マネージド・ビルド:OCI が用意するランナー(ビルド中の OCPU / メモリで課金)がリポジトリ直下の build_spec.yaml を実行する
resource "oci_devops_build_pipeline_stage" "build" {
  build_pipeline_id         = oci_devops_build_pipeline.site.id
  build_pipeline_stage_type = "BUILD"
  display_name              = "build-and-upload"
  image                     = "OL8_X86_64_STANDARD_10"
  build_spec_file           = "build_spec.yaml"
  primary_build_source      = "src"

  stage_execution_timeout_in_seconds = 900 # 固まったら 15 分で強制終了(課金が続くのを防ぐ)

  build_pipeline_stage_predecessor_collection {
    items {
      id = oci_devops_build_pipeline.site.id # 最初のステージの前段 = パイプライン自体
    }
  }

  build_source_collection {
    items {
      name            = "src"
      connection_type = "DEVOPS_CODE_REPOSITORY"
      repository_id   = oci_devops_repository.site.id
      repository_url  = oci_devops_repository.site.http_url
      branch          = "main"
    }
  }

  freeform_tags = local.common_tags
}

# main に push されたらビルドを開始(ほかのブランチへの push では起動しない)
resource "oci_devops_trigger" "push_main" {
  project_id     = oci_devops_project.site.id
  display_name   = "push-main"
  trigger_source = "DEVOPS_CODE_REPOSITORY"
  repository_id  = oci_devops_repository.site.id

  actions {
    type              = "TRIGGER_BUILD_PIPELINE"
    build_pipeline_id = oci_devops_build_pipeline.site.id

    filter {
      trigger_source = "DEVOPS_CODE_REPOSITORY"
      events         = ["PUSH"]

      include {
        head_ref = "main"
      }
    }
  }

  freeform_tags = local.common_tags
}

# ---------- 権限:リソース・プリンシパル ----------
# tf-lab 内のビルド・パイプラインはすべてこの動的グループのメンバーになる
resource "oci_identity_dynamic_group" "site_build" {
  compartment_id = data.oci_identity_compartment.lab.compartment_id
  name           = "${var.project_name}-site-build"
  description    = "DevOps build pipelines in ${data.oci_identity_compartment.lab.name}"
  matching_rule  = "ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '${var.compartment_ocid}'}"

  freeform_tags = local.common_tags
}

# tf-lab にアタッチ:tf-lab(とその配下)の権限しか付与できない
#   manage objects + where でバケット名を指定 → tflab-site 内のオブジェクトしか操作できない(tflab-tfstate には触れず、バケット自体も削除できない)
#   read devops-repository → ビルド時に build_spec.yaml を読み、コードを clone する
#     ハマりどころ:repos はコンテナ・レジストリ(OCIR)のリソース・タイプで、DevOps のコード・リポジトリではない
#         read / use / manage repos はどれも RelatedResourceNotAuthorizedOrNotFound(Unable to fetch build_spec file)
#   use ons-topics → プロジェクトのトピックにビルドのイベントを送る
#   read secret-bundles + where secret.id → AWS Access Key のシークレット 1 つの中身しか読めない(Phase 10-4)
resource "oci_identity_policy" "site_build" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site-build"
  description    = "Permissions for ${oci_identity_dynamic_group.site_build.name}"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to manage objects in compartment ${data.oci_identity_compartment.lab.name} where target.bucket.name = '${oci_objectstorage_bucket.site.name}'",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to read devops-repository in compartment ${data.oci_identity_compartment.lab.name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to use ons-topics in compartment ${data.oci_identity_compartment.lab.name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to read secret-bundles in compartment ${data.oci_identity_compartment.lab.name} where target.secret.id = '${oci_vault_secret.aws_deployer.id}'",
  ]

  freeform_tags = local.common_tags
}
build_spec.yaml
# OCI DevOps のビルド・パイプライン(07-site/devops.tf)が実行する内容
#   main に push される → ここのステップがマネージド・ビルドのランナー(OL8)で順番に実行される
#   ステップごとに別々のシェル → venv は activate に頼らず、絶対パスで呼び出す
version: 0.1
component: build
timeoutInSeconds: 900
shell: bash

env:
  variables:
    NO_MKDOCS_2_WARNING: "1" # Material のメンテナンス終了の通知を表示しない
    PIP_DISABLE_PIP_VERSION_CHECK: "1"
    # ランナー・イメージの pip のグローバル設定に Oracle 社内のプロキシ(www-proxy-hqdc.us.oracle.com、名前解決できない)が書かれている
    # → pip にどの設定ファイルも読ませず、PyPI に直接つなぐ(pip の仕様:PIP_CONFIG_FILE=os.devnull なら設定を読み込まない)
    PIP_CONFIG_FILE: "/dev/null"

steps:
  - type: Command
    name: Setup Python
    timeoutInSeconds: 600
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      # check_ja_code.py には 3.9+ が必要。OL8 標準の python3 は 3.6 の可能性がある → 見つからなければインストール
      PY=""
      for p in python3.12 python3.11 python3.9 python3; do
        if command -v "$p" >/dev/null && "$p" -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then
          PY="$p"; break
        fi
      done
      if [ -z "$PY" ]; then
        dnf install -y -q python3.11
        PY=python3.11
      fi
      "$PY" --version
      "$PY" -m venv /tmp/venv
      /tmp/venv/bin/pip install -r requirements.txt oci==2.189.0 boto3==1.35.49

  # CI:Terraform のチェック(通らなければサイトは公開しない)
  #   ランナーには terraform / tflint がない → 公式サイトから固定バージョンをダウンロード(ローカル PC と同じ)
  #   validate に認証は不要:init -backend=false(state のバケットにはつながない)。provider はダウンロードするだけで設定はしない
  - type: Command
    name: Terraform checks
    timeoutInSeconds: 600
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      TF_VERSION=1.16.6
      TFLINT_VERSION=0.64.0
      mkdir -p /tmp/bin
      curl -sSfL -o /tmp/terraform.zip "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip"
      curl -sSfL -o /tmp/tflint.zip "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip"
      # unzip があるとは限らない → Python の zipfile で展開(展開後は実行権限が消えるので chmod が必要)
      /tmp/venv/bin/python -m zipfile -e /tmp/terraform.zip /tmp/bin
      /tmp/venv/bin/python -m zipfile -e /tmp/tflint.zip /tmp/bin
      chmod +x /tmp/bin/terraform /tmp/bin/tflint
      export PATH="/tmp/bin:${PATH}"
      terraform version
      tflint --version

      terraform fmt -check -recursive -diff .

      # ルート・モジュール(05-resource-manager/stack は除外:モジュールは zip 作成時に入れるため)
      export TF_PLUGIN_CACHE_DIR=/tmp/tf-plugin-cache # oci provider のダウンロードは 1 回だけ
      mkdir -p "${TF_PLUGIN_CACHE_DIR}"
      for d in 01-network 02-compute 03-modules/bootstrap 03-modules/env 04-3tier/env 06-oke 07-site 08-guard 09-secops 10-aws-site/bootstrap 10-aws-site/site; do
        echo "== validate ${d}"
        terraform -chdir="${d}" init -backend=false -input=false -no-color >/dev/null
        terraform -chdir="${d}" validate -no-color
      done

      # tflint:ルート・モジュール + モジュール(i18n/ja のコピーは完全なモジュールではない → チェックしない)
      for d in 01-network 02-compute 03-modules/bootstrap 03-modules/env 03-modules/modules/compute 03-modules/modules/network \
               04-3tier/env 04-3tier/modules/compute 04-3tier/modules/lb 04-3tier/modules/network 06-oke 07-site 08-guard 09-secops 10-aws-site/bootstrap 10-aws-site/site; do
        echo "== tflint ${d}"
        tflint --chdir="${d}" --config="${PWD}/04-3tier/.tflint.hcl"
      done

  - type: Command
    name: Check ja code copies
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python tools/check_ja_code.py

  - type: Command
    name: Build site
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/mkdocs build --strict

  - type: Command
    name: Upload to Object Storage
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python tools/upload_site.py --auth resource_principal

  # Phase 10-4:AWS 版(S3 + CloudFront)にもアップロードする
  #   AWS_* は build pipeline のパラメータ(07-site/devops.tf)→ このファイルには書かない(サイトのページに埋め込まれるため)
  #   Access Key は OCI Vault の Secret にあり、スクリプトが Resource Principal で読む(環境変数にもログにも出ない)
  #   set -u:パラメータが渡らなかった場合はここでエラーにする(黙ってスキップしない)
  - type: Command
    name: Upload to AWS
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python tools/upload_site_s3.py \
        --bucket "${AWS_SITE_BUCKET}" \
        --distribution-id "${AWS_DISTRIBUTION_ID}" \
        --creds-secret-id "${AWS_CREDS_SECRET_ID}"