T-CREATOR

Prisma シークレット運用:接続文字列・証明書・ローテーションのベストプラクティス

Prisma シークレット運用:接続文字列・証明書・ローテーションのベストプラクティス

Prismaを使ったアプリケーション開発において、データベース接続文字列や証明書などのシークレット情報を適切に管理することは、セキュリティ上極めて重要です。本記事では、Prismaにおけるシークレット運用のベストプラクティスを、接続文字列の管理から証明書の取り扱い、定期的なローテーション戦略まで、実践的な観点から詳しく解説していきます。

開発環境では問題なく動いていたアプリケーションが、本番環境でセキュリティインシデントを引き起こしてしまった、という経験をお持ちの方もいらっしゃるのではないでしょうか。シークレット管理は一見地味な作業に思えるかもしれませんが、アプリケーションの安全性を支える基盤となります。

背景

Prismaにおけるシークレット管理の必要性

Prismaは、Node.jsやTypeScriptで使用される次世代のORMツールとして、多くの開発者に支持されています。データベースとの通信には接続文字列(DATABASE_URL)が必須となり、この中にはデータベースのホスト名、ポート番号、ユーザー名、パスワードといった機密情報が含まれますね。

これらの情報が漏洩すると、データベースへの不正アクセスを許してしまい、顧客データの流出やシステムの改ざんといった深刻な事態を招きかねません。

シークレット情報の種類

Prismaアプリケーションで管理すべきシークレット情報は、以下のような種類があります。

#シークレットの種類説明用途
1データベース接続文字列DATABASE_URLに含まれる認証情報Prisma Clientからの接続
2SSL/TLS証明書データベース接続の暗号化に使用通信の安全性確保
3APIキー外部サービスとの連携サードパーティAPI呼び出し
4暗号化キーデータの暗号化・復号化機密データの保護

以下の図は、Prismaアプリケーションとシークレット情報の関係性を示しています。

mermaidflowchart TB
    app["Prisma アプリケーション"]
    env["環境変数 <br/> (.env)"]
    db[("データベース <br/> (PostgreSQL/MySQL)")]
    secrets["シークレット管理サービス <br/> (AWS Secrets Manager等)"]

    app -->|"DATABASE_URL <br/> 読み取り"| env
    app -->|"暗号化接続 <br/> (SSL/TLS)"| db
    secrets -->|"シークレット <br/> 取得"| app
    env -.->|"開発環境のみ"| app

この図から、本番環境ではシークレット管理サービスを経由してシークレットを取得し、開発環境では環境変数ファイルを使用する、という使い分けが理解できます。

シークレット漏洩のリスク

シークレット情報が漏洩する主なパターンとして、以下のようなケースが挙げられます。

まず、Gitリポジトリへの誤コミットです。.envファイルをうっかりコミットしてしまい、GitHubなどの公開リポジトリに機密情報がアップロードされるケースは後を絶ちません。

次に、ログファイルへの出力です。デバッグ目的で接続文字列をログに出力してしまい、それがログ管理システムに残ってしまうパターンですね。

さらに、コンテナイメージへの埋め込みも危険です。DockerイメージにハードコードされたシークレットがDocker Hubなどで公開されると、誰でも閲覧可能になってしまいます。

課題

開発環境と本番環境の管理分離

開発チームが直面する最初の課題は、開発環境と本番環境でシークレット管理の方法を分離することです。開発環境では利便性を重視し、本番環境では厳格なセキュリティを適用する必要がありますが、この両立は容易ではありません。

多くのプロジェクトでは、全環境で.envファイルを使用してしまい、本番環境のシークレットがソースコードリポジトリに混入するリスクを抱えています。

シークレットのライフサイクル管理

シークレット情報は一度設定したら終わりではなく、定期的なローテーション(更新)が求められます。しかし、以下のような課題があります。

ローテーション作業の複雑さです。データベースパスワードを変更する際、すべてのアプリケーションインスタンスで同時に新しいパスワードに切り替える必要がありますね。

ダウンタイムの発生リスクも無視できません。ローテーション中にアプリケーションがデータベースに接続できなくなる可能性があります。

また、監査ログの不足も問題です。誰がいつシークレットを変更したのか、履歴が残らないと、セキュリティインシデント発生時の調査が困難になります。

以下の図は、シークレットのライフサイクルにおける課題を示しています。

