#MLOps#信頼性#リトライ#Kubernetes

70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた


音声モデルの学習ジョブが失敗した。

train failed: R2 download failed:
  voice_training/<model-id>/<clip-id>.wav

このジョブは、TTSで205本のクリップを生成し、品質チェックを通してオブジェクトストレージに保存し、全部揃ったところで学習を始める。生成に約70分。その70分が終わった直後、学習用にクリップを読み戻す最初の1本で落ちた。

該当ファイルを確認すると、普通に存在した。

DBの記録: file_size_bytes = 503084
実際にダウンロード: 503084 バイト

ストレージも無停止だった(Podの再起動0回、1か月以上稼働)。ログを掘ると原因が出てきた。

WARNING r2_service.py : download
  R2 download failed for voice_training/.../<clip-id>.wav:
  Could not connect to the endpoint URL: "http://minio.../..."

一過性の接続失敗。 DNSかTCPの瞬きだ。数秒後には正常に読める。

そして、この経路にリトライが無かった。

1回の失敗が全部を消す構造

該当コードはこれだけだった。

async def download_clip(r2_key: str) -> bytes:
    data = R2Service().download(r2_key)
    if not data:
        raise RuntimeError(f"R2 download failed: {r2_key}")
    return data

呼び出し側は205本を順に読む。

for a in audios:
    raw_by_id[str(a.id)] = await builder.download_clip(a.r2_key)

205回のダウンロードのうち1回でも瞬断に当たれば、例外が上がってジョブ全体が error になる。 生成に70分、その後の読み戻しで数分。この間ずっと、1回の瞬きに全部を賭けている状態だった。

確率で考えると当然の帰結だ。1本あたりの失敗率が仮に0.1%でも、205本連続なら成功率は 0.999^205 ≈ 81%。5回に1回は落ちる。

リトライを入れる

# 学習直前に 200 本超のクリップを連続DLするため、R2/MinIO への瞬断 1 回でも
# 製造済みコーパスごとジョブが全滅していた。
_DOWNLOAD_ATTEMPTS = 4


async def download_clip(r2_key: str, *, attempts: int = _DOWNLOAD_ATTEMPTS) -> bytes:
    for i in range(attempts):
        # R2Service を毎回作り直す: 壊れたコネクションを掴んだままの再試行を避ける。
        data = R2Service().download(r2_key)
        if data:
            return data
        if i < attempts - 1:
            wait = 2 ** i
            logger.warning(
                f"[voice-design] R2 download 再試行 {i + 1}/{attempts - 1} ({wait}s待機): {r2_key}")
            await asyncio.sleep(wait)
    raise RuntimeError(f"R2 download failed: {r2_key} ({attempts}回試行)")

指数バックオフで1s → 2s → 4s、計4回試す。

クライアントを毎回作り直しているのがポイントで、S3系クライアントはコネクションプールを持つため、壊れたコネクションを掴んだまま同じインスタンスで再試行すると同じエラーを繰り返すことがある。作り直せば新しい接続を張る。

挙動をスタブで確認する

リトライは「実際に失敗させないと確認できない」コードなので、書いたら必ず動かす。本番のストレージを落とすわけにはいかないので、スタブで差し替える。

import asyncio, sys, types, time

calls = {"n": 0}
class R2Service:
    def download(self, key):
        calls["n"] += 1
        return b"OK" if calls["n"] >= 3 else None      # 3回目で成功

stub = types.ModuleType("apps.services.r2_service")
stub.R2Service = R2Service
sys.modules["apps.services.r2_service"] = stub
# ... builder モジュールをロード ...

t0 = time.time(); r = asyncio.run(builder.download_clip("k"))
print(f"成功: 呼び出し{calls['n']}回 経過{time.time()-t0:.1f}s")
# → 成功: 呼び出し3回 経過3.0s   (1+2秒待機)

calls["n"] = -1000                                     # 常に失敗させる
t0 = time.time()
try:
    asyncio.run(builder.download_clip("k"))
except RuntimeError as e:
    print(f"失敗: {e} 経過{time.time()-t0:.1f}s")
# → 失敗: R2 download failed: k (4回試行) 経過7.0s   (1+2+4秒待機)

待機時間の合計まで確認する。バックオフの計算を間違えると、リトライしているつもりで即座に4回叩いて終わっていたりする。3.0秒と7.0秒という数字が出て初めて、意図どおりに待っていると言える。

冪等なレジュームを先に作っておく

リトライより先に効いたのが、レジュームの設計だった。今回の事故から復旧できたのは、クリップが1本保存されるたびに進捗をDBに書いていたからだ。

async def _save_clip(db, *, user_id, text, wav) -> str:
    audio_id = uuid.uuid4()
    r2_key = f"voice_training/{user_id}/{audio_id}.wav"
    R2Service().upload(wav, r2_key, "audio/wav")
    db.add(VoiceTrainingAudio(id=audio_id, user_id=..., text=text, r2_key=r2_key, ...))
    await db.commit()
    return str(audio_id)

そして job の progress に line_key → audio_id の対応を記録する。

