1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OCI CLI で Exadata VM クラスタのDBノードを起動・停止する ― oci db system list では引けなかった話

1
Last updated at Posted at 2026-07-07

※ この記事とスクリプトは個人の学習・検証目的で作成したものだ。本番環境での利用は自己責任とする。

OCI の DB ノードを CLI から起動・停止するスクリプトを書いた。最初は VM DB System 向けに作り、実機で動作確認まで済ませていた。ところが同じスクリプトを Exadata VM クラスタに向けて実行したところ、1段目から何も返ってこず、ERROR: 'xxx' という名前のDBシステムが見つかりません で終了した。

原因は OCI 側のリソースモデルの違いだった。Exadata VM クラスタは oci db system list の結果に含まれない。

この記事では、その原因と、cloud VM クラスタ前提で作り直したスクリプトを載せる。ノードの OCID をどう解決するかという話が本題だが、作り直す過程で「状態で絞ってから hostname を照合する」という当初の実装に誤操作のリスクがあることも判明したので、それも併せて書く。


結論

Exadata VM クラスタ配下のノード(コンソールで cloud VM cluster → Virtual Machines に並ぶもの)を操作するなら、OCID の解決経路はこの2段になる。

oci db cloud-vm-cluster list --display-name <クラスタ名>   # → クラスタOCID
oci db node list --vm-cluster-id <上記OCID>                # → 配下のノード一覧

⚠️ ノード一覧のフラグは --db-system-id ではなく --vm-cluster-id。ここを間違えると NotAuthorizedOrNotFound (404) が返る。

リソース種別ごとの対応はこうなっている。

実体 一覧コマンド oci db node list のフラグ
VM / BM DB System oci db system list --db-system-id
ExaDB-D(Exadata Database Service on Dedicated Infrastructure) oci db cloud-vm-cluster list --vm-cluster-id
ExaCC(Exadata Cloud@Customer) oci db vm-cluster list --vm-cluster-id
ExaDB-XS(Exascale Infrastructure) oci db exadb-vm-cluster list --vm-cluster-id

ノードの Start / Stop 自体は Exadata VM クラスタでもサポートされている。誤っていたのは OCID の取得経路だけで、oci db node start / oci db node stop --db-node-id の部分は共通で使える。


なぜ oci db system list では引けないのか

oci db node list のヘルプに答えが書いてある。

$ oci db node list --help
Usage: oci db node list [OPTIONS]

  Lists the database nodes in the specified DB system and compartment. In
  addition to the other required parameters, either '--db-system-id' or '--vm-
  cluster-id' also must be provided, depending on the service being accessed.

Options:
  -c, --compartment-id TEXT       The compartment [OCID]. [required]
  --db-system-id TEXT             The DB system [OCID].
  --vm-cluster-id TEXT            The [OCID] of the VM cluster.
  --db-server-id TEXT             The [OCID] of the Exacc Db server.
  --lifecycle-state [PROVISIONING|AVAILABLE|UPDATING|STOPPING|STOPPED|STARTING|TERMINATING|TERMINATED|FAILED]
  ...

--db-system-id--vm-cluster-id のどちらかが必要。アクセスするサービス次第」と明記されている。この「サービス次第」がリソースモデルの分岐で、DB System と Exadata VM クラスタは API のエンドポイントから別物になっている。

そもそも oci db node list には --display-name が無い。ノードが持つのは hostname フィールドだけなので、名前から一発では引けない。だから 親リソース → ノード の2段階検索が必要になる。この構造自体は DB System でも Exadata でも同じで、変わるのは親リソースの種別と、それを渡すフラグだけだ。

oci db system list が0件だった場合、原因の候補は次の3つになる。切り分け順もこの順がよい。

  1. コンパートメントが違う(oci iam compartment get -c <OCID> が通るか確認する)
  2. リソース種別が違う(Exadata VM クラスタなら oci db cloud-vm-cluster list で見える)
  3. 権限が無い(この場合は空ではなく 404 が返ることが多い)

なお、テナンシ横断で探すなら Resource Search が速い。リソース種別名は oci search resource-type list で確認できる。

oci search resource structured-search \
  --query-text "query CloudVmCluster, VmCluster, ExadbVmCluster, DbNode resources" \
  --query 'data.items[*].{Type:"resource-type",Name:"display-name",Comp:"compartment-id"}' \
  --output table

前提条件

  • OCI CLI がインストールされていること
  • ~/.oci/config にプロファイルが設定されていること
  • ~/.oci/config の各プロファイルに compartment_id(カスタムフィールド)を追加しておくこと
[DEFAULT]
tenancy=ocid1.tenancy.oc1..xxxxx
user=ocid1.user.oc1..xxxxx
fingerprint=xx:xx:xx:xx
key_file=~/.oci/oci_api_key.pem
region=ap-tokyo-1
compartment_id=ocid1.compartment.oc1..xxxxx

compartment_id は OCI CLI 標準のフィールドではなく、スクリプト側で読むための独自拡張だ。[DEFAULT] に書けば他セクションにも継承される(python の configparser 互換の挙動)。

OCI_CLI_CONFIG_FILE 環境変数が設定されている場合はそのファイルを読む(OCI CLI 本体と同じ挙動)。

使用する API 操作は次の4つ。権限不足が疑われる場合はこれらを軸に確認する。

  • GetCompartment(コンパートメント疎通確認)
  • ListCloudVmClusters / GetCloudVmCluster(クラスタ解決)
  • ListDbNodes(ノード一覧)
  • DbNodeAction(start / stop 本体)

スクリプト構成

作ったのは2本だけだ。