mermaidstateDiagram-v2
    [*] --> 生成
    生成 --> 配布: シークレット作成
    配布 --> 利用中: アプリに設定
    利用中 --> ローテーション待機: 一定期間経過
    ローテーション待機 --> ローテーション実行: 更新トリガー
    ローテーション実行 --> 新シークレット配布: 新規生成
    新シークレット配布 --> 利用中: 切り替え完了
    ローテーション実行 --> エラー: 切り替え失敗
    エラー --> ローテーション実行: リトライ
    利用中 --> 失効: 漏洩検知
    失効 --> [*]

この状態遷移図から、ローテーション実行時のエラーハンドリングや、漏洩検知時の緊急失効など、複雑な状態管理が必要であることが分かります。

証明書管理の複雑さ

データベースとの通信をSSL/TLSで暗号化する場合、証明書ファイルの管理が必要になります。証明書には有効期限があり、期限切れになるとアプリケーションが動作しなくなってしまいますね。

証明書ファイルをどこに配置するか、どのように更新するか、複数環境でどう共有するかといった課題があります。

チーム内でのシークレット共有

開発チームメンバー間で、安全にシークレット情報を共有する方法も課題です。Slackやメールで送信するのは論外ですが、かといって口頭で伝えるのも非効率的ですよね。

1PasswordやBitwardenなどのパスワードマネージャーを使う方法もありますが、アプリケーションから自動的にシークレットを取得できないという問題があります。

解決策

環境変数とシークレット管理サービスの使い分け

Prismaのシークレット運用における最も基本的な解決策は、環境に応じて管理方法を使い分けることです。開発環境では.envファイルを使用し、本番環境ではAWS Secrets ManagerやGoogle Secret Manager、HashiCorp Vaultなどのシークレット管理サービスを利用します。

この戦略により、開発の効率性とセキュリティのバランスを取ることができますね。

以下の表は、環境ごとの推奨管理方法をまとめたものです。

#環境管理方法セキュリティレベルコスト
1ローカル開発.envファイル★☆☆無料
2CI/CD環境変数(暗号化)★★☆無料〜低
3ステージングシークレット管理サービス★★★低〜中
4本番環境シークレット管理サービス★★★

以下の図は、環境別のシークレット取得フローを示しています。

mermaidflowchart TD
    start["アプリケーション起動"]
    check["環境確認"]
    dev["開発環境?"]
    prod["本番環境?"]

    start --> check
    check --> dev
    dev -->|Yes| loadEnv["..envファイル読み込み"]
    dev -->|No| prod
    prod -->|Yes| fetchSecret["シークレットマネージャー<br/>からフェッチ"]
    prod -->|No| loadCI["CI環境変数読み込み"]

    loadEnv --> initPrisma["Prisma Client初期化"]
    fetchSecret --> initPrisma
    loadCI --> initPrisma
    initPrisma --> connect["データベース接続"]

この図から、起動時に環境を判定し、適切なシークレット取得方法を選択する流れが理解できます。

.envファイルの安全な運用

開発環境で.envファイルを使用する際は、以下のルールを徹底しましょう。

まず、.gitignoreに必ず.envを追加します。これにより、誤ってGitリポジトリにコミットされることを防ぎます。

次に、.env.exampleファイルをリポジトリに含めます。実際の値は含めず、キー名のみを記載したテンプレートファイルです。

さらに、本番環境の接続文字列は絶対に.envファイルに記載しないルールを設けます。

AWS Secrets Managerを使ったシークレット管理

AWS環境で運用する場合、AWS Secrets Managerは非常に強力なソリューションです。シークレットの暗号化保存、自動ローテーション、アクセス監査ログなど、エンタープライズグレードの機能を提供しますね。

Secrets Managerには以下のような利点があります。

自動ローテーション機能により、定期的にデータベースパスワードを更新できます。IAMポリシーによる細かいアクセス制御が可能で、誰がどのシークレットにアクセスできるかを厳密に管理できます。

また、CloudTrailとの統合により、すべてのシークレットアクセスが監査ログに記録されます。

バージョニング機能により、シークレットの変更履歴が保持され、必要に応じて以前のバージョンにロールバックすることも可能です。

Google Secret Managerの活用

Google Cloud Platform(GCP)を使用している場合は、Google Secret Managerが最適な選択肢になります。GCPの他のサービスとシームレスに統合され、IAMによるアクセス制御が可能です。

Secret Managerでは、シークレットのバージョン管理が自動的に行われ、各バージョンには固有の識別子が付与されます。

Docker環境でのシークレット管理

Dockerコンテナでアプリケーションを実行する場合、シークレットをイメージに埋め込んではいけません。Docker Secretsまたは環境変数として、実行時に注入する方法を採用しましょう。

