45395 - シコウサクゴ -

鮮度閾値が20h→24h→25hと膨張する問題:YAML駆動でハードコード二重管理を統一する

データパイプラインの監視で「最終更新から N 時間以上経過したら警告」という鮮度チェックを、複数のジョブやダッシュボードで使っていました。

気づくと、同じ閾値が 3 つの場所で別々の値になっていました。20h24h25h。どれが正しいのか誰も覚えていません。これを YAML 一元管理に移行した記録です。

現象:閾値が静かに膨れ上がる

散らばっていた閾値

monitor/freshness.py
1
WARN_HOURS = 20 # 何ヶ月も前にこう書いた
2
3
# dashboard/widgets/freshness.py
4
THRESHOLD_HOURS = 24 # ダッシュボード側ではこの値
5
6
# scripts/check_pipeline.sh
7
MAX_AGE_H=25 # ヘルスチェックスクリプト

3 箇所で 202425 という別の数字が並んでいました。

なぜ膨らんだのか

ログを git で遡ると、こんな経緯でした。

  1. 最初は 20h で統一していた
  2. 連休明けにジョブの遅延で警告が出た → ダッシュボードだけ 24h に緩めた
  3. ヘルスチェックスクリプトが「夜間バッチの遅れ」で誤検知 → 25h
  4. monitor 側だけ 20h のまま放置

問題が起きるたびに「目の前のコード」だけ緩める動きが続いた結果、3 つの閾値がバラバラになりました。

緩めた側が勝つ

複数箇所で同じ概念の閾値を扱うとき、「一番緩い」値が実質的な閾値になります。25h で許容しているスクリプトがある一方で、20h で警告が飛ぶ monitor がある。20h を見て対応した人が「これ、ダッシュボードでは正常って出てるけど?」と混乱します。

解決の方針:YAML に一元化

設計

1 つの YAML を single source of truth とし、Python と bash の両方から読む。

config/freshness.yml
1
defaults:
2
warn_hours: 24
3
critical_hours: 48
4
5
pipelines:
6
daily_aggregation:
7
warn_hours: 24
8
critical_hours: 36
9
hourly_ingestion:
10
warn_hours: 2
11
critical_hours: 6
12
weekly_report:
13
warn_hours: 168 # 7日
14
critical_hours: 240

注目点: ジョブごとに別の閾値が必要なのを認めて、defaults + ジョブ別オーバーライドの構造にします。「全ジョブ統一」を強制すると、また別の場所で緩める動きが復活します。

Python 側のローダ

1
from pathlib import Path
2
import yaml
3
from dataclasses import dataclass
4
5
@dataclass(frozen=True)
6
class FreshnessThreshold:
7
warn_hours: float
8
critical_hours: float
9
10
class FreshnessConfig:
11
def __init__(self, path: Path):
12
with path.open() as f:
13
data = yaml.safe_load(f)
14
self._defaults = data["defaults"]
15
self._pipelines = data.get("pipelines", {})
7 collapsed lines
16
17
def get(self, pipeline_name: str) -> FreshnessThreshold:
18
cfg = {**self._defaults, **self._pipelines.get(pipeline_name, {})}
19
return FreshnessThreshold(
20
warn_hours=cfg["warn_hours"],
21
critical_hours=cfg["critical_hours"],
22
)

bash 側でも同じ YAML を読む

bash で YAML を扱うのは面倒ですが、yq を使えばワンライナーで取れます。

Terminal window
1
WARN_H=$(yq ".pipelines.daily_aggregation.warn_hours // .defaults.warn_hours" config/freshness.yml)

// は yq の null 合体演算子で、ジョブ別設定がなければ defaults にフォールバックします。

移行で踏んだ罠

罠 1: 隠れたデフォルト値が残る

1
def check_freshness(pipeline: str, max_age_h: float = 24.0):
2
...

YAML を読むコードに置き換えたつもりでも、呼び出し側が引数を渡し忘れると関数定義のデフォルト値が使われます。「YAML を変えたのに反映されない」事故です。

修正: デフォルト値を排除し、明示渡しを強制する。

1
def check_freshness(pipeline: str, max_age_h: float):
2
...
3
4
# 呼び出し側
5
threshold = config.get("daily_aggregation")
6
check_freshness("daily_aggregation", max_age_h=threshold.warn_hours)

引数を必須にすると、YAML 経由でしか値を渡せなくなります。

罠 2: 環境ごとに別の YAML を読む

開発環境では config/freshness.dev.yml、本番では config/freshness.prod.yml。これも前と同じ問題で、dev だけ緩めて本番に反映し忘れる動きが復活しました。

修正: 環境ごとの差分を overrides で表現し、共通定義は 1 ファイルに集約する。