スクリプト 概要
do_oci_dbnode_start.sh 指定した1ノードを起動する
do_oci_dbnode_stop.sh 指定した1ノードを停止する

共通ライブラリは作らず、各スクリプトは単体で完結させた。 source も一切していない。
前バージョンは4本のスクリプトに同じロジックがコピペで散っていたので、いったんライブラリに切り出す構成も試した。だが運用面では単体完結のほうが扱いやすいと判断して戻した。

  • 必要なファイルを1本だけ scp すれば動く
  • ライブラリの配置パスや相対参照(source "$(dirname "$0")/lib.sh")の事故が起きない
  • スクリプトを読むときに、その1ファイルだけ見れば処理が全部追える

重複するコードは増えるが、配布と可読性を優先した。ロジックを直すときに2ファイル触る手間より、移送先で source に失敗して動かないリスクのほうが痛い。

使い方

bash do_oci_dbnode_start.sh exa-vmc01 exa-vmc01-node1
bash do_oci_dbnode_stop.sh  exa-vmc01 exa-vmc01-node1
bash do_oci_dbnode_stop.sh  exa-vmc01 exa-vmc01-node1 prod   # プロファイル指定

# クラスタ名の代わりに OCID を直接指定してもよい(名前解決をスキップする)
bash do_oci_dbnode_stop.sh ocid1.cloudvmcluster.oc1.ap-tokyo-1.xxxx exa-vmc01-node1

hostname やクラスタ名が分からない場合、わざと存在しない値を渡せば一覧が出る。専用の診断コマンドは用意していない。

$ bash do_oci_dbnode_start.sh exa-vmc01 _
...
ERROR: hostname '_' に一致するノードが無い
--- 配下ノード一覧 ---
  exa-vmc01-node1                          AVAILABLE
  exa-vmc01-node2                          STOPPED

環境変数

変数 既定値 説明
MAX_WAIT 1800 終端状態までの最大待機秒
DRY_RUN 0 1 で API を呼ばず実行予定コマンドのみ表示
ASSUME_YES 0 1 で停止時の確認プロンプトを省略。cron から使う場合に付ける
OCI_CLI_CONFIG_FILE ~/.oci/config 設定ファイルのパス

終了コード

コード 意味
0 成功。または既に目的の状態(冪等にスキップ)
1 引数不足 / クラスタ・ノード解決失敗 / hostname 不一致 / 待機タイムアウト
3 ノードが予期しない状態(UPDATINGFAILED 等)のため操作を拒否
130 確認プロンプトで中止

実装で考えたこと

1. 状態で絞ってから探すのをやめた

前バージョンはこう書いていた。

# 前バージョン: API 側で状態を絞ってから hostname を照合する
DB_NODE_ID=$(oci db node list \
  --db-system-id "${DB_SYSTEM_ID}" \
  --lifecycle-state AVAILABLE \
  --query "data[?hostname=='${NODE_HOSTNAME}'].id | [0]" \
  --raw-output)

問題は --lifecycle-state AVAILABLE だ。絞り込みで消えた理由が呼び出し側に伝わらない。 停止スクリプトでこれが空になったとき、原因は次のどれか分からない。

  • hostname を間違えている
  • ノードが既に STOPPED(=何もしなくてよい)
  • ノードが UPDATING(=メンテ中で触るべきではない)
  • そもそもクラスタに存在しない

前バージョンはこれを全部まとめて「見つかりません」と表示し、exit 0 で終わっていた。何もしなくてよかったのか、触れなかったのかが区別できない。 cron に載せると、メンテ中で停止できなかった日も正常終了として記録される。

そこで順序を入れ替えた。

  1. --lifecycle-state付けずに全ノードを取得する
  2. hostname の完全一致で1台に確定させ、OCID と現在の状態を得る
  3. 確定した1台の状態を見て分岐する

状態の判定は3つに分けた。

  • 既に目的の状態 → SKIP して exit 0(冪等。cron で二重に叩いても安全)
  • 操作可能な状態 → 実行する
  • UPDATING 等の予期しない状態 → exit 3 で拒否する

hostname が一致しない場合は、配下ノードの一覧を出して exit 1 で落とす。「一覧が見られる」こと自体が hostname を調べる手段になるので、診断用のコマンドは別途用意していない。

$ bash do_oci_dbnode_stop.sh exa-vmc01 exa-vmc01-nod
...
ERROR: hostname 'exa-vmc01-nod' に一致するノードが無い
--- 配下ノード一覧 ---
  exa-vmc01-node1                          AVAILABLE
  exa-vmc01-node2                          STOPPED

なお hostname の前方一致(exa-vmc01-nod で node1 に寄せる)は、実装してから外した。理由は後述する。

2. python3jq の依存を外した

このスクリプトは生成AIが使えない別環境に持ち込んで運用する。移送先ホストに python3jq が入っている保証が無いので、依存は bash 4.2以上 / awk / sed / OCI CLI だけに絞った。

問題は、複数ノードの idhostnamelifecycle-state をどう受け取るかだ。JSON を bash だけでパースするのは避けたい。ここは JMESPath 側で整形させて解決した。

# JMESPath 側で "id|hostname|state" を ";" 区切りの1行にまとめさせ、tr で1行1ノードに直す
NODE_QUERY="join(';', map(&join('|', [id, hostname, \"lifecycle-state\"]), data))"
NODES=$(oci db node list ... --query "${NODE_QUERY}" --raw-output | tr ';' '\n' | grep . || true)

map()join() は JMESPath の組み込み関数で、OCI CLI が同梱している jmespath で使える。hostname や OCID に | ; は現れないため区切り文字として安全だ。