Docker Composeを使う場合は、secretsセクションを活用することで、安全にシークレットを渡すことができますね。

Kubernetes環境でのシークレット管理

Kubernetes(K8s)環境では、Secretsリソースを使用してシークレットを管理します。さらに、External Secrets Operatorを導入することで、AWS Secrets ManagerやGoogle Secret Managerと連携し、一元管理が可能になります。

External Secrets Operatorは、外部のシークレット管理サービスからシークレットを自動的に同期し、KubernetesのSecretリソースとして利用可能にしてくれます。

以下の図は、Kubernetes環境でのシークレット管理フローを示しています。

mermaidflowchart LR
    eso["External Secrets<br/>Operator"]
    k8sSecret["Kubernetes<br/>Secret"]
    pod["Pod<br/>(Prismaアプリ)"]
    awsSecrets["AWS Secrets<br/>Manager"]

    awsSecrets -->|"シークレット同期"| eso
    eso -->|"Secret作成"| k8sSecret
    k8sSecret -->|"環境変数として<br/>マウント"| pod

この図から、External Secrets Operatorが外部サービスとKubernetesの橋渡しをする役割を担っていることが分かります。

SSL/TLS証明書の管理戦略

データベースとの通信を暗号化するSSL/TLS証明書も、適切に管理する必要があります。証明書ファイルは、シークレット管理サービスに保存するか、Kubernetes Secretsとして管理しましょう。

証明書の有効期限を監視し、期限切れ前に自動的に更新する仕組みを構築することが重要です。

シークレットローテーション戦略

シークレットのローテーション(定期的な更新)は、セキュリティを維持するために不可欠です。ローテーション戦略には、以下のようなアプローチがあります。

まず、スケジュールベースのローテーションです。30日、60日、90日といった一定期間ごとに自動的にシークレットを更新します。

次に、イベントベースのローテーションです。従業員の退職やセキュリティインシデント発生時など、特定のイベントをトリガーとして即座にローテーションを実行します。

ゼロダウンタイムローテーションの実現

シークレットローテーション時にアプリケーションのダウンタイムを発生させないためには、以下の手順を踏むことが重要です。

二重シークレット期間を設けます。新旧両方のシークレットが同時に有効な期間を作り、段階的に切り替えます。

すべてのアプリケーションインスタンスが新しいシークレットに切り替わったことを確認してから、旧シークレットを無効化します。

ヘルスチェックエンドポイントを実装し、各インスタンスのデータベース接続状態を監視できるようにします。

具体例

開発環境での.envファイル設定

まず、開発環境での基本的な.envファイルの設定例を見ていきましょう。以下は、PostgreSQLを使用する場合のシンプルな構成です。

env# .env (開発環境用)
DATABASE_URL="postgresql://dev_user:dev_password@localhost:5432/myapp_dev?schema=public"

このファイルは、ローカル開発環境でのみ使用され、Gitリポジトリには含めません。

次に、.gitignoreファイルに除外設定を追加します。

gitignore# .gitignore
.env
.env.local
.env.*.local

この設定により、すべての環境の.envファイルがGit管理から除外されます。

リポジトリには、テンプレートファイルとして.env.exampleを含めましょう。

env# .env.example
DATABASE_URL="postgresql://username:password@host:port/database?schema=public"
SHADOW_DATABASE_URL="postgresql://username:password@host:port/shadow_database?schema=public"

チームメンバーは、このファイルをコピーして自分の.envファイルを作成し、実際の値を設定します。

prisma/schema.prismaでの接続文字列参照

Prismaのスキーマファイルでは、環境変数から接続文字列を読み込む設定を行います。

prisma// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

このenv("DATABASE_URL")という記述により、Prismaは環境変数から接続文字列を取得します。これにより、スキーマファイル自体にはシークレット情報が含まれないため、安全にリポジトリで管理できますね。

複数のデータベース環境を使い分ける場合は、以下のように設定します。

prisma// prisma/schema.prisma (複数環境対応)

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

shadowDatabaseUrlは、Prisma Migrateがマイグレーションをテストする際に使用する一時的なデータベースです。本番データベースに影響を与えずに安全にマイグレーションを検証できます。

Node.jsアプリケーションでのdotenv使用

開発環境では、dotenvパッケージを使用して.envファイルを読み込みます。まず、パッケージをインストールしましょう。

bashyarn add dotenv

次に、アプリケーションのエントリーポイントでdotenvを初期化します。

typescript// src/index.ts

import * as dotenv from 'dotenv';

// .envファイルを読み込む(アプリケーション起動時に最初に実行)
dotenv.config();

