※ この記事とスクリプトは個人の学習・検証目的で作成したものだ。本番環境での利用は自己責任とする。
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つになる。切り分け順もこの順がよい。
- コンパートメントが違う(
oci iam compartment get -c <OCID>が通るか確認する) - リソース種別が違う(Exadata VM クラスタなら
oci db cloud-vm-cluster listで見える) - 権限が無い(この場合は空ではなく 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 |
ノードが予期しない状態(UPDATING・FAILED 等)のため操作を拒否 |
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 に載せると、メンテ中で停止できなかった日も正常終了として記録される。
そこで順序を入れ替えた。
-
--lifecycle-stateを付けずに全ノードを取得する - hostname の完全一致で1台に確定させ、OCID と現在の状態を得る
- 確定した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. python3 と jq の依存を外した
このスクリプトは生成AIが使えない別環境に持ち込んで運用する。移送先ホストに python3 や jq が入っている保証が無いので、依存は bash 4.2以上 / awk / sed / OCI CLI だけに絞った。
問題は、複数ノードの id と hostname と lifecycle-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 が状態を変えないこと |
| 回帰 | 旧版が同じ環境で失敗すること |
| 移植性 |
python3 と jq を実行不能にしても動作すること |
モックが実バグを見つけた
前述の「前方一致で 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つある。
-
OCI の「DB」は1つのリソースモデルではない。 DB System と Exadata VM クラスタは別系統で、
oci db node listに渡すフラグから変わる。--helpの「depending on the service being accessed」のような一文は読み飛ばさないほうがよい -
API のフィルタで絞ると、絞られて消えた理由が分からなくなる。
--lifecycle-stateで先に絞ると「hostname 違い」と「既に目的の状態」と「メンテ中」が全部同じ空結果になる。名前で1台に確定させてから状態を見れば、3つを別々に扱える - 機能を消すのが一番確実なバグ対策になる。 曖昧な hostname 指定を許した結果、意図しないノードが停止した。照合順を直せば防げたが、そもそも許さないことにしたら分岐が丸ごと消えた
-
実機が無くてもロジックは検証できる。
--queryを実際に評価するモックを作れば、JMESPath 式も空結果時の分岐も含めてテストできる。ただし検証できたのはロジックだけであり、実 API での確認は別途必要になる