こうして 1行1ノードのテキストにしておくと、後段が awk 1行で済む。

# hostname は完全一致のみ。クラスタ内で一意なので、一致すれば1行に決まる
MATCH=$(printf '%s\n' "${NODES}" | awk -F'|' -v h="${NODE_HOSTNAME}" '$1 == h')
if [ -z "${MATCH}" ]; then
  echo "ERROR: hostname '${NODE_HOSTNAME}' に一致するノードが無い" >&2
  echo "--- 配下ノード一覧 ---" >&2
  printf '%s\n' "${NODES}" | awk -F'|' '{printf "  %-40s %s\n", $1, $2}' >&2
  exit 1
fi

IFS='|' read -r _ NODE_STATE NODE_ID <<< "${MATCH}"

~/.oci/config の読み取りも awk で書いた。[DEFAULT] セクションの値を他セクションに継承させる挙動も awk 側で再現している。

3. 削って、削って、また削った

この [2/4] は3回書き直した。行数の推移が分かりやすい。

行数 やっていたこと
1回目 78行 id / hostname / state を bash 配列3本に詰め、添字ループで完全一致 → 前方一致 → 件数判定
2回目 30行 配列をやめ、1行1ノードのテキストに。awk で完全一致 → 前方一致 → 件数判定
3回目 20行 前方一致をやめる。完全一致だけなら件数判定も要らない

3回目に踏み切ったきっかけは、「この節は結局何を作っているのか」を確認したことだった。後続の [3/4] で使うのは --db-node-id に渡す OCID 1個だけで、hostname は表示用、状態は直後の分岐用しかない。前方一致のために持っていた変数と件数判定は、目的に対して過剰だった。

前方一致をやめた判断はこうだ。

  • hostname はクラスタ内で一意なので、完全一致なら必ず1行に決まる。「複数一致したらエラー」という分岐が丸ごと不要になる
  • 一致しなければ一覧が出るので、正しい hostname はその場で分かる。曖昧な指定を受け付ける必要が無い
  • そして停止スクリプトで曖昧な一致を許すのは、そもそも筋が悪い

3点目は実際に痛い目を見た。前方一致を実装した2回目の版で、exa-vmc01-node という中途半端な指定に対し、node1 が確認も警告も無く停止された。当時は状態で絞った後に照合していたため、STOPPED の node2 が候補から消え、前方一致が node1 だけに当たった形だ。「その状態のノードが1台しか無かった」という偶然が誤操作を成立させていた。これはテストで見つけた(後述)。

照合順を直せば防げるが、そこまでして曖昧な指定を許す価値が無いと判断して機能ごと落とした。分岐を消すのが一番確実なバグ対策だ。

4. 待機秒の既定値を 600 → 1800 にした

Exadata のノードは状態遷移に10分以上かかることがある。DB System 想定の 600 秒だと --wait-for-state がタイムアウトし、実際には遷移途中なのに WARN が出る。既定を 1800 秒にして、MAX_WAIT で上書きできるようにした。

5. 破壊的操作にガードを付けた

  • 確認プロンプト: 停止スクリプトは、端末から対話実行した場合のみ確認を求める。[ -t 0 ] で判定しているので、cron やパイプ経由では従来どおり止まらずに実行される。ASSUME_YES=1 でも省略できる
  • DRY_RUN=1: API を呼ばず、実行予定の oci db node ... コマンドだけを表示する
  • コンパートメントの事前確認: 本題に入る前に oci iam compartment get を叩く。「一覧が空」の原因が権限やコンパートメント違いなのかを先に切り分けるため

⚠️ Exadata は停止中もコンピュート課金が継続する(課金を止めるにはクラスタの終了が必要)。夜間停止をコスト削減目的でやるなら、削減効果を先に確認したほうがよい。


コード

do_oci_dbnode_start.sh ― ノード起動

#!/usr/bin/bash
# Exadata cloud VM クラスタ(ExaDB-D)配下のDBノードを起動する
#
# 使い方: bash do_oci_dbnode_start.sh <VMクラスタ名 or クラスタOCID> <ノードhostname> [OCIプロファイル名]
# 環境変数: MAX_WAIT(待機秒。既定 1800)/ DRY_RUN=1(APIを呼ばず実行内容のみ表示)
#
# 終了コード: 0=成功/既に起動済み  1=解決エラー・待機失敗  3=予期しない状態
#
# 依存: bash 4.2以上, awk, sed, oci CLI のみ(python3 / jq は不要)
# このスクリプトは単体で完結している(他ファイルを source しない)
set -eu

if [ "${#}" -lt 2 ]; then
  echo "使い方: $0 <VMクラスタ名 or クラスタOCID> <ノードhostname> [OCIプロファイル名]"
  exit 1
fi

TARGET="${1}"
NODE_HOSTNAME="${2}"
OCI_PROFILE="${3:-DEFAULT}"
MAX_WAIT="${MAX_WAIT:-1800}"   # Exadata ノードの起動は10分以上かかることがある

OCI_CONFIG_PATH="${OCI_CLI_CONFIG_FILE:-${HOME}/.oci/config}"

# ---- OCI CLI エラーを読める形に整形するラッパ ----
export SUPPRESS_LABEL_WARNING=True
OCI_ERR_FILE=$(mktemp)
trap 'rm -f "${OCI_ERR_FILE}"' EXIT

