Python .env管理で3回事故った私が、20本運用で落ち着いた型

正直に書きます。私はAPIキーをGitHubにpushしたことが、半年で2回あります。

1回目は深夜のリファクタ中で、3分後に気づいてrevertしました。2回目は気づかずに10時間放置していて、GitHubから「あなたのリポジトリに秘密鍵らしき文字列が見つかりました」というメールで知りました。地方の静かな部屋で、コーヒーが完全に冷めて、隣で寝ていた猫が「何かあった?」みたいに薄目を開けたのを覚えています。

Pythonスクリプトが20本を超えたあたりで、.env の管理は完全に破綻していました。3回事故って、ようやく自分の中で「これなら寝られる」という形に落ち着きました。今日はその地味な構成を、業務自動化基盤の足元として、コピペできる粒度で残します。

目次

なぜ20本で破綻したのか

スクリプトが3本のときは .env 1個に python-dotenv で十分でした。.gitignore.env を追加して終わり。

破綻したのは自動化が10本を超えたあたりです。freee API、Google Drive、Slack、Notion、WordPress、それぞれ本番と検証で2セット必要なものもあり、気づいたら .env は60行超え、API_KEY で grep しないと自分でも何の鍵か分からなくなっていました。

そしてある夜、検証用のつもりで本番のキーを貼り付けて実行、Slackに「本番チャンネルに10件の検証メッセージが流れています」と通知が来た瞬間、3回目の事故と気づきました。

事故1: 直書きしたAPIキーをGitHubにpushする

最初の事故は典型例です。Jupyterノートブックに直接APIキーを書き、動いた嬉しさで git add . && git commit -m "first try" && git push してしまいました。3分後、「あのキー消してない」と気づいて青ざめました。

# やってはいけない例
import requests

API_KEY = "sk-proj-Abc123XyzNeverDoThis"
res = requests.get("https://api.example.com/me", headers={"Authorization": f"Bearer {API_KEY}"})

push後にローカルから消して再commitしても、GitHub履歴には残ります。git filter-repo で履歴ごと消す手はありますが面倒です。実際には該当キーを即座にローテーションして旧キーを無効化しました。「pushする前に絶対grep」と決意した日です。

事故2: .gitignore に書き忘れた .env.local

2回目はもっと間抜けな話です。

.env.gitignore に書いていました。ところが本番と検証を分けるため .env.production.env.staging を作って、ついでに .env.local を作ったとき、.gitignore の更新を完全に忘れました。

.env.local にはローカル検証用APIキーとDBパスワードが入っていました。これがpushされて、10時間後にGitHubのシークレットスキャンから「秘密鍵らしき文字列が含まれています」とメールが届きました。

実際にやってみた対応は、こうです。

# 1. まず該当キーを即時ローテーション
# 2. ローカルのファイル名を含めて .gitignore に追加
# 3. 履歴からは消さず、キー側を全部無効化することで安全側に倒す

ここで学んだのは、.gitignore は「拡張子1個でまとめる」ことです。