この初期化処理は、他のモジュールをインポートする前に実行する必要があります。そうすることで、すべてのモジュールが環境変数にアクセスできるようになりますね。

Prisma Clientを初期化する際は、自動的に環境変数が使用されます。

typescript// src/db.ts

import { PrismaClient } from '@prisma/client';

// Prisma Clientのインスタンスを作成
// DATABASE_URLは自動的に環境変数から読み込まれる
export const prisma = new PrismaClient();

明示的に接続文字列を指定したい場合は、以下のように記述します。

typescript// src/db.ts (明示的な指定)

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL,
    },
  },
});

この方法では、process.env.DATABASE_URLが未定義の場合にエラーを検知しやすくなります。

AWS Secrets Managerからのシークレット取得

本番環境では、AWS Secrets Managerからシークレットを取得します。まず、必要なAWS SDKをインストールしましょう。

bashyarn add @aws-sdk/client-secrets-manager

シークレットを取得するヘルパー関数を作成します。

typescript// src/utils/secrets.ts

import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

// Secrets Managerクライアントを初期化
const client = new SecretsManagerClient({
  region: process.env.AWS_REGION || 'ap-northeast-1',
});

この初期化では、AWSリージョンを環境変数から取得しています。指定がない場合は、デフォルトで東京リージョン(ap-northeast-1)を使用します。

次に、シークレットを取得する非同期関数を実装します。

typescript// src/utils/secrets.ts (続き)

/**
 * AWS Secrets Managerからシークレットを取得する関数
 * @param secretName - 取得するシークレットの名前
 * @returns シークレットの値(JSON文字列)
 */
export async function getSecret(secretName: string): Promise<string> {
  try {
    const command = new GetSecretValueCommand({
      SecretId: secretName,
    });

    const response = await client.send(command);

    // シークレットの値を返す
    if (response.SecretString) {
      return response.SecretString;
    }

    throw new Error('SecretString is undefined');
  } catch (error) {
    console.error(`Failed to retrieve secret: ${secretName}`, error);
    throw error;
  }
}

このエラーハンドリングにより、シークレット取得失敗時に詳細なログが出力され、問題の特定が容易になりますね。

取得したシークレットをPrismaで使用するための初期化関数を作成します。

typescript// src/db.ts (Secrets Manager対応版)

import { PrismaClient } from '@prisma/client';
import { getSecret } from './utils/secrets';

let prisma: PrismaClient;

/**
 * Prisma Clientを初期化する関数
 * 本番環境ではSecrets Managerからシークレットを取得
 */
export async function initializePrisma(): Promise<PrismaClient> {
  // 開発環境では環境変数を直接使用
  if (process.env.NODE_ENV === 'development') {
    prisma = new PrismaClient();
    return prisma;
  }

  // 本番環境ではSecrets Managerから取得
  const secretName = process.env.DB_SECRET_NAME || 'prod/database/url';
  const secretString = await getSecret(secretName);

  // シークレットをJSONとしてパース
  const secret = JSON.parse(secretString);

  prisma = new PrismaClient({
    datasources: {
      db: {
        url: secret.DATABASE_URL,
      },
    },
  });

  return prisma;
}

/**
 * Prisma Clientインスタンスを取得する関数
 */
export function getPrisma(): PrismaClient {
  if (!prisma) {
    throw new Error('Prisma has not been initialized. Call initializePrisma first.');
  }
  return prisma;
}

この実装では、環境に応じて異なる初期化方法を使用しています。開発環境では.envファイルから、本番環境ではSecrets Managerからシークレットを取得しますね。

アプリケーションのエントリーポイントで、Prismaを初期化します。

typescript// src/index.ts

import express from 'express';
import { initializePrisma, getPrisma } from './db';

async function startServer() {
  // Prismaを初期化
  await initializePrisma();

  const app = express();
  const PORT = process.env.PORT || 3000;

  // ヘルスチェックエンドポイント
  app.get('/health', async (req, res) => {
    try {
      const prisma = getPrisma();
      // データベース接続を確認
      await prisma.$queryRaw`SELECT 1`;
      res.status(200).json({ status: 'healthy' });
    } catch (error) {
      res.status(503).json({ status: 'unhealthy', error: error.message });
    }
  });

  app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
  });
}

startServer().catch((error) => {
  console.error('Failed to start server:', error);
  process.exit(1);
});

このヘルスチェックエンドポイントにより、データベース接続の状態をモニタリングできます。

AWS Secrets ManagerでのシークレットJSON構造