oci() {
  local exit_code=0
  command oci "$@" 2>"${OCI_ERR_FILE}" || exit_code=$?
  if [ "${exit_code}" -ne 0 ] && [ -s "${OCI_ERR_FILE}" ]; then
    # ServiceError の JSON から要点行だけ拾う。拾えなければ全文を出す。
    if grep -qE '"(code|message|status|operation_name)"' "${OCI_ERR_FILE}"; then
      grep -E '"(code|message|status|operation_name)"' "${OCI_ERR_FILE}" \
        | sed -e 's/^[[:space:]]*/[OCI ERROR] /' -e 's/,$//' >&2
    else
      sed -e 's/^/[OCI ERROR] /' "${OCI_ERR_FILE}" >&2
    fi
  fi
  return "${exit_code}"
}

# ---- コンパートメント ----
if [ ! -f "${OCI_CONFIG_PATH}" ]; then
  echo "ERROR: OCI設定ファイルが見つからない: ${OCI_CONFIG_PATH}" >&2
  exit 1
fi

# [DEFAULT] の値は他セクションに継承される(python configparser 互換の挙動)
COMPARTMENT_ID=$(awk -v section="${OCI_PROFILE}" '
  function trim(s) { sub(/^[ \t]*/, "", s); sub(/[ \t]*$/, "", s); return s }
  /^\[.*\]$/ { cur = substr($0, 2, length($0) - 2); next }
  cur == "DEFAULT" && $0 ~ /^[ \t]*compartment_id[ \t]*=/ {
    v = $0; sub(/^[ \t]*compartment_id[ \t]*=[ \t]*/, "", v); default_val = trim(v)
  }
  cur == section && $0 ~ /^[ \t]*compartment_id[ \t]*=/ {
    v = $0; sub(/^[ \t]*compartment_id[ \t]*=[ \t]*/, "", v); section_val = trim(v)
  }
  END {
    if (section == "DEFAULT") { print default_val }
    else if (section_val != "") { print section_val }
    else { print default_val }
  }
' "${OCI_CONFIG_PATH}")

if [ -z "${COMPARTMENT_ID}" ]; then
  echo "ERROR: ${OCI_CONFIG_PATH} の [${OCI_PROFILE}] セクションに compartment_id が設定されていない" >&2
  exit 1
fi

# コンパートメント自体が見えるか先に確認する(一覧が空になる原因の切り分け)
if ! oci iam compartment get \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --query 'data.name' --raw-output >/dev/null; then
  echo "ERROR: compartment_id にアクセスできない(OCIDが誤り/権限不足/別テナンシ)" >&2
  echo "       ${COMPARTMENT_ID}" >&2
  exit 1
fi

# ---- [1/4] cloud VM クラスタの解決 ----
echo "=== [1/4] cloud VM クラスタ検索 (${TARGET}) ==="
case "${TARGET}" in
  ocid1.cloudvmcluster.*)
    # OCID が直接渡された場合は検索しない
    CLUSTER_ID="${TARGET}"
    CLUSTER_NAME=$(oci db cloud-vm-cluster get \
      --profile "${OCI_PROFILE}" \
      --cloud-vm-cluster-id "${CLUSTER_ID}" \
      --query 'data."display-name"' --raw-output 2>/dev/null || true)
    [ -z "${CLUSTER_NAME}" ] && CLUSTER_NAME="(名称取得不可)"
    ;;
  *)
    CLUSTER_ID=$(oci db cloud-vm-cluster list \
      --profile "${OCI_PROFILE}" \
      --compartment-id "${COMPARTMENT_ID}" \
      --display-name "${TARGET}" \
      --all \
      --query 'data[0].id' \
      --raw-output 2>/dev/null || true)
    if [ -z "${CLUSTER_ID}" ] || [ "${CLUSTER_ID}" = "null" ]; then
      echo "ERROR: '${TARGET}' という名前の cloud VM クラスタが見つからない" >&2
      echo "--- このコンパートメントの cloud VM クラスタ一覧 ---" >&2
      oci db cloud-vm-cluster list \
        --profile "${OCI_PROFILE}" \
        --compartment-id "${COMPARTMENT_ID}" \
        --all \
        --query 'data[*].{Name:"display-name",State:"lifecycle-state",ID:id}' \
        --output table >&2 || true
      echo "※ 一覧が空の場合、compartment_id が対象と別か、ExaCC(oci db vm-cluster) 側の可能性がある" >&2
      exit 1
    fi
    CLUSTER_NAME="${TARGET}"
    ;;
esac

echo "=== パラメータ ==="
echo "  VMクラスタ名    : ${CLUSTER_NAME}"
echo "  ノードhostname  : ${NODE_HOSTNAME}"
echo "  OCIプロファイル : ${OCI_PROFILE}"
echo "  コンパートメント: ${COMPARTMENT_ID}"
echo "  クラスタOCID    : ${CLUSTER_ID}"
echo "  最大待機秒      : ${MAX_WAIT}"
[ "${DRY_RUN:-0}" = "1" ] && echo "  DRY_RUN         : 有効(APIは呼ばない)"

# ---- [2/4] 対象ノードのOCID取得 ----
# 状態で絞ってから hostname を照合すると、前方一致が「その状態の1台」に偶然絞られて
# 意図しないノードを操作する事故が起きる。--lifecycle-state は付けず、hostname だけで引く。
echo "=== [2/4] ノードOCID取得 (hostname=${NODE_HOSTNAME}) ==="

# JMESPath 側で "hostname|state|id" を ";" 区切りの1行にまとめさせ、tr で1行1ノードに直す。
# (hostname や OCID に "|" ";" は現れないため衝突しない)
NODE_QUERY="join(';', map(&join('|', [hostname, \"lifecycle-state\", id]), data))"
NODES=$(oci db node list \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --vm-cluster-id "${CLUSTER_ID}" \
  --all \
  --query "${NODE_QUERY}" \
  --raw-output 2>/dev/null | tr ';' '\n' | grep . || true)

