A+B 土曜複合 — @retry デコレータ × Docker マルチステージビルド

2026-05-02 (Day 2) 土曜複合問題 ★★★☆☆ A: コーディング/デコレータ B: インフラ/Docker

カテゴリ A: コーディング — Python デコレータとメタプログラミング

以下の要件を満たす @retry デコレータを実装せよ。

要件

  1. 関数が例外を raise した場合、指定回数まで自動リトライする
  2. リトライ間隔は指数バックオフinitial_wait × 2^n 秒)
  3. 最大リトライ回数を超えた場合は最後の例外をそのまま raise する
  4. リトライのたびに "Retry N/max: <exception>" をログ出力する
  5. @retry(max_retries=3, initial_wait=1.0) の形式で使えること

使用例

@retry(max_retries=3, initial_wait=1.0)
def call_flaky_api():
    import random
    if random.random() < 0.7:
        raise ConnectionError("API timeout")
    return "success"

カテゴリ B: インフラ — Docker マルチステージビルドとイメージ最適化

以下の要件を満たす Dockerfile を作成せよ。

要件

  • Python 3.12 ベース
  • アプリケーション: main.py(FastAPI)と requirements.txt
  • マルチステージビルドを使って最終イメージのサイズを最小化
  • 本番イメージには pipgcc も含めない
  • 非 root ユーザー appuser で実行
  • ヘルスチェック: /health エンドポイントを 30 秒ごとに確認

マルチステージビルド × K8s Retry 連携図

🔨 Builder Stage python:3.12-slim pip install -r requirements.txt → /opt/venv に依存関係 gcc / pip が残る(本番不要) この層は本番に含めない COPY --from /opt/venv のみ 🚀 Production Stage python:3.12-slim(クリーン) appuser(非root)で実行 HEALTHCHECK /health 30s uvicorn main:app --port 8000 pip / gcc は含まれない ✓ イメージサイズ最小化 deploy ☸ K8s Pod ReadinessProbe failureThreshold × period @retry バックオフ 1s → 2s → 4s = max 7s Probe timeout 以内に収めること! Circuit Breaker tenacity / Istio ⚠ 注意: @retry の合計待機時間(最大7秒)は K8s ReadinessProbe の failureThreshold × periodSeconds より短く設定すること

モデル解答

import time
import functools
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def retry(max_retries: int = 3, initial_wait: float = 1.0, exceptions: tuple = (Exception,)):
    """
    指数バックオフ付きリトライデコレータ

    Args:
        max_retries: 最大リトライ回数
        initial_wait: 初回待機秒数(以降 2倍ずつ増加)
        exceptions: リトライ対象の例外タプル
    """
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_retries + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_retries:
                        wait = initial_wait * (2 ** attempt)
                        logger.warning(f"Retry {attempt + 1}/{max_retries}: {e} (waiting {wait:.1f}s)")
                        time.sleep(wait)
                    else:
                        logger.error(f"Max retries ({max_retries}) exceeded: {e}")
            raise last_exception
        return wrapper
    return decorator

# 使用例
@retry(max_retries=3, initial_wait=0.1, exceptions=(ConnectionError, TimeoutError))
def call_flaky_api():
    import random
    if random.random() < 0.7:
        raise ConnectionError("API timeout")
    return "success"
# === ステージ 1: ビルド ===
FROM python:3.12-slim AS builder

WORKDIR /app

# 依存関係をインストール(キャッシュ最適化: requirements.txt を先にコピー)
COPY requirements.txt .

# 仮想環境にインストール(本番イメージにコピーするため)
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt

# === ステージ 2: 本番 ===
FROM python:3.12-slim AS production

# セキュリティ: 非 root ユーザーを作成
RUN useradd --no-create-home --shell /bin/false appuser

WORKDIR /app

# ビルドステージから仮想環境のみコピー(pip, gcc は不要)
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# アプリケーションコードをコピー
COPY --chown=appuser:appuser main.py .

USER appuser

EXPOSE 8000

# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
    || exit 1

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Dockerfile テクニック解説

テクニック理由
マルチステージビルドbuilder に gcc/pip が残るが production イメージには含めない
python -m venv /opt/venvシステムの Python を汚さず、コピー可能な形で依存関係を隔離
COPY requirements.txt を先にコードが変わっても依存関係レイヤーをキャッシュ利用できる
--no-cache-dirpip のキャッシュをイメージに含めない(サイズ削減)
非 root ユーザーコンテナ脱出時の被害を最小化
--chown=appuserコピー時に所有者を変更(RUN chown より効率的)

複合問題: A × B 統合

問い: K8s上のFastAPIサービスに @retry デコレータを導入しようとしている。以下の観点から問題点を指摘せよ。
1. リトライとKubernetesのProbe(LivenessProbe/ReadinessProbe)の競合
2. タイムアウト設定の重要性
✗ 問題のある設定
# max_retries=3, initial_wait=1.0 の場合
# 合計待機: 1s + 2s + 4s = 7秒

# もし ReadinessProbe が
# periodSeconds=5, failureThreshold=1 なら
# → Probe timeout (5s) < retry timeout (7s)
# → Pod が unhealthy 判定されてしまう!
✓ 正しい設計
# retry の合計タイムアウトを
# ReadinessProbe より短く設定
@retry(max_retries=2, initial_wait=0.5)
# 合計: 0.5s + 1.0s = 1.5秒

# ReadinessProbe:
# periodSeconds=10, failureThreshold=3
# → 30秒以内に回復すればOK

# Circuit Breaker(tenacity/Istio)と
# 組み合わせて Thundering Herd 防止
1 Probe との競合
@retry(max_retries=3, initial_wait=1.0) は最大 1+2+4=7秒の待機を発生させる。K8s の ReadinessProbe の failureThreshold × periodSeconds 以内にリトライが完了しない場合、Pod が unhealthy と判定されて Traffic が切られる。
対策: retry の合計タイムアウトを ReadinessProbe のタイムアウトより短く設定する。
2 タイムアウトの重要性
リトライは「一時的な障害」に対して有効だが、下流サービスがダウンしている場合はリトライが逆効果(Thundering Herd問題)。
対策: Circuit Breaker パターン(tenacity ライブラリや Istio のサービスメッシュ機能)と組み合わせる。

ポイント解説

1 @functools.wraps(func): デコレータ適用後も func.__name____doc__ を保持する。デバッグ時にラッパー関数名ではなく元の関数名が見える。
2 指数バックオフ: initial_wait * 2^attempt → 1s → 2s → 4s。フラットなリトライより下流サービスへの負荷を分散できる。
3 exceptions タプルで対象例外を絞る: ConnectionError のみリトライ、ValueError はリトライしないなど、ビジネスロジックに合わせた細かい制御が可能。

自己評価

自分の回答

気づき・メモ