45395 - シコウサクゴ -

本番・Pre・開発の3環境を1人で運用する:AI駆動開発における環境分離の設計

2026-04-03
AI駆動開発
AI駆動開発
Claude Code
DevOps
環境分離
Dry-Run
Last updated:2026-04-05
12 Minutes
2343 Words

本番稼働中のシステムに新機能を安全にデプロイするには、環境分離が不可欠です。しかし個人開発でProduction / Pre-Production / Developmentの3環境を維持するのは、チーム開発に比べてオーバーヘッドが大きいです。

本記事では、macOS上で3ワークスペースを構築し、1人で運用している実践例を記録します。特に「Pre環境でDry-Run検証してから本番に適用する」フローが、AIが生成したコードの品質担保にどう効いたかを説明します。


なぜ3環境が必要になったか

きっかけ:新機能をそのまま本番に入れて16日間気づかなかった事故

ある機能をClaude Codeで実装し、テストを通して本番にデプロイしました。CIはPASSしていました。しかし、launchd(macOSの定時実行デーモン)経由で起動したとき、環境変数の未設定により機能が静かに無効化されていました。

1
本番デプロイ: 03-08
2
問題発覚: 03-24
3
無効化期間: 16日間

エラーは出ません。ログにも異常は記録されません。しかし機能は動いていません。テストでは「APIキーがある前提」で書かれており、launchd環境でAPIキーが存在しないケースをカバーしていませんでした。

この事故で「テストとCIだけでは不十分。本番と同じ条件でDry-Runする環境が必要」と確信しました。


3ワークスペースの設計

全体構成

1
Production(本番):
2
パス: /Users/htada/data-processing
3
データ: /Volumes/work2/data-processing_data/_tmp(外付SSD)
4
DB: data_processing
5
用途: 実際にAPIを叩いてリクエストを送る
6
7
Pre(プリプロダクション):
8
パス: /Users/htada/data-processing-pre
9
データ: 本番と共有(読み取り専用)
10
DB: data_processing_pre
11
用途: 本番と同じデータでDry-Run。リクエストは送らない
12
13
Dev(開発):
14
パス: /Volumes/work/data-processing
15
データ: /Volumes/work/data-processing/_tmp
2 collapsed lines
16
DB: なし(テスト用SQLite)
17
用途: コーディング・テスト・検証

重要な設計判断

1. Pre環境は本番のデータを「読み取り専用」で参照する

Pre環境に別のデータセットを用意すると、「Pre環境では動くが本番では動かない」問題が起きます。本番と全く同じデータを使うことで、この乖離をゼロにします。

1
本番の分析プログラムが出力した SIM Parquet ファイル
2
↓ シンボリックリンクで共有
3
Pre環境の処理エンジンが読み込み
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_processing
2
Pre: PostgreSQL database = data_processing_pre

Pre環境が本番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ではない)
2
with open(plist_path, "w") as f:
3
json.dump(config, f, indent=2)
4
5
# ✅ 正しいplist形式で出力
6
import plistlib
7
with open(plist_path, "wb") as f:
8
plistlib.dump(config, f)

JSON形式のファイルは有効なplistではありません。しかし、launchdはキャッシュからジョブを実行するため、壊れたplistでもlaunchctl listにジョブが表示され、問題に気づきませんでした。

macOSを再起動すると、キャッシュがクリアされて全ジョブが消失するリスクがありました。

再発防止策:

Terminal window
1
# plist生成後に必ず構文検証
2
plutil -lint ~/Library/LaunchAgents/jp.dataprocessing.*.plist

CLAUDE.mdへの反映

この事故を受けて、CLAUDE.mdに以下のルールを追加しました。

1
#### plist 生成時の形式ルール ⚠️
2
plist ファイルの生成は plistlib.dump() のみ使用すること。
3
json.dump() や手動文字列生成は禁止。

AIがplist生成コードを書く際、このルールがあることでjson.dump()を使う実装を事前に防げます。


Pre環境でのDry-Run検証フロー

新機能デプロイの標準フロー

1
1. Dev環境で実装 + テスト(CI PASS確認)
2
3
2. feature/* ブランチを pre ブランチにマージ
4
5
3. Pre環境のlaunchdジョブに新plistを登録
6
7
4. 3段階検証:
8
a. plutil -lint <plist> → XML構文OK
9
b. 手動実行 → ログ確認 → 初期化成功
10
c. 翌日 validate_launchd_health.sh → スケジュール発火確認
11
12
5. 1〜4週間のDry-Run運用
13
14
6. 統計的Go/No-Go判断(30処理以上のサンプル)
15
1 collapsed line
16
7. GOなら本番に適用

3段階検証が必要な理由

1段階目(plutil)だけでは不十分です。plistの構文は正しくても、実行時にModuleNotFoundErrorが出ることがあります。

2段階目(手動実行)だけでも不十分です。手動実行時は環境変数が設定されていても、launchdスケジュール実行時には設定されていない場合があります。

3段階目(翌日のログ鮮度確認)で、「スケジュールが実際に発火したか」を確認します。launchctl listにジョブが表示されていても、スケジュールが発火しないケースがあります(plistキャッシュの問題)。


validate_launchd_health.sh:ヘルスチェックスクリプト

90のplistファイルを手動で確認するのは現実的ではありません。自動ヘルスチェックスクリプトを作成しました。

validate_launchd_health.sh
1
#!/bin/bash
2
# 1. plist構文検証
3
for plist in ~/Library/LaunchAgents/jp.dataprocessing.*.plist; do
4
plutil -lint "$plist" || echo "❌ INVALID: $plist"
5
done
6
7
# 2. ログ鮮度チェック(最終実行から閾値時間以内か)
8
check_log_freshness() {
9
local log_file=$1
10
local threshold_hours=$2
11
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 ]; then
16
echo "❌ STALE: $log_file (${age_hours}h > ${threshold_hours}h)"
17
fi
18
}
19
20
# hourlyジョブは3時間、dailyジョブは28時間が閾値
21
check_log_freshness "/tmp/engine_a_hourly/stdout.log" 3
22
check_log_freshness "/tmp/engine_b_daily/stdout.log" 28
23
24
# 3. エラーパターン検出
25
grep -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が書いたコード
2
api_key = os.getenv("API_KEY")
3
client = APIClient(api_key) # api_key=None でも初期化は成功する
4
result = 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点です。

  1. Pre環境は本番データを共有: データの乖離をゼロにし、Dry-Runの信頼性を最大化
  2. 3段階検証: plutil構文 → 手動実行 → 翌日ログ鮮度。1段階では足りない
  3. ヘルスチェックの自動化: 90ジョブの健全性を毎朝自動確認。異常はSlack通知

「AIが書いたコードは正しいか」を検証する最も確実な方法は、本番と同じ条件で動かすことです。Pre環境はその「同じ条件」を安全に提供します。

Article title:本番・Pre・開発の3環境を1人で運用する:AI駆動開発における環境分離の設計
Article author:45395
Release time:2026-04-03

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

フィードバックを送る