はじめに
Oracle Cloud Infrastructure(OCI)の Flexible Load Balancer(FLB)配下でアプリケーションサーバー群を運用しているのですが、次のような場面で「一部または全部のバックエンドへのアクセスを一時的に止めたい」という要望がありました(扱っているソフトウェアやシステム構成によって、この必要性は変わってくると思います)。
- データバックアップ取得中はアプリへのアクセスを止めてデータ整合性を保ちたい
- サーバーを1台ずつ再起動したいが、サービス全体は止めたくない(無停止アップデート)
OCI の FLB には、バックエンドを「ドレイン状態」にする機能があります。ドレインにすると新規リクエストの振り分けが止まり、既存の接続だけ処理が継続されます。これを OCI CLI から操作して、上記の運用を自動化するシェルスクリプト一式を作ったので、設計のポイントを共有します。
表記について:この記事には「バックアップ」という言葉が2つの異なる意味で登場します。1つはアプリのデータを退避するデータバックアップ(運用ジョブとして実行されるもの)、もう1つはFLBの
backup属性を持つバックアップ用バックエンド(フォールバック用のサーバー)です。混同しないよう、以降はこの2つを区別して表記します。
作ったもの
3本の Bash スクリプトです。
| スクリプト | 役割 |
|---|---|
backup-drain.sh |
バックアップ用バックエンドを除く全バックエンドをドレイン状態にする(データバックアップ処理の前工程) |
backup-undrain.sh |
全バックエンドのドレインを解除する(データバックアップ処理の後工程) |
rolling-update.sh |
バックエンドを1台ずつ「ドレイン→再起動→ドレイン解除」する |
データバックアップの実行自体は本スクリプトの責務外です。backup-drain.sh → データバックアップ処理 → backup-undrain.sh という一連の流れをジョブスケジューラーで連動させ、1つの運用ジョブとして実行する想定にしています。
全体像はこんな感じです。
【データバックアップ運用】(ジョブとして連動実行)
backup-drain.sh 実行
→ 全バックエンドがドレイン状態に
→ FLB がバックアップ用バックエンドに振り分け
→ データバックアップ処理を実施
→ backup-undrain.sh 実行 → 通常運用へ復帰
【ローリングアップデート運用】
rolling-update.sh 実行
→ 対象サーバーを1台ずつ「ドレイン → 再起動 → ドレイン解除」
→ サービス全体を止めずに全台更新完了
OCI FLBのバックエンド属性について
OCI CLI で操作するにあたって、まず FLB のバックエンド(Backend)が持つ属性を理解しておく必要があります。oci lb backend list で1台のバックエンドを取得すると、次のようなJSONが返ってきます。
{
"name": "10.0.1.10:8080",
"ip-address": "10.0.1.10",
"port": 8080,
"weight": 1,
"backup": false,
"drain": false,
"offline": false
}
このうち今回の運用で関わってくるのが weight / backup / drain / offline の4つです。
drain:新規リクエストの受付停止
drain を true にすると、そのバックエンドは新規のリクエストの振り分け対象から外れますが、すでに張られている既存のTCP接続はそのまま処理が継続されます。つまり「今アクセス中の人には影響を出さず、これから来る人だけ他のサーバーに回す」という緩やかな切り離しができるのがポイントです。即座に接続を切ってしまう offline とは効果がまったく異なります。
FLB上でドレインを設定すると、ヘルスチェック自体は継続されたまま「振り分け対象外」というマーキングだけが変わる形になります。バックエンド自体を落とす必要はなく、CLIから属性を切り替えるだけで挙動が変わるのが運用上便利な点です。
ただし、既存接続がいつ処理を終えるかはアプリケーション側の処理時間に依存するため、「ドレインにした瞬間にゼロコネクションになる」わけではありません。長時間かかるリクエストを処理している最中のバックエンドをドレインした場合、そのリクエストが終わるまで接続は残り続けます。今回の実装では、ドレイン設定後に固定の待機時間を設けることで、実務上「十分にコネクションが捌けているはず」というタイミングを作ってから次の処理(再起動やバックアップ)に進むようにしています(詳細は後述)。
backup:バックアップ用バックエンドの区別
backup が true のバックエンドは、Backend Set 内で「バックアップ用バックエンド」として区別されるサーバーです。通常時のロードバランシング対象からは外れており、他の全バックエンドが利用不可(ドレインやオフライン、ヘルスチェック失敗など)になったときに初めてトラフィックが振り分けられる、仕組み上はフォールバック用の位置付けです。データ退避のためのバックアップとは無関係の、あくまでFLB上の属性名です。
この性質を逆手に取ると、「対象の全バックエンドを意図的にドレインにする」ことで、強制的にバックアップ用バックエンドへ全リクエストを誘導できます。今回はこのバックアップ用バックエンドに、メンテナンス中や障害発生時のお知らせを表示する簡易ページ(いわゆるソーリーページ)を置いておくことで、データバックアップ処理の前に対象の全台をドレインするだけで、利用者からのアクセスをそのお知らせページに自然に集約させる、という使い方をしています。
weight と offline:ドレイン更新時の落とし穴
oci lb backend update は、バックエンドの設定を丸ごと更新するAPIです。更新したい属性だけを指定する差分更新ではないため、ドレイン状態だけ変えたい場合でも、実際には次のように他の属性も一緒に渡す必要があります。
oci lb backend update \
--load-balancer-id "${LB_OCID}" \
--backend-set-name "${BACKEND_SET_NAME}" \
--backend-name "${backend_name}" \
--drain true \
--weight "${current_weight}" \
--backup "${current_backup}" \
--offline "${current_offline}"
--weight(ロードバランシングの重み)や --offline(明示的なオフライン化)を省略・デフォルト値で渡してしまうと、意図せずそれらの設定まで上書きしてしまいます。これを避けるため、事前に oci lb backend list で取得した現在値をそのまま使い回すようにしています。
# 1回のjq呼び出しで name/weight/backup/offline をまとめて取り出す
extracted="$(printf '%s' "${backend_json}" \
| jq -r '[.name, (.weight|tostring), (.backup|tostring), (.offline|tostring)] | @tsv')"
IFS=$'\t' read -r backend_name current_weight current_backup current_offline <<< "${extracted}"
「ドレインを変更する」という1つの操作のために、関係のない属性まで一緒に読み書きする必要がある点は、FLBのバックエンド更新APIを使う上で意識しておきたいポイントです。
バックアップ用バックエンドの除外
FLBのバックエンドセットには、前述の通り通常のトラフィック振り分け対象外の「バックアップ用バックエンド」(backup 属性が true)を含められます。このバックアップ用バックエンドは、ドレイン/ドレイン解除いずれの操作からも常に除外しています。誤ってバックアップ用バックエンドをドレイン状態にしてしまうと、他の全バックエンドが利用不可になった際のフォールバック先まで失われてしまうためです。
filter_non_backup() {
local input_json="${1:-${BACKENDS_JSON:-}}"
# backup == true のバックエンドはスキップ対象として WARN ログに記録
local skipped_names
skipped_names="$(printf '%s' "${input_json}" \
| jq -r '.data[] | select(.backup == true) | .name')"
# ...ログ出力...
# backup == false のみを抽出
FILTERED_BACKENDS="$(printf '%s' "${input_json}" \
| jq -c '{data: [.data[] | select(.backup == false)]}')"
}
無停止ローリングアップデートの実装
rolling-update.sh は対象サーバーを1台ずつ「ドレイン → プロセス再起動 → ドレイン解除」の順で処理します。1台の処理がすべて完了してから次の1台に進むことで、常に残りのサーバーがリクエストを受け付けられる状態を保ち、サービス全体を止めずにアップデートできます。
while IFS= read -r backend_json; do
backend_name="$(printf '%s' "${backend_json}" | jq -r '.name')"
# ① ドレイン設定
set_drain "${backend_json}" true
wait_seconds "${DRAIN_WAIT_SECONDS}" "ドレイン反映・既存接続の処理完了待ち"
# ② プロセス再起動
"${RESTART_SHELL}" "${backend_name}"
wait_seconds "${POST_RESTART_WAIT_SECONDS}" "プロセス起動待ち"
# ③ ドレイン解除
set_drain "${backend_json}" false
wait_seconds "${UNDRAIN_WAIT_SECONDS}" "次のバックエンドへ進む前の待機"
done < <(printf '%s' "${FILTERED_BACKENDS}" | jq -c '.data[]')
途中のいずれかのステップ(ドレイン設定・再起動・ドレイン解除)が失敗した場合は、その場で処理を中断します。中途半端にドレイン状態が残ってしまう可能性があるため、失敗時は対象のバックエンド名を明示したエラーメッセージを出し、運用者が手動で状態を確認・復旧できるようにしています。
ステップ間の待機とドレイン反映待ち
ドレインを設定した直後にすぐ次の処理へ進むと、既存接続がまだ処理中の可能性があります。そこで各ステップの後に待機を入れる仕組みを用意しました。
wait_seconds() {
local seconds="${1:-}"
local reason="${2:-}"
# 0・空文字・非数値は「待機しない」に倒す(安全側)
if [[ ! "${seconds}" =~ ^[0-9]+$ ]]; then
return 0
fi
if [[ "${seconds}" -le 0 ]]; then
return 0
fi
log INFO "${reason}のため ${seconds} 秒待機します"
sleep "${seconds}"
}
待機秒数は設定ファイルで一元管理し、ドレイン設定後・再起動後・ドレイン解除後の3箇所でそれぞれ個別に指定できるようにしています。
DRAIN_WAIT_SECONDS=60 # ドレイン設定後(既存接続の処理完了待ち)
POST_RESTART_WAIT_SECONDS=30 # 再起動後(プロセス起動待ち)
UNDRAIN_WAIT_SECONDS=10 # ドレイン解除後(次のサーバーへ進む前)
秒数のバリデーションは「正の整数のときだけ待機する」という単純なルールにし、設定ミス(空文字や非数値)があってもスクリプトが異常終了せず、単に待機をスキップする安全側の挙動にしています。
フェイルファスト or ベストエフォート、使い分けた理由
3本のスクリプトはエラー時の挙動を統一させていません。
-
backup-drain.shとrolling-update.shは 1件でも失敗したら即終了 -
backup-undrain.shは 1件失敗しても残りは処理を続ける
これらのスクリプトはジョブスケジューラー経由で実行されるため、途中で exit 1 すればジョブがアベンド(異常終了)し、監視システム経由で運用者に通報が飛ぶ仕組みになっています。つまりどちらの失敗でも気づけること自体は同じです。使い分けているのは「異常に気づいた後、どちらの挙動がより被害を小さくできるか」という観点です。
backup-drain.sh はこれからデータバックアップ処理に入る前工程なので、1台でもドレインし損ねたサーバーが残っていると、データバックアップ中に想定外のアクセスが発生し整合性が崩れるおそれがあります。ここは中途半端に進めるより即座に止めて確実に気づいてもらう方が安全です。
一方 backup-undrain.sh はサービスを通常運用に戻すための後工程です。1台のドレイン解除に失敗したからといって、残りの正常なサーバーまで復旧を止めてしまうと、直せる台数まで巻き込んでサービス断が長引いてしまいます。そのためベストエフォートで最後まで処理を続け、復旧できる台数を最大化した上で、成功件数・失敗件数をまとめてログに残し、失敗があれば exit 1 でジョブアベンドさせて後続の手動対応につなげる設計にしています。
# backup-undrain.sh の該当部分(要約)
while IFS= read -r backend_json; do
if set_drain "${backend_json}" false; then
success_count=$((success_count + 1))
else
fail_count=$((fail_count + 1))
log ERROR "ドレイン解除に失敗しました。残りのバックエンドの処理を継続します。"
fi
done < <(printf '%s' "${FILTERED_BACKENDS}" | jq -c '.data[]')
if [[ "${fail_count}" -gt 0 ]]; then
log ERROR "ドレイン解除が完了しました(一部失敗): 成功 ${success_count} 件 / 失敗 ${fail_count} 件"
exit 1
fi
まとめ
OCI FLBのバックエンドドレインをBashで自動化する際に押さえておきたいポイントをまとめます。
-
drainは新規リクエストの受付だけを止める緩やかな切り離し。既存接続はそのまま継続されるため、実際にコネクションが捌けるまでには時間差がある -
backup属性のバックエンドはフォールバック用。対象の全台をドレインすることで意図的にバックアップ用バックエンドへ全リクエストを誘導できる -
oci lb backend updateは差分更新ではないため、weight/backup/offlineは現在値を一緒に渡す必要がある - バックアップ用バックエンド(
backup: true)は操作対象から常に除外する - フェイルファスト/ベストエフォートは、ジョブアベンドで気づけること自体は同じでも「どちらの挙動が被害を小さくできるか」という観点で使い分ける
同じような運用自動化をされている方の参考になれば幸いです。