本番稼働中のシステムに新機能を安全にデプロイするには、環境分離が不可欠です。しかし個人開発でProduction / Pre-Production / Developmentの3環境を維持するのは、チーム開発に比べてオーバーヘッドが大きいです。
本記事では、macOS上で3ワークスペースを構築し、1人で運用している実践例を記録します。特に「Pre環境でDry-Run検証してから本番に適用する」フローが、AIが生成したコードの品質担保にどう効いたかを説明します。
なぜ3環境が必要になったか
きっかけ:新機能をそのまま本番に入れて16日間気づかなかった事故
ある機能をClaude Codeで実装し、テストを通して本番にデプロイしました。CIはPASSしていました。しかし、launchd(macOSの定時実行デーモン)経由で起動したとき、環境変数の未設定により機能が静かに無効化されていました。
1本番デプロイ: 03-082問題発覚: 03-243無効化期間: 16日間エラーは出ません。ログにも異常は記録されません。しかし機能は動いていません。テストでは「APIキーがある前提」で書かれており、launchd環境でAPIキーが存在しないケースをカバーしていませんでした。
この事故で「テストとCIだけでは不十分。本番と同じ条件でDry-Runする環境が必要」と確信しました。
3ワークスペースの設計
全体構成
1Production(本番):2 パス: /Users/htada/data-processing3 データ: /Volumes/work2/data-processing_data/_tmp(外付SSD)4 DB: data_processing5 用途: 実際にAPIを叩いてリクエストを送る6
7Pre(プリプロダクション):8 パス: /Users/htada/data-processing-pre9 データ: 本番と共有(読み取り専用)10 DB: data_processing_pre11 用途: 本番と同じデータでDry-Run。リクエストは送らない12
13Dev(開発):14 パス: /Volumes/work/data-processing15 データ: /Volumes/work/data-processing/_tmp2 collapsed lines
16 DB: なし(テスト用SQLite)17 用途: コーディング・テスト・検証重要な設計判断
1. Pre環境は本番のデータを「読み取り専用」で参照する
Pre環境に別のデータセットを用意すると、「Pre環境では動くが本番では動かない」問題が起きます。本番と全く同じデータを使うことで、この乖離をゼロにします。
1本番の分析プログラムが出力した SIM Parquet ファイル2 ↓ シンボリックリンクで共有3Pre環境の処理エンジンが読み込み4 ↓ Dry-Runモードで処理5リクエストAPIは呼ばない(ログ出力のみ)2. Pre環境のlaunchdジョブは本番+5分のオフセット
1<!-- 本番: 毎時00分に実行 -->2<key>Minute</key><integer>0</integer>3
4<!-- Pre: 毎時05分に実行 -->5<key>Minute</key><integer>5</integer>本番と同じスケジュールで、5分遅れて実行します。これにより:
- 本番データが生成された後にPre環境が読み込む(データ依存関係の確保)
- ログが時系列で比較可能(同じデータ状況での本番 vs Pre)
3. DBは完全分離
1本番: PostgreSQL database = data_processing2Pre: PostgreSQL database = data_processing_prePre環境が本番DBに書き込む事故を防ぐため、データベースは完全に分離します。接続文字列は環境変数で切り替えます。
plistファイル管理の教訓
launchdのplist(定時実行設定)とは
macOSで定時実行するには、~/Library/LaunchAgents/にplistファイル(XML形式の設定ファイル)を置きます。1ジョブ = 1 plistファイルです。
現在、本番で約77ジョブ、Pre環境で13ジョブが稼働しています。合計90のplistファイルを管理しています。
plist 30ファイル破損事故
plistファイルを自動生成するスクリプトを書きましたが、plistlib.dump()(Python標準ライブラリのplist出力関数)ではなくjson.dump()を使ってしまいました。
1# ❌ JSON形式で出力(plistではない)2with open(plist_path, "w") as f:3 json.dump(config, f, indent=2)4
5# ✅ 正しいplist形式で出力6import plistlib7with open(plist_path, "wb") as f:8 plistlib.dump(config, f)JSON形式のファイルは有効なplistではありません。しかし、launchdはキャッシュからジョブを実行するため、壊れたplistでもlaunchctl listにジョブが表示され、問題に気づきませんでした。
macOSを再起動すると、キャッシュがクリアされて全ジョブが消失するリスクがありました。
再発防止策:
1# plist生成後に必ず構文検証2plutil -lint ~/Library/LaunchAgents/jp.dataprocessing.*.plistCLAUDE.mdへの反映
この事故を受けて、CLAUDE.mdに以下のルールを追加しました。
1#### plist 生成時の形式ルール ⚠️2plist ファイルの生成は plistlib.dump() のみ使用すること。3json.dump() や手動文字列生成は禁止。AIがplist生成コードを書く際、このルールがあることでjson.dump()を使う実装を事前に防げます。
Pre環境でのDry-Run検証フロー
新機能デプロイの標準フロー
11. Dev環境で実装 + テスト(CI PASS確認)2 ↓32. feature/* ブランチを pre ブランチにマージ4 ↓53. Pre環境のlaunchdジョブに新plistを登録6 ↓74. 3段階検証:8 a. plutil -lint <plist> → XML構文OK9 b. 手動実行 → ログ確認 → 初期化成功10 c. 翌日 validate_launchd_health.sh → スケジュール発火確認11 ↓125. 1〜4週間のDry-Run運用13 ↓146. 統計的Go/No-Go判断(30処理以上のサンプル)15 ↓1 collapsed line
167. GOなら本番に適用3段階検証が必要な理由
1段階目(plutil)だけでは不十分です。plistの構文は正しくても、実行時にModuleNotFoundErrorが出ることがあります。
2段階目(手動実行)だけでも不十分です。手動実行時は環境変数が設定されていても、launchdスケジュール実行時には設定されていない場合があります。
3段階目(翌日のログ鮮度確認)で、「スケジュールが実際に発火したか」を確認します。launchctl listにジョブが表示されていても、スケジュールが発火しないケースがあります(plistキャッシュの問題)。
validate_launchd_health.sh:ヘルスチェックスクリプト
90のplistファイルを手動で確認するのは現実的ではありません。自動ヘルスチェックスクリプトを作成しました。
1#!/bin/bash2# 1. plist構文検証3for plist in ~/Library/LaunchAgents/jp.dataprocessing.*.plist; do4 plutil -lint "$plist" || echo "❌ INVALID: $plist"5done6
7# 2. ログ鮮度チェック(最終実行から閾値時間以内か)8check_log_freshness() {9 local log_file=$110 local threshold_hours=$211 local last_modified=$(stat -f "%m" "$log_file" 2>/dev/null || echo 0)12 local now=$(date +%s)13 local age_hours=$(( (now - last_modified) / 3600 ))12 collapsed lines
14
15 if [ $age_hours -gt $threshold_hours ]; then16 echo "❌ STALE: $log_file (${age_hours}h > ${threshold_hours}h)"17 fi18}19
20# hourlyジョブは3時間、dailyジョブは28時間が閾値21check_log_freshness "/tmp/engine_a_hourly/stdout.log" 322check_log_freshness "/tmp/engine_b_daily/stdout.log" 2823
24# 3. エラーパターン検出25grep -l "ModuleNotFoundError\|ImportError\|OSError" /tmp/engine_a_hourly/*.logこのスクリプトはlaunchdで毎朝06:30に自動実行し、異常があればSlack通知します。
AIが生成したコードの「環境依存バグ」
Dev環境では動くがPre/本番で動かないパターン
Claude Codeが生成するコードは、Dev環境(ターミナルから手動実行)では問題なく動きます。しかしlaunchd経由の実行では以下の違いがあります。
| 項目 | Dev(手動実行) | Pre/本番(launchd) |
|---|---|---|
| 環境変数 | シェルプロファイルから読み込み | plistのEnvironmentVariablesのみ |
| カレントディレクトリ | 任意 | / |
| PATH | フル設定 | 最小限 |
| .envファイル | load_dotenv()で読み込み可 | 明示的にパスを指定しないと読めない |
AIは「ターミナルから実行する前提」でコードを書きます。os.getenv("API_KEY")がNoneを返す状況を想定していないことが多いです。
Pre環境で発見できた具体例
1# AIが書いたコード2api_key = os.getenv("API_KEY")3client = APIClient(api_key) # api_key=None でも初期化は成功する4result = client.fetch_data() # ここで初めてエラー(認証失敗)5
6# しかし fetch_data() のエラーハンドリングが7# Exception をキャッチして None を返す実装だったため、8# エラーが表面化せず、機能が静かに無効化されたPre環境でDry-Runすることで、この「静かな無効化」を本番適用前に発見できました。
学んだこと
1. テストが通るだけでは不十分
CIが緑でも、launchd環境での動作は保証されません。Pre環境でのDry-Runが「最後の防波堤」になります。
2. 個人開発でも3環境は維持するべき
「1人だから2環境で十分」と思っていましたが、Pre環境なしでは「本番でいきなり動かして祈る」になります。3環境の維持コスト(plist管理・DB分離)よりも、本番事故の復旧コストの方がはるかに大きいです。
3. ヘルスチェックスクリプトは投資ではなく保険
90ジョブを手動確認するのは不可能です。自動ヘルスチェックは「確認を自動化する」のではなく「確認しなくても問題に気づける」仕組みです。
4. AIへの指示にlaunchd制約を明記する
CLAUDE.mdに「launchd環境ではシェルプロファイルを読まない」と書いておくことで、AIがos.getenv()を使うコードを書く際にload_dotenv()を追加するようになりました。
まとめ
3ワークスペース運用で重要なのは以下の3点です。
- Pre環境は本番データを共有: データの乖離をゼロにし、Dry-Runの信頼性を最大化
- 3段階検証: plutil構文 → 手動実行 → 翌日ログ鮮度。1段階では足りない
- ヘルスチェックの自動化: 90ジョブの健全性を毎朝自動確認。異常はSlack通知
「AIが書いたコードは正しいか」を検証する最も確実な方法は、本番と同じ条件で動かすことです。Pre環境はその「同じ条件」を安全に提供します。