AWS Secrets Managerには、以下のようなJSON形式でシークレットを保存します。

json{
  "DATABASE_URL": "postgresql://prod_user:prod_password@db.example.com:5432/myapp_prod?schema=public&sslmode=require",
  "SHADOW_DATABASE_URL": "postgresql://prod_user:prod_password@db.example.com:5432/myapp_shadow?schema=public&sslmode=require"
}

このJSON構造により、関連する複数のシークレットを1つのシークレットオブジェクトとして管理できます。

AWS CLIを使ってシークレットを作成する場合は、以下のコマンドを使用します。

bashaws secretsmanager create-secret \
  --name prod/database/url \
  --description "Production database connection string" \
  --secret-string file://secret.json \
  --region ap-northeast-1

secret.jsonファイルには、上記のJSON構造を記述しておきます。

SSL/TLS証明書を使った安全な接続

データベースとの通信を暗号化するために、SSL/TLS証明書を使用した接続を設定しましょう。まず、証明書ファイルをSecrets Managerに保存します。

証明書の内容をBase64エンコードして保存する方法が一般的です。

typescript// src/utils/ssl.ts

import * as fs from 'fs';
import * as path from 'path';

/**
 * Base64エンコードされた証明書をデコードしてファイルに保存
 * @param base64Cert - Base64エンコードされた証明書
 * @param outputPath - 保存先ファイルパス
 */
export function saveCertificate(base64Cert: string, outputPath: string): void {
  const certBuffer = Buffer.from(base64Cert, 'base64');
  fs.writeFileSync(outputPath, certBuffer);
}

この関数により、Secrets Managerから取得したBase64エンコード済み証明書を、一時ファイルとして保存できます。

証明書を含むシークレットをSecrets Managerに保存する場合、以下のようなJSON構造を使用します。

json{
  "DATABASE_URL": "postgresql://prod_user:prod_password@db.example.com:5432/myapp_prod?schema=public&sslmode=require",
  "SSL_CERT": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...",
  "SSL_KEY": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t...",
  "SSL_CA": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
}

SSL_CERTSSL_KEYSSL_CAは、それぞれBase64エンコードされた証明書、秘密鍵、CA証明書です。

証明書を使用してPrismaを初期化する実装を見ていきましょう。

typescript// src/db.ts (SSL証明書対応版)

import { PrismaClient } from '@prisma/client';
import { getSecret } from './utils/secrets';
import { saveCertificate } from './utils/ssl';
import * as path from 'path';
import * as os from 'os';

/**
 * SSL証明書を使用してPrisma Clientを初期化
 */
export async function initializePrismaWithSSL(): Promise<PrismaClient> {
  const secretName = process.env.DB_SECRET_NAME || 'prod/database/url';
  const secretString = await getSecret(secretName);
  const secret = JSON.parse(secretString);

  // 一時ディレクトリに証明書を保存
  const tmpDir = os.tmpdir();
  const certPath = path.join(tmpDir, 'db-cert.pem');
  const keyPath = path.join(tmpDir, 'db-key.pem');
  const caPath = path.join(tmpDir, 'db-ca.pem');

  // Base64デコードして保存
  saveCertificate(secret.SSL_CERT, certPath);
  saveCertificate(secret.SSL_KEY, keyPath);
  saveCertificate(secret.SSL_CA, caPath);

  // SSL証明書を含む接続文字列を構築
  const sslParams = `sslcert=${certPath}&sslkey=${keyPath}&sslrootcert=${caPath}&sslmode=require`;
  const databaseUrl = `${secret.DATABASE_URL}&${sslParams}`;

  const prisma = new PrismaClient({
    datasources: {
      db: {
        url: databaseUrl,
      },
    },
  });

  return prisma;
}

この実装により、証明書を使用した暗号化接続が確立されます。一時ディレクトリに証明書を保存することで、コンテナの再起動時にも動的に証明書を更新できますね。

Docker Composeでのシークレット管理

Docker Compose環境では、secrets機能を使用してシークレットを安全に管理します。まず、docker-compose.ymlファイルを作成しましょう。

yaml# docker-compose.yml

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
      DB_SECRET_NAME: prod/database/url
    secrets:
      - db_connection
    depends_on:
      - postgres

この設定では、secretsセクションで定義したシークレットをコンテナ内の​/​run​/​secrets​/​ディレクトリにマウントします。

次に、secretsセクションを定義します。

yaml# docker-compose.yml (続き)

  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_USER: myapp_user
      POSTGRES_DB: myapp_prod
    secrets:
      - db_password
    volumes:
      - postgres_data:/var/lib/postgresql/data