if [ -z "${NODES}" ]; then
  echo "ERROR: cloud VM クラスタ配下にノードが1台も見つからない" >&2
  echo "       クラスタ: ${CLUSTER_NAME} (${CLUSTER_ID})" >&2
  echo "       ノードの読み取り権限があるかを確認すること" >&2
  exit 1
fi

# hostname は完全一致のみ。クラスタ内で一意なので、一致すれば1行に決まる。
# 見つからない場合は配下ノードの一覧を出して終了する(hostname を調べる手段も兼ねる)。
MATCH=$(printf '%s\n' "${NODES}" | awk -F'|' -v h="${NODE_HOSTNAME}" '$1 == h')
if [ -z "${MATCH}" ]; then
  echo "ERROR: hostname '${NODE_HOSTNAME}' に一致するノードが無い" >&2
  echo "--- 配下ノード一覧 ---" >&2
  printf '%s\n' "${NODES}" | awk -F'|' '{printf "  %-40s %s\n", $1, $2}' >&2
  exit 1
fi

IFS='|' read -r _ NODE_STATE NODE_ID <<< "${MATCH}"
echo "対象ノードOCID: ${NODE_ID}"
echo "現在の状態    : ${NODE_STATE}"

# 1台に確定してから状態を判定する
if [ "${NODE_STATE}" = "AVAILABLE" ]; then
  echo "SKIP: ${NODE_HOSTNAME} は既に AVAILABLE のため何もしない"
  exit 0
fi
if [ "${NODE_STATE}" != "STOPPED" ]; then
  echo "ERROR: ${NODE_HOSTNAME}${NODE_STATE} 状態。STOPPED でなければ操作しない" >&2
  exit 3
fi

# ---- [3/4] 起動 ----
echo "=== [3/4] ノード起動 ==="
FAILED=0
if [ "${DRY_RUN:-0}" = "1" ]; then
  echo "DRY_RUN: 実行しないコマンド ->"
  echo "  oci db node start --profile ${OCI_PROFILE} --db-node-id ${NODE_ID} \\"
  echo "    --wait-for-state AVAILABLE --max-wait-seconds ${MAX_WAIT}"
else
  if ! oci db node start \
    --profile "${OCI_PROFILE}" \
    --db-node-id "${NODE_ID}" \
    --wait-for-state AVAILABLE \
    --max-wait-seconds "${MAX_WAIT}" \
    --query 'data.{Hostname:hostname, State:"lifecycle-state"}' \
    --output table; then
    echo "WARN: ${NODE_HOSTNAME}${MAX_WAIT}秒以内に AVAILABLE にならなかった"
    FAILED=1
  fi
fi

# ---- [4/4] サマリ ----
echo "=== [4/4] 起動完了サマリ ==="
oci db node list \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --vm-cluster-id "${CLUSTER_ID}" \
  --all \
  --query 'data[*].{Hostname:hostname, State:"lifecycle-state", ID:id}' \
  --output table || true

[ "${FAILED}" -eq 0 ] || exit 1

do_oci_dbnode_stop.sh ― ノード停止

起動版との違いは、判定する状態が逆になる点と、停止前に確認プロンプトが入る点だ。

#!/usr/bin/bash
# Exadata cloud VM クラスタ(ExaDB-D)配下のDBノードを停止する
#
# 使い方: bash do_oci_dbnode_stop.sh <VMクラスタ名 or クラスタOCID> <ノードhostname> [OCIプロファイル名]
# 環境変数: MAX_WAIT(待機秒。既定 1800)/ DRY_RUN=1(APIを呼ばず実行内容のみ表示)
#           ASSUME_YES=1(確認プロンプトを省略。cron用)
#
# 終了コード: 0=成功/既に停止済み  1=解決エラー・待機失敗  3=予期しない状態  130=中止
#
# 依存: bash 4.2以上, awk, sed, oci CLI のみ(python3 / jq は不要)
# このスクリプトは単体で完結している(他ファイルを source しない)
set -eu

if [ "${#}" -lt 2 ]; then
  echo "使い方: $0 <VMクラスタ名 or クラスタOCID> <ノードhostname> [OCIプロファイル名]"
  exit 1
fi

TARGET="${1}"
NODE_HOSTNAME="${2}"
OCI_PROFILE="${3:-DEFAULT}"
MAX_WAIT="${MAX_WAIT:-1800}"   # Exadata ノードの状態遷移は10分以上かかることがある

OCI_CONFIG_PATH="${OCI_CLI_CONFIG_FILE:-${HOME}/.oci/config}"

# ---- OCI CLI エラーを読める形に整形するラッパ ----
export SUPPRESS_LABEL_WARNING=True
OCI_ERR_FILE=$(mktemp)
trap 'rm -f "${OCI_ERR_FILE}"' EXIT

oci() {
  local exit_code=0
  command oci "$@" 2>"${OCI_ERR_FILE}" || exit_code=$?
  if [ "${exit_code}" -ne 0 ] && [ -s "${OCI_ERR_FILE}" ]; then
    # ServiceError の JSON から要点行だけ拾う。拾えなければ全文を出す。
    if grep -qE '"(code|message|status|operation_name)"' "${OCI_ERR_FILE}"; then
      grep -E '"(code|message|status|operation_name)"' "${OCI_ERR_FILE}" \
        | sed -e 's/^[[:space:]]*/[OCI ERROR] /' -e 's/,$//' >&2
    else
      sed -e 's/^/[OCI ERROR] /' "${OCI_ERR_FILE}" >&2
    fi
  fi
  return "${exit_code}"
}

