2-4. 環境変数とシークレット - 12 Factor App と漏えい対策

所要時間: 40-50分 ゴール: 12 Factor App の Config 原則を理解し、開発と本番でシークレットを安全に投入できる コミット内容: ~/learn/infra/day11/.env.example、シークレット投入 compose、.gitignore の設定例


このレッスンのゴール

  • 12 Factor App の Config 原則を1分で説明できる
  • environmentenv_file を場面別に使い分けられる
  • 環境変数で secret を渡すリスク(ps, inspect, env dump)を語れる
  • Docker Secrets / AWS Secrets Manager / Vault の使い所を把握する
  • .env.gitignore 確実に弾く運用ができる

なぜ学ぶか(実務悩みベース)

  • 「AWS キーを GitHub に commit」→ 数分で不正利用される事故を確実に防ぐ
  • 開発・ステージング・本番で接続先を切り替える設計が必要
  • セキュリティレビューで「シークレット平文書き」を指摘されない
  • ローテーション運用が回るチームになる

前章とのつながり

2-3 で外部公開ポートを絞った。今回は 設定情報をどう安全にコンテナへ渡すか の話。ネットワーク層と認証情報層、両方を締めて初めて「本番に出せる」状態になる。


大前提: シークレット漏えいは1度で会社が傾く

「うっかり API キーを Git に commit した」「Dockerfile に DB パスワードをハードコード」これは 新人がやらかす定番事故 であり、ベテランも油断するとやる事故 でもある。

実際の事故例:

  • AWS のアクセスキーを GitHub に push → 数分で発見され、不正な EC2 が大量起動。数十万円の請求 が来る
  • Stripe の本番キーが Public リポジトリに → 偽決済を仕込まれて返金祭り
  • パスワードを DockerfileENV に書いてイメージレジストリへ push → 全社員+外部委託先が閲覧可能に

これらを防ぐには 「シークレットはコードから完全に切り離す」 という原則を最初から守ること。後から付け加えるのは非常に難しい。今日はその原則と実装パターンを学ぶ。

本日の到達点

  • 12 Factor App の III. Config を理解(環境変数で設定を渡す)
  • environmentenv_file の使い分け
  • .env.gitignore で確実に弾く
  • 環境変数で secret を渡すリスク(プロセス一覧、ログ、env dump)
  • Docker Secrets による安全な投入
  • AWS Secrets Manager / HashiCorp Vault / GitHub Actions Secrets への入口