config/freshness.yml
1
defaults:
2
warn_hours: 24
3
4
env_overrides:
5
dev:
6
warn_hours: 168 # dev は緩く
7
prod: {} # 本番は defaults のまま
1
ENV = os.getenv("APP_ENV", "prod")
2
overrides = data.get("env_overrides", {}).get(ENV, {})
3
cfg = {**data["defaults"], **overrides, **pipeline_overrides}

「本番でだけ緩めた閾値」を作りにくくする構造にします。

罠 3: bash と Python で型変換がズレる

YAML の warn_hours: 24 は yq で取り出すとそのまま 24、Python の yaml では int(24)24.5 のような小数を入れた瞬間に bash の (( ... )) が壊れます。

Terminal window
1
# OK: 整数なら問題ない
2
WARN_H=$(yq ".defaults.warn_hours" config/freshness.yml)
3
if (( elapsed > WARN_H )); then ...
4
5
# NG: 小数だと (( )) が壊れる
6
WARN_H=24.5
7
if (( elapsed > WARN_H )); then ... # syntax error

修正: bash 側では小数を扱わない。閾値は時間の整数のみとし、より細かい単位は分にする。

1
defaults:
2
warn_minutes: 1440 # 24h
3
critical_minutes: 2880

最初から「秒・分・時間のどれを単位にするか」を決めておくと、後で bash 化したときに困りません。

罠 4: YAML パースエラーで全部止まる

YAML の構文エラーで yaml.safe_load が例外を吐くと、監視自体が止まることがあります。閾値の取得元が壊れた瞬間に、最も検知してほしいエラーが見逃される最悪のパターンです。

修正: 起動時バリデーションを足す。

1
import sys
2
3
def load_config(path: Path) -> FreshnessConfig:
4
try:
5
return FreshnessConfig(path)
6
except (yaml.YAMLError, KeyError) as e:
7
# フォールバック: デフォルト値で起動するが、起動ログに必ずエラーを出す
8
sys.stderr.write(f"FATAL: failed to load {path}: {e}\n")
9
sys.stderr.write("Using emergency defaults (warn=24h, critical=48h)\n")
10
return FreshnessConfig.emergency_defaults()

「設定が壊れたから止まる」より、「壊れていることを大声で通知してデフォルトで動く」ほうが運用上は安全です。

CI で「ハードコード閾値」を禁止する

YAML 化しても、誰かが if hours > 24: のような直書きを足すと振り出しに戻ります。CI で検知します。

Terminal window
1
# pre-commit
2
forbidden_patterns=(
3
'hours > [0-9]+'
4
'WARN_HOURS = [0-9]+'
5
'MAX_AGE_H=[0-9]+'
6
)
7
8
for pattern in "${forbidden_patterns[@]}"; do
9
if grep -rE "$pattern" --include="*.py" --include="*.sh" src/; then
10
echo "ERROR: hardcoded threshold detected. Use config/freshness.yml"
11
exit 1
12
fi
13
done

完璧ではないですが、「ハードコードを書きにくい」状態を作るだけで再発がだいぶ減ります。

効果

指標BeforeAfter
閾値の定義箇所3 ヶ所(20h/24h/25h)1 ヶ所(YAML)
環境差分の管理環境ごとに別ファイル1 ファイル内で env_overrides
「閾値どこですか」議論月数回ゼロ
緩めた閾値が放置ありYAML レビューで気づける

学び

1. 同じ概念の数値は 1 ヶ所に置く

これは設定管理の基本ですが、「目の前の問題を急いで直したい」モチベーションで散らかるのが現実でした。普段からのレビューで「これ YAML に書ける?」と聞き続けるしかありません。

2. デフォルト値は「沈黙の元凶」

関数のデフォルト引数、env のフォールバック値、get() の第二引数。これら全部が「YAML を変えても反映されない」原因になります。明示渡しを強制する設計にします。

3. 設定が壊れたときの挙動を決める

yaml.safe_load の例外で監視ジョブが落ちると、本当に検知したかったエラーが見逃されます。設定エラー時の縮退動作を必ず設計に入れます。

4. CI で再発を止める

人間の規律だけでは元に戻ります。grep ベースでも CI で禁止パターンを検出する仕組みを入れると、レビュアーの認知負荷が下がります。

まとめ

「閾値が複数箇所でズレる」のは設定管理の典型的な負債で、問題が起きるたびに目の前だけ緩める動きで進行します。

YAML に統一する作業自体はたいしたことありませんが、defaults + overrides の構造、env ごとの差分表現、CI での再発防止までセットで設計しないと、半年後にまた 25h が生まれます。

「閾値はどこに書いてある?」と聞かれたら、即座に 1 ファイル名で答えられる状態にすることが、運用上の最大の利得でした。

Article title:鮮度閾値が20h→24h→25hと膨張する問題:YAML駆動でハードコード二重管理を統一する
Article author:45395
Release time:2026-04-29

記事へのご質問・ご感想をお聞かせください

フィードバックを送る