# ---- コンパートメント ----
if [ ! -f "${OCI_CONFIG_PATH}" ]; then
  echo "ERROR: OCI設定ファイルが見つからない: ${OCI_CONFIG_PATH}" >&2
  exit 1
fi

# [DEFAULT] の値は他セクションに継承される(python configparser 互換の挙動)
COMPARTMENT_ID=$(awk -v section="${OCI_PROFILE}" '
  function trim(s) { sub(/^[ \t]*/, "", s); sub(/[ \t]*$/, "", s); return s }
  /^\[.*\]$/ { cur = substr($0, 2, length($0) - 2); next }
  cur == "DEFAULT" && $0 ~ /^[ \t]*compartment_id[ \t]*=/ {
    v = $0; sub(/^[ \t]*compartment_id[ \t]*=[ \t]*/, "", v); default_val = trim(v)
  }
  cur == section && $0 ~ /^[ \t]*compartment_id[ \t]*=/ {
    v = $0; sub(/^[ \t]*compartment_id[ \t]*=[ \t]*/, "", v); section_val = trim(v)
  }
  END {
    if (section == "DEFAULT") { print default_val }
    else if (section_val != "") { print section_val }
    else { print default_val }
  }
' "${OCI_CONFIG_PATH}")

if [ -z "${COMPARTMENT_ID}" ]; then
  echo "ERROR: ${OCI_CONFIG_PATH} の [${OCI_PROFILE}] セクションに compartment_id が設定されていない" >&2
  exit 1
fi

# コンパートメント自体が見えるか先に確認する(一覧が空になる原因の切り分け)
if ! oci iam compartment get \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --query 'data.name' --raw-output >/dev/null; then
  echo "ERROR: compartment_id にアクセスできない(OCIDが誤り/権限不足/別テナンシ)" >&2
  echo "       ${COMPARTMENT_ID}" >&2
  exit 1
fi

# ---- [1/4] cloud VM クラスタの解決 ----
echo "=== [1/4] cloud VM クラスタ検索 (${TARGET}) ==="
case "${TARGET}" in
  ocid1.cloudvmcluster.*)
    # OCID が直接渡された場合は検索しない
    CLUSTER_ID="${TARGET}"
    CLUSTER_NAME=$(oci db cloud-vm-cluster get \
      --profile "${OCI_PROFILE}" \
      --cloud-vm-cluster-id "${CLUSTER_ID}" \
      --query 'data."display-name"' --raw-output 2>/dev/null || true)
    [ -z "${CLUSTER_NAME}" ] && CLUSTER_NAME="(名称取得不可)"
    ;;
  *)
    CLUSTER_ID=$(oci db cloud-vm-cluster list \
      --profile "${OCI_PROFILE}" \
      --compartment-id "${COMPARTMENT_ID}" \
      --display-name "${TARGET}" \
      --all \
      --query 'data[0].id' \
      --raw-output 2>/dev/null || true)
    if [ -z "${CLUSTER_ID}" ] || [ "${CLUSTER_ID}" = "null" ]; then
      echo "ERROR: '${TARGET}' という名前の cloud VM クラスタが見つからない" >&2
      echo "--- このコンパートメントの cloud VM クラスタ一覧 ---" >&2
      oci db cloud-vm-cluster list \
        --profile "${OCI_PROFILE}" \
        --compartment-id "${COMPARTMENT_ID}" \
        --all \
        --query 'data[*].{Name:"display-name",State:"lifecycle-state",ID:id}' \
        --output table >&2 || true
      echo "※ 一覧が空の場合、compartment_id が対象と別か、ExaCC(oci db vm-cluster) 側の可能性がある" >&2
      exit 1
    fi
    CLUSTER_NAME="${TARGET}"
    ;;
esac

echo "=== パラメータ ==="
echo "  VMクラスタ名    : ${CLUSTER_NAME}"
echo "  ノードhostname  : ${NODE_HOSTNAME}"
echo "  OCIプロファイル : ${OCI_PROFILE}"
echo "  コンパートメント: ${COMPARTMENT_ID}"
echo "  クラスタOCID    : ${CLUSTER_ID}"
echo "  最大待機秒      : ${MAX_WAIT}"
[ "${DRY_RUN:-0}" = "1" ] && echo "  DRY_RUN         : 有効(APIは呼ばない)"

# ---- [2/4] 対象ノードのOCID取得 ----
# 状態で絞ってから hostname を照合すると、前方一致が「その状態の1台」に偶然絞られて
# 意図しないノードを停止する事故が起きる。--lifecycle-state は付けず、hostname だけで引く。
echo "=== [2/4] ノードOCID取得 (hostname=${NODE_HOSTNAME}) ==="

# JMESPath 側で "hostname|state|id" を ";" 区切りの1行にまとめさせ、tr で1行1ノードに直す。
# (hostname や OCID に "|" ";" は現れないため衝突しない)
NODE_QUERY="join(';', map(&join('|', [hostname, \"lifecycle-state\", id]), data))"
NODES=$(oci db node list \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --vm-cluster-id "${CLUSTER_ID}" \
  --all \
  --query "${NODE_QUERY}" \
  --raw-output 2>/dev/null | tr ';' '\n' | grep . || true)

if [ -z "${NODES}" ]; then
  echo "ERROR: cloud VM クラスタ配下にノードが1台も見つからない" >&2
  echo "       クラスタ: ${CLUSTER_NAME} (${CLUSTER_ID})" >&2
  echo "       ノードの読み取り権限があるかを確認すること" >&2
  exit 1