セッション①: 12 Factor App と環境変数(25-30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/infra/day11
cd ~/learn/infra/day11
script ~/log/infra_day11.log

1. 12 Factor App の III. Config

コードに混ぜていいもの:
  - ビジネスロジック
  - 構造(テーブル、API スキーマ)
  - フレームワーク選定

コードに混ぜてはいけないもの:
  - DB 接続情報
  - API キー、シークレット
  - 環境固有の値(本番 URL、リージョン)
  - 機能フラグの状態

12 Factor App とは

2011年に Heroku が公開した、現代的な SaaS アプリの12個の設計原則。Docker、Kubernetes、サーバーレスの設計思想の 共通基盤 になっている。

原文: 12factor.net

12項目のうち、コンテナ運用で特に重要なのが III. Config:

設定 (Config) を環境変数に格納する Config はコードから厳密に分離されているべきである。 … コードに混ぜたら最後、新しいリリース毎にビルドし直さなければならず、本番と開発で同じコードが使えなくなる

なぜ環境変数かというと:

  • すべての OS / 言語 / フレームワークでサポートされている共通インターフェース
  • イメージは1つで環境ごとに変えられる(コンテナの利点を最大化)
  • 設定変更でリビルドが不要

設定 = どこに置くべきか

種類どこに置く
機密でない環境固有値LOG_LEVEL=infoPORT=8080環境変数(.env、compose environment
機密値(DB パスワード、APIキー)STRIPE_API_KEY=sk_live_...Secret 管理ツール(後述)
ビジネスロジックバリデーションルール、計算ロジックコードに書く
機能フラグA/B テストの分岐機能フラグサービス(LaunchDarkly 等)

「環境変数に何でも入れる」のも雑。機密 vs 非機密 で分けるのが基本。

2. environment と env_file

services:
  api:
    image: myapi:1.0
    # 方法 A: 直接書く
    environment:
      LOG_LEVEL: info
      PORT: "8080"
      DATABASE_URL: "app:apppass@tcp(db:3306)/app"
 
    # 方法 B: ファイルから
    env_file:
      - .env
      - .env.production
 
    # 方法 C: ホストの環境変数を渡す
    environment:
      STRIPE_KEY: ${STRIPE_KEY}     # シェルの環境変数を参照

environment vs env_file の使い分け

方法メリットデメリット使い所
environment 直書きYAML 1枚で完結、見やすいシークレットを書くと git に commit される非機密な設定のみ
env_fileシークレットを別ファイルへ分離できるファイルの所在管理が必要開発環境のシークレット
${VAR} 補間CI / シェルから動的注入シェルの状態に依存CI / 本番デプロイ

実務での組み合わせ

environment:
  LOG_LEVEL: info                 # 非機密、直書き
  DATABASE_URL: ${DATABASE_URL}   # シェルから注入
env_file:
  - .env.local                    # 開発時のローカルオーバーライド

Compose の変数補間の仕組み

${VAR}Compose が YAML を読む時に展開する

services:
  api:
    image: myapi:${TAG:-latest}     # TAG が未設定なら "latest" を使う
    environment:
      DATABASE_URL: ${DATABASE_URL}
      LOG_LEVEL: ${LOG_LEVEL:-info}  # デフォルト値

変数の取得元(優先順):

  1. シェル環境変数(export DATABASE_URL=...
  2. .env ファイル(プロジェクトルートの .env を自動で読む)

**「.env ファイルが自動で読まれる」**のは Compose の親切機能。env_file: を書かなくても、compose.yml と同じディレクトリの .env は変数補間に使われる。

3. .env と .env.example

# .env.example (リポジトリに commit する。雛形)
DATABASE_URL=app:CHANGE_ME@tcp(db:3306)/app
STRIPE_API_KEY=sk_test_xxxxx
LOG_LEVEL=info
 
# .env (個人のローカル設定。絶対 commit しない)
DATABASE_URL=app:my_real_dev_password@tcp(db:3306)/app
STRIPE_API_KEY=sk_test_51A2B3C...
LOG_LEVEL=debug
# .gitignore (絶対必須)
.env
.env.local
.env.*.local
*.pem
*.key

.env と .env.example のセット運用

.env.example: 「こういう変数を埋めてください」のテンプレート。リポジトリに含める。

.env: 個人の実際の値。Git で無視する。

開発者のオンボーディング:

git clone myrepo
cd myrepo
cp .env.example .env       # コピーして
vim .env                    # 個別に値を埋める
docker compose up -d

これにより:

  • 「どの環境変数が必要か」がコードベースで自明
  • 値の漏えいは起きない(.env は git にない)
  • チームで形式が揃う

アンチパターン: .env を git commit

git add .env
git commit -m "add env"
git push origin main

なぜ最悪か:

  • 公開リポジトリなら全世界に漏えい
  • プライベートリポジトリでも、退職した社員、外部委託先、ハッキングされた開発者 PC から流出可能
  • git の歴史から消すのは事実上不可能git filter-branch でも、すでに clone した人の手元には残る)

やってしまった場合の対応:

  1. その秘密を 即座にローテーション(漏えいした前提で)
  2. インシデント記録
  3. リポジトリのアクセスログ確認
  4. 検知のため GitGuardiangit-secrets 導入

予防策: .gitignore.env* を入れる + pre-commit hook で検査。

.gitignore は「過去には遡らない」

.gitignore.env を追加しても、既に追跡済みの .env は追跡され続ける

追跡解除:

git rm --cached .env
git commit -m "remove .env from tracking"

ただしこれでも 過去のコミットには残る。本当に漏えいしたなら必ずローテーション。

4. Dockerfile に ENV でシークレットを焼くな

# ----- NG -----
FROM golang:1.23
ENV STRIPE_API_KEY=sk_live_51A2B3C4D...    # NG
ENV DATABASE_URL=mysql://...                # NG

アンチパターン: Dockerfile ENV にシークレット

なぜNGか:

  • イメージレイヤーに 永久に書き込まれる
  • docker history myimage で全員見られる
  • イメージレジストリにあがれば、レジストリへのアクセス権を持つ全員に晒される
  • レイヤーキャッシュとして他人のビルドキャッシュに混入する可能性

正解: シークレットは 実行時に注入 する。

services:
  api:
    image: myapi:1.0
    environment:
      STRIPE_API_KEY: ${STRIPE_API_KEY}   # 起動時に注入

ビルド時にだけ必要なシークレット(プライベートリポジトリの認証情報など)は BuildKit Secrets で:

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) go build ...

これならビルド成果物にトークンは残らない。


セッション②: シークレット投入の正しいやり方(25-30分)

5. 環境変数で渡すリスク

# あるユーザーが ps で見れる
ps eww
# PID TTY      STAT   TIME COMMAND
# 123 ?        Sl     0:00 ./api STRIPE_API_KEY=sk_live_xxx DATABASE_URL=...
 
# /proc から覗ける
cat /proc/123/environ | tr '\0' '\n'
# STRIPE_API_KEY=sk_live_xxx
# DATABASE_URL=mysql://...

環境変数経由のシークレットは漏えい経路がある

「環境変数なら安全」ではない。次のルートで漏れる可能性がある:

  1. プロセス一覧: 一部の OS / 設定では ps -e で見える
  2. /proc/<PID>/environ: 同じユーザーのプロセスから読める
  3. エラーログでの全環境変数 dump: フレームワークがクラッシュ時に env を吐く実装がある(Rails の RAILS_ENV=production でも本番事故が起きる)
  4. コアダンプ: クラッシュ時のメモリダンプに残る
  5. docker inspect <container>: コンテナの env がすべて表示される
  6. CI ログ: ビルドログにうっかり出力

対策の方向

  • 環境変数より ファイル経由/run/secrets/... を読む)の方が安全
  • 環境変数を使うなら、エラーログの環境変数フィルタを必ず入れる
  • シークレットは短期トークン化してローテーション