secrets:
  db_connection:
    file: ./secrets/db_connection.txt
  db_password:
    file: ./secrets/db_password.txt

volumes:
  postgres_data:

シークレットファイルは、.​/​secrets​/​ディレクトリに配置します。このディレクトリは.gitignoreに追加して、リポジトリには含めないようにしましょう。

アプリケーション側で、マウントされたシークレットファイルを読み込む実装を追加します。

typescript// src/utils/secrets.ts (Docker Secrets対応)

import * as fs from 'fs';
import * as path from 'path';

/**
 * Docker Secretsからシークレットを読み込む関数
 * @param secretName - シークレット名
 * @returns シークレットの内容
 */
export function getDockerSecret(secretName: string): string {
  const secretPath = path.join('/run/secrets', secretName);

  if (fs.existsSync(secretPath)) {
    return fs.readFileSync(secretPath, 'utf8').trim();
  }

  throw new Error(`Docker secret not found: ${secretName}`);
}

この関数により、Docker Secretsとしてマウントされたシークレットを簡単に読み込むことができます。

環境に応じて、適切なシークレット取得方法を選択する統合関数を作成しましょう。

typescript// src/utils/secrets.ts (統合版)

/**
 * 環境に応じて適切な方法でシークレットを取得
 * @param secretName - シークレット名
 * @returns シークレットの内容
 */
export async function getSecretFromEnvironment(
  secretName: string
): Promise<string> {
  // Docker環境の場合
  if (fs.existsSync('/run/secrets')) {
    return getDockerSecret(secretName);
  }

  // AWS環境の場合
  if (process.env.AWS_REGION) {
    return await getSecret(secretName);
  }

  // 開発環境の場合(環境変数から取得)
  const envValue = process.env[secretName];
  if (envValue) {
    return envValue;
  }

  throw new Error(`Secret not found: ${secretName}`);
}

この統合関数により、同じコードでさまざまな環境に対応できますね。

Kubernetes環境でのExternal Secrets Operator設定

Kubernetes環境では、External Secrets Operatorを使用して、AWS Secrets ManagerのシークレットをKubernetes Secretsに同期します。まず、Operatorをインストールしましょう。

bashhelm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets-system --create-namespace

次に、SecretStoreリソースを作成して、AWS Secrets Managerへの接続を設定します。

yaml# secret-store.yaml

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: default
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-northeast-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa

この設定では、IAM Roles for Service Accounts(IRSA)を使用してAWSへの認証を行います。

ExternalSecretリソースを作成して、特定のシークレットを同期します。

yaml# external-secret.yaml

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: database-credentials
  namespace: default
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: database-credentials
    creationPolicy: Owner
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: prod/database/url
        property: DATABASE_URL

このリソースにより、AWS Secrets Managerのprod​/​database​/​urlシークレットからDATABASE_URLプロパティを取得し、Kubernetes SecretのDATABASE_URLキーとして保存されます。

refreshIntervalを設定することで、1時間ごとに自動的にシークレットが同期され、常に最新の状態が保たれますね。

PodでこのSecretを使用するDeploymentを作成します。

yaml# deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: prisma-app
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: prisma-app
  template:
    metadata:
      labels:
        app: prisma-app
    spec:
      containers:
        - name: app
          image: myregistry/prisma-app:latest
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: database-credentials
                  key: DATABASE_URL
            - name: NODE_ENV
              value: production

この設定により、Podには環境変数としてDATABASE_URLが注入され、Prismaが自動的に使用します。

シークレットローテーションの自動化スクリプト

AWS Secrets Managerでは、自動ローテーション機能を使用して、定期的にシークレットを更新できます。Lambda関数を作成して、ローテーションロジックを実装しましょう。

まず、ローテーション用のLambda関数の基本構造を見ていきます。

typescript// lambda/rotate-secret.ts

import {
  SecretsManagerClient,
  GetSecretValueCommand,
  PutSecretValueCommand,
  DescribeSecretCommand,
} from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({
  region: process.env.AWS_REGION,
});

interface RotationEvent {
  SecretId: string;
  Token: string;
  Step: 'createSecret' | 'setSecret' | 'testSecret' | 'finishSecret';
}

AWS Secrets Managerのローテーションは、4つのステップで実行されます。各ステップを処理するハンドラーを実装していきます。

typescript// lambda/rotate-secret.ts (続き)

/**
 * Lambda関数のエントリーポイント
 */