{
  "clips": {
    "base_01": "<asset-id-1>",
    "base_02": "<asset-id-2>",
    "joy_1":   "<asset-id-3>",
    ...
  },
  "counts": {"done": 205, "total": 205, "failed": 1},
  "params": {"conv_style": "narration"}
}

再実行時は、記録済みの line_key をスキップして続きから始める。

[voice-design] job <job-id> start: 男性ナレーター plan=205 resume=204

204本を再利用して、作り直したのは1本だけ。 70分の製造をスキップして、数分で学習段階に復帰した。

このレジュームが無ければ、瞬断のたびに70分やり直しになる。リトライは事故を減らし、レジュームは事故のコストを下げる。両方要る。

進捗の記録単位

レジュームの粒度は「やり直したくない単位」で決める。今回は1クリップあたり10〜20秒かかるので、クリップ単位で記録した。

これがバッチ単位(たとえば50本ごと)だと、49本目で落ちたときに49本ぶんを捨てることになる。逆に細かすぎるとDBへの書き込みがボトルネックになるが、10秒に1回のコミットなら誤差の範囲だった。

「1単位の処理時間 × 記録間隔」が、事故1回あたりの損失になる。 ここから逆算して粒度を決める。

同じ穴を他にも探す

この事故のあと、同じパターンを探した。**「外部依存を呼ぶ箇所で、リトライが無く、かつ失敗が全体を巻き込む」**を機械的に洗う。

# 例外を上げるだけの外部呼び出しを探す
grep -rn "raise RuntimeError" --include=*.py apps/ | \
  grep -iE "download|upload|fetch|request|connect"

見つかった候補を、こう分類した。

分類 対応
ループ内で呼ばれ、失敗が全体を止める リトライ必須(今回のケース)
単発で呼ばれ、ユーザーが再実行できる リトライ不要(UIで再試行させる)
失敗しても続行できる(ログだけ残す) そのまま

分類の軸は「失敗したとき、何がやり直しになるか」だ。70分やり直しになる箇所と、ボタンを押し直せば済む箇所では、必要な堅牢性が違う。

事故を「見えるように」しておく

もう1つ効いたのは、失敗がジョブのステータスとエラーメッセージとして残っていたことだ。

SELECT name, status, phase, progress->'counts', error
FROM voice_design_jobs WHERE status = 'error';
男性ナレーター | error | error | {"done":205,"total":205,"failed":1}
  | R2 download failed: voice_training/.../<clip-id>.wav

done: 205 / total: 205 と出ているので、製造は完走していて学習で落ちたと一目で分かる。もしステータスだけで error としか残っていなければ、どの段階で落ちたのか切り分けから始めることになった。

failed: 1 も効いている。品質チェックで除外されたクリップが1本ある、という別の情報がここに出ていて、「205本中204本が使える状態でレジュームできる」という判断に直結した。

進捗のカウンタは、成功時には誰も見ないが、失敗時に切り分けの起点になる。 実装コストはほぼゼロなので、長時間ジョブには必ず入れておくといい。

まとめ

  • 連続処理では、1回あたりの失敗率が低くても全体では高確率で落ちる。205本なら0.1%でも5回に1回
  • リトライはクライアントを作り直す。コネクションプールが壊れたコネクションを掴んでいることがある
  • リトライの挙動はスタブで確認する。待機時間の合計まで見る
  • レジュームは事故のコストを下げる。粒度は「1単位の処理時間 × 記録間隔 = 損失」から逆算する
  • 進捗カウンタとエラー文字列を残す。失敗時にしか使わないが、そのときは切り分けの起点になる

ちなみに今回のリトライは、その後のバッチで一度も発動しなかった。ログで確認済みだ。保険としては、それが正しい状態だと思う。


シリーズ: 拡散TTSから実用ボイスを量産する

キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第4部 運用にあたります。

← 前: 文字起こしでは見つからない欠陥がある → 次: “ja” を “JP” と書いて喃語モデルができるまで

シリーズ全18本
  1. 音質で選んだTTSが、会話には遅すぎた
  2. 声をガチャで引く
  3. 「ナレーターっぽい声」を24候補から機械に選ばせる
  4. 品質ゲートを厳しくするほど、棒読みが生き残る
  5. 学習後に話速は変えられない
  6. 生成するたび「録音場所」が変わるTTS
  7. クリップ1本の粗さが、スタイル全体を嗄れさせる
  8. AIが「こんにちわー」と伸ばす癖は、どこで身についたのか
  9. 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた
  10. ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった
  11. 品質ゲートが許した「3文字」が、モデルの口癖になった
  12. 治せる欠陥で候補を落としていた
  13. 文字起こしでは見つからない欠陥がある 14. 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた ← いまここ
  14. “ja” を “JP” と書いて喃語モデルができるまで
  15. 登録経路が4つ、管理画面が0
  16. デプロイのたびに互いの成果を消していた
  17. 測れていないものを閾値で追い込むと、必ず失敗する

知見の元になったノートは 拡散TTSから実用ボイスを量産する製造パイプライン にまとめてあります。

← 記事一覧へ