6. Docker Secrets

services:
  api:
    image: myapi:1.0
    secrets:
      - db_password
      - stripe_key
    environment:
      # シークレットを指すファイル名を渡す
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
      STRIPE_KEY_FILE: /run/secrets/stripe_key
 
secrets:
  db_password:
    file: ./secrets/db_password.txt
  stripe_key:
    file: ./secrets/stripe_key.txt
// アプリ側で _FILE 経由で読み込む
func mustLoadSecret(envKey string) string {
    if path := os.Getenv(envKey + "_FILE"); path != "" {
        b, err := os.ReadFile(path)
        if err != nil {
            log.Fatalf("read secret: %v", err)
        }
        return strings.TrimSpace(string(b))
    }
    return os.Getenv(envKey)   // フォールバック
}
 
dbPass := mustLoadSecret("DATABASE_PASSWORD")

Docker Secrets の本質

シークレットを ファイルとして コンテナ内の /run/secrets/ にマウントする仕組み。環境変数経由より安全な理由:

  • プロセス環境変数に出てこない → psenv dump で漏れない
  • tmpfs(メモリ上のファイルシステム)にマウントされる → ディスクに残らない
  • ファイル権限を絞れる → 他ユーザーから読めない

歴史的経緯: もともと Docker Swarm のための機能。Swarm モードでないと暗号化された秘密管理にはならない。素の Compose(v2)で書いても、内部的には bind mount に近い扱い。

厳密なシークレット管理が必要なら Kubernetes Secrets + 外部 Secret 管理(External Secrets Operator)か AWS Secrets Manager 直接連携。

_FILE パターンは公式イメージで広く採用

MySQL、PostgreSQL、Redis、Elasticsearch などの公式イメージは <VAR>_FILE 環境変数で値の場所を指定する パターンに対応している。

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    secrets:
      - db_root_password
 
secrets:
  db_root_password:
    file: ./secrets/db_root_password.txt

これで MYSQL_ROOT_PASSWORD を環境変数として渡さなくて済む。エントリーポイントスクリプトがファイルから読んで使ってくれる。

7. 本番のシークレット管理

開発              ステージング           本番
─────────         ────────────         ────────
.env ファイル      CI Secrets           Secrets Manager
                                       Vault
                                       KMS

本番のシークレット管理の選択肢