export async function handler(event: RotationEvent): Promise<void> {
  const { SecretId, Token, Step } = event;

  console.log(`Rotating secret ${SecretId} - Step: ${Step}`);

  switch (Step) {
    case 'createSecret':
      await createSecret(SecretId, Token);
      break;
    case 'setSecret':
      await setSecret(SecretId, Token);
      break;
    case 'testSecret':
      await testSecret(SecretId, Token);
      break;
    case 'finishSecret':
      await finishSecret(SecretId, Token);
      break;
    default:
      throw new Error(`Invalid step: ${Step}`);
  }
}

この構造により、各ステップを個別に処理できます。

最初のステップであるcreateSecretでは、新しいパスワードを生成します。

typescript// lambda/rotate-secret.ts (createSecret実装)

import { randomBytes } from 'crypto';

/**
 * ステップ1: 新しいシークレットを作成
 */
async function createSecret(secretId: string, token: string): Promise<void> {
  // 現在のシークレットを取得
  const getCurrentCommand = new GetSecretValueCommand({
    SecretId: secretId,
    VersionStage: 'AWSCURRENT',
  });

  const currentSecret = await client.send(getCurrentCommand);
  const currentValue = JSON.parse(currentSecret.SecretString || '{}');

  // 新しいパスワードを生成(32文字のランダム文字列)
  const newPassword = randomBytes(32).toString('base64');

  // 新しいシークレット値を作成
  const newValue = {
    ...currentValue,
    password: newPassword,
  };

  // 新しいバージョンとして保存
  const putCommand = new PutSecretValueCommand({
    SecretId: secretId,
    ClientRequestToken: token,
    SecretString: JSON.stringify(newValue),
    VersionStages: ['AWSPENDING'],
  });

  await client.send(putCommand);
  console.log('New secret version created');
}

このステップでは、安全なランダムパスワードを生成し、AWSPENDINGステージとして保存します。

2番目のステップであるsetSecretでは、データベース側のパスワードを実際に変更します。

typescript// lambda/rotate-secret.ts (setSecret実装)

import { Client } from 'pg';

/**
 * ステップ2: データベースのパスワードを更新
 */
async function setSecret(secretId: string, token: string): Promise<void> {
  // 新しいシークレットを取得
  const getPendingCommand = new GetSecretValueCommand({
    SecretId: secretId,
    VersionId: token,
    VersionStage: 'AWSPENDING',
  });

  const pendingSecret = await client.send(getPendingCommand);
  const newValue = JSON.parse(pendingSecret.SecretString || '{}');

  // 現在のシークレット(管理者権限)を使って接続
  const getCurrentCommand = new GetSecretValueCommand({
    SecretId: secretId,
    VersionStage: 'AWSCURRENT',
  });

  const currentSecret = await client.send(getCurrentCommand);
  const currentValue = JSON.parse(currentSecret.SecretString || '{}');

  // データベースに接続してパスワードを変更
  const dbClient = new Client({
    host: currentValue.host,
    port: currentValue.port,
    user: currentValue.username,
    password: currentValue.password,
    database: 'postgres',
  });

  await dbClient.connect();

  // パスワード変更SQLを実行
  const query = `ALTER USER ${currentValue.username} WITH PASSWORD $1`;
  await dbClient.query(query, [newValue.password]);

  await dbClient.end();
  console.log('Database password updated');
}

この実装により、データベース側のパスワードが新しい値に更新されます。

3番目のステップであるtestSecretでは、新しいパスワードで接続できることを確認します。

typescript// lambda/rotate-secret.ts (testSecret実装)

/**
 * ステップ3: 新しいシークレットで接続をテスト
 */
async function testSecret(secretId: string, token: string): Promise<void> {
  // 新しいシークレットを取得
  const getPendingCommand = new GetSecretValueCommand({
    SecretId: secretId,
    VersionId: token,
    VersionStage: 'AWSPENDING',
  });

  const pendingSecret = await client.send(getPendingCommand);
  const newValue = JSON.parse(pendingSecret.SecretString || '{}');

  // 新しいパスワードで接続テスト
  const dbClient = new Client({
    host: newValue.host,
    port: newValue.port,
    user: newValue.username,
    password: newValue.password,
    database: newValue.database,
  });

  try {
    await dbClient.connect();
    await dbClient.query('SELECT 1');
    await dbClient.end();
    console.log('Connection test successful with new password');
  } catch (error) {
    console.error('Connection test failed:', error);
    throw new Error('Failed to connect with new password');
  }
}

接続テストに失敗した場合、ローテーション全体がロールバックされ、古いパスワードが引き続き使用されます。