fi

# hostname は完全一致のみ。クラスタ内で一意なので、一致すれば1行に決まる。
# 見つからない場合は配下ノードの一覧を出して終了する(hostname を調べる手段も兼ねる)。
MATCH=$(printf '%s\n' "${NODES}" | awk -F'|' -v h="${NODE_HOSTNAME}" '$1 == h')
if [ -z "${MATCH}" ]; then
  echo "ERROR: hostname '${NODE_HOSTNAME}' に一致するノードが無い" >&2
  echo "--- 配下ノード一覧 ---" >&2
  printf '%s\n' "${NODES}" | awk -F'|' '{printf "  %-40s %s\n", $1, $2}' >&2
  exit 1
fi

IFS='|' read -r _ NODE_STATE NODE_ID <<< "${MATCH}"
echo "対象ノードOCID: ${NODE_ID}"
echo "現在の状態    : ${NODE_STATE}"

# 1台に確定してから状態を判定する
if [ "${NODE_STATE}" = "STOPPED" ]; then
  echo "SKIP: ${NODE_HOSTNAME} は既に STOPPED のため何もしない"
  exit 0
fi
if [ "${NODE_STATE}" != "AVAILABLE" ]; then
  echo "ERROR: ${NODE_HOSTNAME}${NODE_STATE} 状態。AVAILABLE でなければ操作しない" >&2
  exit 3
fi

# ---- [3/4] 停止 ----
echo "=== [3/4] ノード停止 ==="

# 対話実行時のみ確認する。cron やパイプ経由(非対話)ではそのまま実行する。
if [ "${ASSUME_YES:-0}" != "1" ] && [ -t 0 ]; then
  read -r -p "${NODE_HOSTNAME} を停止する。続行するか? [y/N]: " ANS
  case "${ANS}" in
    y | Y | yes | YES) ;;
    *) echo "中止した"; exit 130 ;;
  esac
fi

FAILED=0
if [ "${DRY_RUN:-0}" = "1" ]; then
  echo "DRY_RUN: 実行しないコマンド ->"
  echo "  oci db node stop --profile ${OCI_PROFILE} --db-node-id ${NODE_ID} \\"
  echo "    --wait-for-state STOPPED --max-wait-seconds ${MAX_WAIT}"
else
  if ! oci db node stop \
    --profile "${OCI_PROFILE}" \
    --db-node-id "${NODE_ID}" \
    --wait-for-state STOPPED \
    --max-wait-seconds "${MAX_WAIT}" \
    --query 'data.{Hostname:hostname, State:"lifecycle-state"}' \
    --output table; then
    echo "WARN: ${NODE_HOSTNAME}${MAX_WAIT}秒以内に STOPPED にならなかった"
    FAILED=1
  fi
fi

# ---- [4/4] サマリ ----
echo "=== [4/4] 停止完了サマリ ==="
oci db node list \
  --profile "${OCI_PROFILE}" \
  --compartment-id "${COMPARTMENT_ID}" \
  --vm-cluster-id "${CLUSTER_ID}" \
  --all \
  --query 'data[*].{Hostname:hostname, State:"lifecycle-state", ID:id}' \
  --output table || true

[ "${FAILED}" -eq 0 ] || exit 1

cron 例

# 平日 22:00 に node2 を停止 / 翌 7:00 に起動
0 22 * * 1-5 ASSUME_YES=1 /home/opsuser/bin/do_oci_dbnode_stop.sh exa-vmc01 exa-vmc01-node2 >> /var/log/oci-dbnode.log 2>&1
0  7 * * 2-6 /home/opsuser/bin/do_oci_dbnode_start.sh exa-vmc01 exa-vmc01-node2 >> /var/log/oci-dbnode.log 2>&1

モックを作って検証した

運用先の Exadata 環境と開発環境が別で、開発側のテナンシには DB 系リソースが存在しない。かつ運用先では試行錯誤したくない。そこで OCI CLI のモックを用意して、スクリプトのロジックを検証した。

工夫した点は、モックが --query実際に JMESPath で評価するようにしたことだ。OCI CLI は自身の venv に jmespath を同梱しているので、それを使う。

import jmespath

def emit(response, opts):
    """--query を jmespath で評価して実CLIと同じ形式で出力"""
    q = opts.get("query")
    result = jmespath.search(q, response) if isinstance(q, str) else response

    if result is None:
        sys.stderr.write("Query returned empty result, no output to show.\n")
        sys.exit(0)
    ...

こうすると、--query の式そのものが検証対象になる。--query 'data[0].id' --raw-output を空結果に対して実行したときの挙動(stdout は空、stderr に Query returned empty result, no output to show.、終了コードは 0)も実 CLI で実測して合わせた。ここを合わせておかないと「空だったときの分岐」がテストできない。

さらに、モックは db node list に渡されたフラグと親リソースの種別が食い違っていたら 404 を返すようにした。これで --db-system-id に cloud VM クラスタの OCID を渡すと失敗するという、今回の不具合そのものを再現できる。

$ bash test/run-tests.sh
===== [1] 構文チェック / 自己完結性 =====
  PASS  bash -n do_oci_dbnode_start.sh (rc=0)
  PASS  do_oci_dbnode_start.sh は単体で完結している
  ...
===== [6] 旧スクリプト(20260707)の回帰確認 =====
  PASS  旧スクリプトは cloud VM クラスタを扱えない(rc=1)→ 修正の必要性を確認
===== [7] 外部依存の排除確認 (python3 / jq を潰して実行) =====
  PASS  python3/jq 無しで起動できる (rc=0)
  ...