サービス提供元特徴
AWS Secrets ManagerAWS自動ローテーション、IAM 連携
AWS Systems Manager Parameter StoreAWS安価、KMS で暗号化
GCP Secret ManagerGCP自動レプリケーション
Azure Key VaultAzureHSM 対応
HashiCorp VaultOSS / HCPクラウド非依存、動的シークレット
DopplerSaaS多環境一元管理 UI
1Password / Bitwarden Secrets ManagerSaaS開発者体験重視

基本パターン: シークレットマネージャから起動時に取得 → アプリのメモリに保持 → 定期的にリフレッシュ。

AWS なら IAM Role でアクセス → コードにキーすら持たせない。

GitHub Actions Secrets

CI/CD でシークレットを扱う最も身近な仕組み。

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
        run: ./scripts/deploy.sh
  • GitHub のリポジトリ設定 > Secrets で登録
  • workflow ログには *** でマスクされる(ベストエフォート)
  • 環境ごとに「Environment Secrets」を分けられる(staging / production)

注意: secrets は workflow の echocat で出力すると そのままログに残る(マスクされない場合もある)。echo $SECRET でデバッグするのは絶対 NG。

8. シークレットローテーション

ローテーション戦略

「シークレットは漏えいする前提」で運用する。1度漏れても被害が最小限になるように。

ローテーションの自動化

  • AWS Secrets Manager: Lambda で自動ローテーション(RDS のパスワード等)
  • HashiCorp Vault: 動的シークレット(DB に都度ユーザーを作成)
  • GitHub Actions: 定期的に gh secret set を走らせる

アプリの対応

  • シークレットの変更を検知して再読込(SIGHUP で reload する Unix の流儀)
  • 短命トークンを使う(OAuth2 の access token + refresh token)
  • 起動時だけでなく 稼働中も 最新の値を取りに行く設計

9. 実例: 開発と本番でシークレットを切り替える

ディレクトリ:

~/learn/infra/day11/
├── compose.yml              # 共通
├── compose.override.yml     # 開発用(git管理)
├── compose.prod.yml         # 本番用(git管理)
├── .env.example             # 雛形(git管理)
├── .env                     # 開発の実値(gitignore)
├── secrets/                 # 開発のシークレット(gitignore)
│   ├── db_password.txt
│   └── stripe_key.txt
└── .gitignore

.gitignore:

.env
.env.local
.env.*.local
secrets/
*.pem
*.key

.env.example:

COMPOSE_PROJECT_NAME=myapp_dev
LOG_LEVEL=debug
DATABASE_URL=app:CHANGE_ME@tcp(db:3306)/app

compose.yml:

services:
  api:
    build: ./api
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
      STRIPE_KEY_FILE: /run/secrets/stripe_key
    secrets:
      - db_password
      - stripe_key
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - db_data:/var/lib/mysql
    secrets:
      - db_password
      - db_root_password
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p$$(cat /run/secrets/db_root_password)"]
      interval: 5s
      retries: 10
      start_period: 20s
 
volumes:
  db_data:
 
secrets:
  db_password:
    file: ./secrets/db_password.txt
  db_root_password:
    file: ./secrets/db_root_password.txt
  stripe_key:
    file: ./secrets/stripe_key.txt

本番では secrets:file: を外部のシークレットマネージャから生成したファイルパスに差し替える(CI が aws secretsmanager get-secret-value で取ってきて、ローカルファイルに書く → compose に渡す → 終了後に削除)。

この構成のポイント

  • シークレットは 環境変数として直接渡さない_FILE で示す)
  • 開発はローカルファイル、本番は CI 経由でシークレットマネージャから取得
  • .env には機密値を入れない(非機密設定のみ)
  • .gitignoresecrets/ ディレクトリごと弾く

練習課題

mkdir -p ~/learn/infra/day11/secrets
cd ~/learn/infra/day11
script ~/log/infra_day11.log
 