# .gitignore の安全な書き方
.env
.env.*
!.env.example
*.pem
*.key
credentials/*.json
!credentials/.gitkeep

.env.* で先に全部弾いて .env.example だけ明示的に許可する。新しい .env.foobar を作っても自動的に無視されます。事故後、この3行に書き換えました。

事故3: 本番キーで検証スクリプトを叩く

3回目が一番ヒヤッとしました。

新しい自動化スクリプトで .env から SLACK_WEBHOOK_URL を読み、検証用に繋いでいるつもりで10回テスト送信したら、本番のSlackチャンネルに10件のテストメッセージが流れました。

原因は同じ変数名で本番と検証を別の .env に書き分けていたことです。読み込む .env を変えれば切り替わるつもりが、前セッションで本番を読んだままにしていました。

このとき決めたのは、変数名を環境ごとに分けることです。

# Before: 環境を分けたつもりが事故る
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

# After: 環境名を変数名に埋め込んで、誤読込を物理的に防ぐ
SLACK_WEBHOOK_URL_PRODUCTION=https://hooks.slack.com/services/...
SLACK_WEBHOOK_URL_STAGING=https://hooks.slack.com/services/...

スクリプト側で明示的に環境を選びます。

import os
from dotenv import load_dotenv

load_dotenv()

ENV = os.environ.get("APP_ENV", "staging")  # 既定はstagingで安全側に倒す
WEBHOOK = os.environ[f"SLACK_WEBHOOK_URL_{ENV.upper()}"]

「既定がstaging」が地味に効きます。APP_ENV を指定し忘れたら検証側に飛ぶので、最悪でも検証チャンネルが汚れるだけで本番に被害が出ません。この方針にしてから本番事故ゼロです。

業務自動化基盤として何を選んだか

落ち着く前に別案も3つ試しました。

direnv: Windows/PowerShellと相性が悪かった

direnv は自動で .env を読んで便利ですが、私のWindows + PowerShell環境では direnv hook pwsh の初期化が安定せず、毎回プロファイルを書き直して挫折しました。

OS資格情報マネージャ: 検証ループがだるくて挫折

Windows資格情報マネージャに1個ずつ放り込む案は安全性は最高ですが、デバッグで毎回 keyring.get_password() を叩くと、ChatGPT APIキーやClaude APIキーの差し替えに数十秒かかり、検証ループの体感が悪くて続きませんでした。

Doppler/AWS Secrets Manager: 個人副業には重すぎ

DopplerやAWS Secrets Managerは本格運用なら正解ですが、副業レベルに月額数千円やAWS課金は重すぎました。1人運用では「秒で復元」価値より月額の心理コストが勝ちました。

結論: ローカル .env 4点セット

個人レベルで現実的なのは、ローカル .env + 共通ローダ + gitフック + ヘルスチェックの4点セットでした。Windows/Macで動き、引き継ぎが .env.example 1枚で済みます。

最終的に落ち着いた構成

3回事故った末、深夜の机で猫が膝に乗ってきたタイミングで、リポジトリを今の構成に書き直しました。

project/
├── .env                      # 自分のローカル用。git管理外
├── .env.example              # キー名だけ書いた見本。git管理内
├── .gitignore                # .env.* を全部弾く
├── credentials/
│   ├── .gitkeep              # 空ディレクトリ維持用
│   ├── google_oauth.json     # git管理外
│   └── freee_token.json      # git管理外
└── tools/
    ├── env_loader.py         # 共通の読み込みモジュール
    └── *.py                  # 各種スクリプト

.env.example にはキー名と説明だけを書きます。値は絶対入れません。

# .env.example
APP_ENV=staging

# Slack (production / staging で別webhook)
SLACK_WEBHOOK_URL_PRODUCTION=
SLACK_WEBHOOK_URL_STAGING=

# Notion API
NOTION_TOKEN=
NOTION_DATABASE_ID=

# WordPress (tomori.codes 用 アプリケーションパスワード)
WP_TOMORI_URL=
WP_TOMORI_USER=
WP_TOMORI_APP_PASSWORD=

# AI API (記事生成や自動レビュー用)
ANTHROPIC_API_KEY=
OPENAI_API_KEY=

新しいスクリプトを書く人(主に未来の自分)は、.env.example をコピーして .env を作り値を埋めます。キー名は共有、値は各人が自分の端末で管理する、というシンプルなルールです。

共通の読み込みモジュール

20本のスクリプトに毎回 load_dotenv() を書くと書き忘れが必ず1本出ます。共通モジュールを作り、そこからしか環境変数を読まないことにしました。

# tools/env_loader.py
"""tomori.codes 共通環境変数ローダ。

使い方:
    from env_loader import get_env, get_env_for_app
    token = get_env("NOTION_TOKEN")
    webhook = get_env_for_app("SLACK_WEBHOOK_URL")
"""
from __future__ import annotations

import os
from pathlib import Path

from dotenv import load_dotenv


_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_ENV_PATH = _PROJECT_ROOT / ".env"

# 一度だけ読み込む。再import時に既存環境変数を上書きしないでもよい
load_dotenv(_ENV_PATH, override=False)


def get_env(key: str, default: str | None = None, required: bool = True) -> str:
    """環境変数を取得する。requiredでTrue指定時、未設定なら例外。"""
    value = os.environ.get(key, default)
    if required and (value is None or value == ""):
        raise RuntimeError(
            f"環境変数 {key} が未設定です。.env.example を参照して .env に追加してください。"
        )
    return value or ""


def get_env_for_app(base_key: str, app_env: str | None = None) -> str:
    """環境名サフィックス付きで取得する (例: SLACK_WEBHOOK_URL → SLACK_WEBHOOK_URL_STAGING)."""
    env_name = (app_env or os.environ.get("APP_ENV") or "staging").upper()
    suffixed = f"{base_key}_{env_name}"
    return get_env(suffixed)

各スクリプトはこう書きます。

# tools/notify_slack.py
from env_loader import get_env_for_app
import requests


def notify(text: str) -> None:
    url = get_env_for_app("SLACK_WEBHOOK_URL")
    res = requests.post(url, json={"text": text}, timeout=10)
    res.raise_for_status()


if __name__ == "__main__":
    notify("自動化スクリプト動作確認")

このモジュールにしてから、私のスクリプトには os.environ.get("SLACK_WEBHOOK_URL") のような直書きが1つも残らなくなりました。grepで os.environ.get を検索して、env_loader 経由に置き換えるだけで、20本の整理が90分で終わりました。ChatGPT APIキー、Claude APIキー、WordPressアプリケーションパスワードといった「漏れたら本当にまずいやつ」も、ここを通せば1か所で扱えます。

push前の自動チェックを仕込む

人間は何度でも同じ失敗をするので機械に見張ってもらいます。.git/hooks/pre-commit に簡単なシェルを置きました。

#!/usr/bin/env bash
# .git/hooks/pre-commit
# .env のうっかりcommitを物理的にブロックする

if git diff --cached --name-only | grep -E '(^|/)\.env(\..+)?$' | grep -v '\.env\.example$' > /dev/null; then
    echo "ERROR: .env がステージされています。コミットを中止しました。"
    git diff --cached --name-only | grep -E '(^|/)\.env(\..+)?$'
    exit 1
fi

# APIキーっぽい文字列の混入チェック (簡易版)
if git diff --cached -U0 | grep -E '(api[_-]?key|secret|token|password)["\x27]?\s*[:=]\s*["\x27][A-Za-z0-9_\-]{20,}' > /dev/null; then
    echo "ERROR: APIキー/シークレットらしき文字列が検出されました。"
    echo "本当に問題なければ git commit --no-verify でバイパス可能です。"
    exit 1
fi

exit 0

このフックを入れてから3回 git commit を止められました。3回とも今思えば事故になっていた変更です。30行のシェルで防げる事故なら、書いておく方が安いと思います。

なお git commit --no-verify でバイパスできるので意図的なときは通せます。ただし「無効化したい」瞬間は、だいたい間違ったコミットをしようとしている瞬間でもあります。経験上99%は、その時点で一旦止まれます。

起動前ヘルスチェック

最後に .env が正しくセットされているかを起動前に確認するヘルスチェックを置いています。

# tools/check_env.py
"""起動前ヘルスチェック。全スクリプトが必要とする環境変数を一覧表示する。"""
from __future__ import annotations

import sys

from env_loader import get_env


REQUIRED_KEYS = {
    "APP_ENV": "stagingまたはproductionを指定",
    "NOTION_TOKEN": "Notion APIトークン",
    "NOTION_DATABASE_ID": "投入先のデータベースID",
    "WP_TOMORI_URL": "WordPressサイトURL",
    "WP_TOMORI_USER": "WordPress管理ユーザー名",
    "WP_TOMORI_APP_PASSWORD": "アプリケーションパスワード",
}

ENV_SUFFIXED_KEYS = {
    "SLACK_WEBHOOK_URL": "Slack webhook (環境ごと)",
}


def main() -> int:
    failed = 0
    print("=== 必須環境変数チェック ===")
    for key, desc in REQUIRED_KEYS.items():
        try:
            value = get_env(key)
            shown = value[:4] + "***" if len(value) > 4 else "***"
            print(f"  OK   {key:30s} = {shown}  ({desc})")
        except RuntimeError as exc:
            failed += 1
            print(f"  NG   {key:30s} 未設定  ({desc})")

    app_env = (get_env("APP_ENV", "staging", required=False) or "staging").upper()
    print(f"=== 環境別変数チェック (APP_ENV={app_env}) ===")
    for base_key, desc in ENV_SUFFIXED_KEYS.items():
        full_key = f"{base_key}_{app_env}"
        try:
            value = get_env(full_key)
            shown = value[:10] + "***"
            print(f"  OK   {full_key:38s} = {shown}  ({desc})")
        except RuntimeError as exc:
            failed += 1
            print(f"  NG   {full_key:38s} 未設定  ({desc})")

    if failed:
        print(f"\n失敗: {failed}件。.env.example を参照して .env を更新してください。")
        return 1
    print("\n全項目OK。")
    return 0


if __name__ == "__main__":
    sys.exit(main())

python tools/check_env.py で20本のスクリプトが要求する全環境変数の充足を一度に確認できます。新しいPCに環境を移したとき、私は最初にこれを叩きます。最初はエラーが出ても足りないキーが一覧で見えるので、.env.example を見ながら埋めるだけ。30分の作業が3分に短縮されました。

振り返って思うこと

3回の事故を振り返ると、原因は全部「人間の油断」でした。1回目は .gitignore を忘れ、2回目はファイル名を増やしたとき .gitignore 更新を後回しにし、3回目は本番と検証の切替えを記憶に頼りました。

私は自分の記憶力をもう信用していません。だから地味に仕組みで防ぐ方を選びました。.gitignore は広めに弾く、変数名に環境名を埋める、フックでブロック、起動前にヘルスチェック。派手な技術ではありませんが、夜中に「あれpushしたっけ」と布団から起き上がる回数は確実に減りました。

業務自動化を続けるとスクリプト数は必ず増えます。3本で通用した管理は20本では破綻します。私の環境では今この構成で半年運用していて新規事故ゼロ、pre-commitフックには3回助けられました。地味な構成が深夜の安心を生みます。

まとめ

.env で事故ると、APIキーのローテーション、ログ確認、関係者への謝罪と、本来の自動化と関係ない作業が大量に発生します。

それを避けるために私は4つの仕組みを入れました。.gitignore.env.* で広く弾く。変数名に環境名を埋める。pre-commitフックでcommit前にブロック。起動前にヘルスチェックを叩く。どれも10分で書けて、20本のスクリプトの足元を40分で固められる投資です。

地方の静かな部屋で、ある夜「あれ大丈夫だったかな」と気になって眠れない経験は、もうしたくありません。地味な仕組みは、深夜の自分への手紙だと思っています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次