===================================
 PASS: 51 / FAIL: 0
===================================

検証した区分は次のとおり。

区分 内容
構文・構成 bash -n / 各スクリプトが source を使わず単体完結であること
正常系 起動・停止の状態遷移 / クラスタOCID直接指定
異常系 hostname 不一致・部分一致の拒否 / クラスタ名誤り / クラスタ0件 / ノード0件 / 冪等スキップ / UPDATING 拒否 / 不正プロファイル / 引数不足
障害 終端状態に到達しない場合の WARN と終了コード / MAX_WAIT 上書き
安全機構 DRY_RUN=1 が状態を変えないこと
回帰 旧版が同じ環境で失敗すること
移植性 python3jq を実行不能にしても動作すること

モックが実バグを見つけた

前述の「前方一致で node1 が勝手に停止された」件は、このテストが検出したものだ。前方一致が複数ノードに当たるケースを書いたところ、エラーになるはずが rc=0 で通り、ログを見ると node1 が停止されていた。状態で絞った後に照合していたせいで、候補が偶然1台になっていた。

意図を「異常系のテストを書く」に置いていたおかげで拾えた。正常系だけ書いていたら、運用で踏むまで気付かなかったはずだ。

テストには設計方針そのものを固定する検査も入れた。source を使っていないこと、スクリプトが2本だけであることを確認している。方針は書いておくだけだと戻ってしまうので、落ちるようにしておく。

⚠️ モック検証の限界は明確にしておく。 モックが検証するのはリクエストの組み立てとスクリプトのロジックだけで、実際の OCI の応答・権限・所要時間は再現していない。実 API に対する Exadata VM クラスタでの検証はまだ実施していない。 実環境では一覧確認 → DRY_RUN → 本番の順で進める必要がある。


参考: VM DB System での実測値

前バージョン(DB System 対象)は実機で検証済みなので、その結果を残しておく。検証環境は VM DB System(VM.Standard.E4.Flex、1 OCPU、Standard Edition、19c、License Included、256GB)を1台。

  • AVAILABLE → STOPPED の遷移: 約2分40秒
  • STOPPED → AVAILABLE の遷移: 約2分38秒
  • --wait-for-state によるポーリングが正しく完了を待ち、サマリ表示が最新状態を反映することを確認した

DB System はこの程度で終わるが、Exadata はノード数もサイズも違うため、同じ待機秒では足りない。既定値を 1800 秒にしたのはこれが理由だ。

ハマったポイント: プライベートサブネットとサービスゲートウェイ

検証用の DB System をプライベートサブネットに構築したところ、oci db system launch が次のエラーで失敗した。

InvalidParameter: Cannot access Object Storage using the subnet with the following OCID: xxx. Review your VCN configuration.

DB System のプロビジョニングはバックエンドで Object Storage 等の Oracle Services Network 上のサービスにアクセスする。プライベートサブネットの場合は サービスゲートウェイ と、それを指す ルートテーブルのルール(destination-type: SERVICE_CIDR_BLOCK が事前に必要になる。VCN を新規に作る場合は前提条件に加えておくとよい。


トラブルシュート

症状 原因 対処
oci db system list が何も返さない cloud VM クラスタは DB System ではない oci db cloud-vm-cluster list を使う
compartment_id にアクセスできない OCID が誤り / 権限なし / 別テナンシ oci iam compartment get -c <OCID> を単体で確認する
クラスタが見つからない + 一覧が空 compartment_id が対象と別、または ExaCC 側 別コンパートメントを確認する。ExaCC なら oci db vm-cluster list で見える
クラスタが見つからない + 一覧に出る 名前の綴り違い 一覧の Name をそのまま指定する
配下にノードが1台も見つからない フラグ違い / ノード読み取り権限なし oci db node list -c <C> --vm-cluster-id <ID> を単体で確認する
hostname に一致するノードが無い 綴り違い・部分指定 一緒に出る一覧の hostname をそのまま指定する
NotAuthorizedOrNotFound (404) 権限不足、またはフラグと親リソースの不一致 --vm-cluster-id を使っているか確認する
MAX_WAIT秒以内に ... にならなかった Exadata の状態遷移が長い MAX_WAIT=3600 を指定し、コンソールで実際の状態を確認する

まとめ

やりたいこと 使うもの
クラスタ・ノードの一覧を見たい 存在しない名前を渡してエラー出力の一覧を見る
1ノード起動・停止したい do_oci_dbnode_start.sh / do_oci_dbnode_stop.sh
実行内容だけ先に見たい DRY_RUN=1 を付ける

今回の学びは4つある。

  1. OCI の「DB」は1つのリソースモデルではない。 DB System と Exadata VM クラスタは別系統で、oci db node list に渡すフラグから変わる。--help の「depending on the service being accessed」のような一文は読み飛ばさないほうがよい
  2. API のフィルタで絞ると、絞られて消えた理由が分からなくなる。 --lifecycle-state で先に絞ると「hostname 違い」と「既に目的の状態」と「メンテ中」が全部同じ空結果になる。名前で1台に確定させてから状態を見れば、3つを別々に扱える
  3. 機能を消すのが一番確実なバグ対策になる。 曖昧な hostname 指定を許した結果、意図しないノードが停止した。照合順を直せば防げたが、そもそも許さないことにしたら分岐が丸ごと消えた
  4. 実機が無くてもロジックは検証できる。 --query を実際に評価するモックを作れば、JMESPath 式も空結果時の分岐も含めてテストできる。ただし検証できたのはロジックだけであり、実 API での確認は別途必要になる
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?