# .gitignore を設定
cat > .gitignore <<'EOF'
.env
.env.local
secrets/
EOF
  1. .env.example を作成し、必要な変数を列挙
  2. cp .env.example .env でローカル設定 → 中身を埋める
  3. secrets/db_password.txt などを作成し、chmod 600 secrets/* で権限を絞る
  4. compose を組み立て、docker compose up -d で起動
  5. docker compose exec api cat /run/secrets/db_password で読めることを確認
  6. docker compose exec api envDATABASE_PASSWORD が環境変数に出ていない ことを確認
  7. git statussecrets/.env が untracked のままなことを確認
  8. exit で script 終了

考察課題

  • チームメンバーが新しくジョインしたとき、シークレットをどうやって安全に共有する?(メールは NG、Slack DM は議論の余地あり、1Password でチーム共有等)
  • 退職者が出たとき、その人がアクセスしていたシークレットをどう扱う?

締め: git で証跡を残す

cd ~/learn/infra/day11
git init -q 2>/dev/null || true
git add .gitignore .env.example compose.yml api/
# .env と secrets/ は .gitignore で除外されるので git add しても無視される(ことを確認)
git status                 # untracked に .env / secrets/ が出ていないことを確認
git commit -m "feat(infra): シークレットを Docker Secrets で投入する構成"

チェックリスト

  • 12 Factor App の Config 原則を口頭で説明できる
  • environmentenv_file を使い分けられる
  • .env.env.example のセット運用ができる
  • .gitignore.env を確実に弾ける
  • Dockerfile に ENV でシークレットを焼くと何が起きるか説明できる
  • 環境変数経由のシークレットの漏えい経路を3つ以上挙げられる
  • Docker Secrets / _FILE パターンで投入できる
  • 本番でのシークレット管理選択肢(Secrets Manager / Vault / etc.)を知っている

詰まった時のチートシート

やりたいこと方法
開発の設定をローカル分離.env を作る(自動で読まれる)
環境ごとに分ける--env-file production.env
シェル変数で上書きDATABASE_URL=xxx docker compose up
.env を git から除外.gitignore.env* を追加
ENV を確認docker compose exec <svc> env
シークレットファイルを確認docker compose exec <svc> ls /run/secrets/
設定を YAML に展開して確認docker compose config

やらかし事例: シークレット漏えいの定番

事例1: .env を git に commit

.gitignore に書き忘れて commit → push。履歴に永遠に残るgit filter-repo で削除しても他のクローンに残る可能性大。ローテーション必須。

事例2: Dockerfile に ENV PASSWORD=xxx

イメージレジストリに push → docker history で誰でも見える。

事例3: docker inspect で素通り

environment で渡したパスワードは docker inspect <container> で平文で出る。プロセス一覧 ps -ef にも漏れることがある。Secrets を使う。

事例4: ログにシークレットがダンプ

アプリの起動時に log.Printf("env: %v", os.Environ()) でデバッグ → 本番ログにシークレット流出。ログ整形時にマスクする習慣。

事例5: ローテーション無し運用

5年前の DB パスワードがそのまま、退職者の AWS キーが活きている。年1回のローテーション + 退職時の即時失効。

対比表: シークレット投入方式の選択

方式セキュリティ用途注意点
Dockerfile ENV× 最悪使ってはいけないイメージに焼き込まれる
environment:開発のみinspect/env dump で漏れる
env_file:開発の .envgitignore 必須
Docker Secrets本番(Swarm/Compose)tmpfs マウントで安全
_FILE パターン本番アプリ実装が必要
Vault / Secrets Manager本番、マルチクラウド運用コスト
BuildKit secret mountビルド時のみレイヤーに残らない

「実務OK」基準

  • シークレットを git に入れない癖が体に染み付く: git add .env を手が打たない
  • アプリ起動時にシークレットを動的に取得する設計ができる: _FILE パターン、Vault 連携
  • CI/CD でシークレットを安全に流せる: GitHub Actions Secrets、AWS Secrets Manager から取得
  • 漏えい時の対応が言える: 即ローテーション、インシデント記録、原因分析

自己評価チェックリスト

知識レベル

  • 12 Factor App の III. Config を語れる
  • environment と Docker Secrets の差を理解した
  • _FILE パターンの目的を即答できる
  • 漏えい時の対応フロー(ローテ・通知・原因分析)が言える

実行レベル

  • .env.gitignore で確実に弾く設定をした
  • Docker Secrets で平文を排した compose を書いた
  • git-secrets / GitGuardian を組み込んだ

メタ認知

  • 自分のリポジトリ履歴に過去のシークレットが残っていないかチェックした
  • チームのシークレット運用ルールを言語化した

さらに深掘るなら


次のレッスン

2-5_ヘルスチェック.md: HEALTHCHECK、/healthz vs /readyz、restart policy、Kubernetes の readiness/liveness probe の元祖を学ぶ。