最後のステップであるfinishSecretでは、新しいシークレットを本番用として確定します。

typescript// lambda/rotate-secret.ts (finishSecret実装)

/**
 * ステップ4: 新しいシークレットを本番用に昇格
 */
async function finishSecret(secretId: string, token: string): Promise<void> {
  // シークレットのメタデータを取得
  const describeCommand = new DescribeSecretCommand({
    SecretId: secretId,
  });

  const metadata = await client.send(describeCommand);

  // 現在のバージョンを確認
  const currentVersion = Object.keys(metadata.VersionIdsToStages || {}).find(
    (versionId) =>
      metadata.VersionIdsToStages?.[versionId]?.includes('AWSCURRENT')
  );

  if (currentVersion === token) {
    console.log('Secret already marked as current');
    return;
  }

  // 新しいバージョンをAWSCURRENTに昇格
  // この操作は自動的に行われるため、明示的な処理は不要
  console.log('Rotation completed successfully');
}

このステップが完了すると、新しいシークレットがAWSCURRENTステージに昇格し、アプリケーションから使用されるようになります。

ローテーション設定をSecrets Managerに適用するには、以下のAWS CLIコマンドを使用します。

bashaws secretsmanager rotate-secret \
  --secret-id prod/database/url \
  --rotation-lambda-arn arn:aws:lambda:ap-northeast-1:123456789012:function:rotate-database-secret \
  --rotation-rules AutomaticallyAfterDays=30

この設定により、30日ごとに自動的にシークレットがローテーションされます。

エラーハンドリングとリトライロジック

シークレット取得時のエラーハンドリングも重要です。ネットワークエラーや一時的な障害に対応するため、リトライロジックを実装しましょう。

typescript// src/utils/retry.ts

/**
 * リトライ付きで関数を実行
 * @param fn - 実行する関数
 * @param maxRetries - 最大リトライ回数
 * @param delay - リトライ間の遅延(ミリ秒)
 */
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  let lastError: Error;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      console.warn(`Attempt ${attempt} failed:`, error);

      if (attempt < maxRetries) {
        // 指数バックオフで待機時間を増やす
        const waitTime = delay * Math.pow(2, attempt - 1);
        console.log(`Retrying in ${waitTime}ms...`);
        await new Promise(resolve => setTimeout(resolve, waitTime));
      }
    }
  }

  throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`);
}

この実装では、指数バックオフアルゴリズムを使用して、リトライ間の待機時間を徐々に増やしていますね。

リトライロジックをシークレット取得関数に適用します。

typescript// src/utils/secrets.ts (リトライ対応版)

import { withRetry } from './retry';

/**
 * リトライ付きでシークレットを取得
 */
export async function getSecretWithRetry(
  secretName: string
): Promise<string> {
  return withRetry(
    async () => {
      return await getSecret(secretName);
    },
    3, // 最大3回リトライ
    1000 // 初回は1秒待機
  );
}

これにより、一時的なネットワークエラーが発生しても、自動的にリトライして復旧できます。

まとめ

Prismaアプリケーションにおけるシークレット運用は、セキュリティとアプリケーションの安定性を支える重要な要素です。本記事では、接続文字列の管理から証明書の取り扱い、定期的なローテーション戦略まで、実践的なベストプラクティスを解説してきました。

開発環境では.envファイルで利便性を確保しつつ、本番環境ではAWS Secrets ManagerやGoogle Secret Managerなどのエンタープライズグレードのシークレット管理サービスを活用することで、セキュリティと運用効率の両立が可能になります。

Docker環境ではDocker Secrets、Kubernetes環境ではExternal Secrets Operatorを使用することで、各環境に最適化されたシークレット管理を実現できますね。

シークレットローテーションは、セキュリティを維持するために欠かせない運用です。AWS Secrets Managerの自動ローテーション機能を活用することで、ダウンタイムなしでシークレットを定期的に更新し、万が一の漏洩リスクを最小限に抑えることができます。

重要なのは、環境に応じた適切な管理方法を選択し、チーム全体で一貫した運用ルールを守ることです。.gitignoreへのファイル追加、.env.exampleの活用、IAMポリシーによるアクセス制御など、基本的なセキュリティ対策を徹底しましょう。

本記事で紹介したベストプラクティスを実践することで、Prismaアプリケーションのシークレット管理を堅牢化し、安心して運用できる環境を構築できるはずです。セキュリティは一度設定したら終わりではなく、継続的な改善が求められる分野ですので、定期的に運用方法を見直し、最新のベストプラクティスを取り入れていくことをお勧めします。

関連リンク