AWS KMS・Secrets Manager、1年本番運用して見えたハマりどころと現実的な設計

「とりあえずKMS使ってます」状態から抜け出すのに1年かかった話。CMK粒度設計の失敗、ローテーション自動化の罠、コスト最適化まで、SOC2対応の現場で学んだ実践知見をまとめました。

KMS・Secrets Manager鍵管理を1年運用して見えた、本番での現実的な設計とハマりどころ

うちのチームでSOC2の審査対応を進める中で、KMSとSecrets Managerの使い方をゼロから見直すことになった。それが去年の話で、今ではある程度落ち着いた運用ができているんだけど、途中でかなりハマった。「とりあえずKMS使ってます」「Secrets Managerにシークレット入れてます」みたいな状態から、ちゃんと設計思想を持った運用に変えるまでに、正直かなり時間がかかった。

この記事では、その1年間で学んだことをまとめた。公式ドキュメントには書いていない「実際どうやって使うと辛くなるか」みたいな話を中心に書く。SOC2対応の全体像についてはSOC2対応2026年版の記事でまとめているので、そちらも合わせて読んでもらえると背景が掴みやすいと思う。


まず最初にやらかした話:CMKの設計が後から変えられなくて詰んだ

最初の失敗はCMK(カスタマー管理キー)の粒度設計をサボったことだ。「とりあえずひとつのCMKで全部暗号化しておけばいいか」という雑な判断をしたら、後からアクセスポリシーの分離ができなくなって詰んだ。

KMSのキーポリシーは一度作ると「このキーを使う権限がある人/ロール」を厳密に絞るのが難しくなる場面がある。特にマルチアカウント構成だと、キーを共有する側・される側の設計を最初に決めておかないと後から修正するのがかなり辛い。

うちのチームが最終的に落ち着いた設計は以下のような分類だ。最初は「細かく切りすぎじゃないか」と感じたけど、後から振り返るとこのくらいの粒度で正解だった。

用途CMKの粒度キーポリシーの特徴
RDS / Aurora の暗号化サービス単位RDSサービスロールのみ許可
S3 バケット暗号化バケット単位バケットポリシーと組み合わせ
Secrets Manager 暗号化環境単位(dev/stg/prd)Lambda実行ロール・ECSタスクロールを限定
Lambda 環境変数用途グループ単位最小権限
SSM Parameter Storeシステム単位SSMサービス連携

「環境単位でCMKを分ける」は最初やりすぎじゃないかと思ったけど、本番・検証でキーを分離することで、本番の暗号化されたデータが検証環境から操作できないことを保証できる。これはコンプライアンス審査でも重要なポイントになるので、早めに決めておくべき設計だった。


構成図:うちのチームが落ち着いた鍵管理アーキテクチャ

現在の構成をMermaidで書くとこんな感じ。マルチアカウント構成で、Secrets Managerを中心に据えた設計になっている。

graph TB
    subgraph SecurityAccount["🔐 Security Account (鍵管理専用)"]
        KMSAdmin["KMS Key Administrator\n(IAM Role)"]
        CMK_Prd["CMK - Production\n(自動ローテーション有効)"]
        CMK_Stg["CMK - Staging"]
        CMK_Dev["CMK - Development"]
        CloudTrail_KMS["CloudTrail\n(KMS操作監査)"]
        KMSAdmin --> CMK_Prd
        KMSAdmin --> CMK_Stg
        KMSAdmin --> CMK_Dev
        CMK_Prd --> CloudTrail_KMS
    end

    subgraph ProductionAccount["🏭 Production Account"]
        subgraph VPC_Prd["VPC (10.0.0.0/16)"]
            subgraph AZ_A["AZ: ap-northeast-1a"]
                ECS_Task["ECS Task\n(App Service)"]
                Lambda_Rot["Lambda\n(Rotation Function)"]
            end
            subgraph AZ_B["AZ: ap-northeast-1c"]
                RDS_Primary["RDS Aurora\n(Primary)"]
            end
            VPC_Endpoint_KMS["VPC Endpoint\nKMS"]
            VPC_Endpoint_SM["VPC Endpoint\nSecrets Manager"]
        end
        SM_Prd["Secrets Manager\n(DB Credentials / API Keys)"]
        SM_Prd_CMK["暗号化キー参照"]
        ECS_Task -->|シークレット取得| VPC_Endpoint_SM
        VPC_Endpoint_SM --> SM_Prd
        SM_Prd --> SM_Prd_CMK
        Lambda_Rot -->|ローテーション| SM_Prd
        Lambda_Rot --> RDS_Primary
        ECS_Task -->|KMS API| VPC_Endpoint_KMS
    end

    subgraph MonitoringAccount["📊 Monitoring Account"]
        CloudWatch_Alarm["CloudWatch Alarms\n(異常な復号化リクエスト検知)"]
        EventBridge_Rule["EventBridge Rule\n(ローテーション失敗通知)"]
        SNS_Alert["SNS Topic\n(Slack通知)"]
        CloudWatch_Alarm --> SNS_Alert
        EventBridge_Rule --> SNS_Alert
    end

    CMK_Prd -->|クロスアカウントキー使用許可| SM_Prd
    VPC_Endpoint_KMS --> CMK_Prd
    CloudTrail_KMS --> CloudWatch_Alarm
    SM_Prd --> EventBridge_Rule

ポイントはVPCエンドポイント経由でKMSとSecrets Managerにアクセスしていること。インターネット経由のAPIコールをなくすことで、ネットワークレイヤーのリスクを減らせる。これはコスト的にも意外とメリットがあって、NAT Gatewayのデータ転送料金を削減できた。地味に便利な副産物だった。


Secrets Managerのローテーション自動化で詰まったポイント

Secrets Managerのローテーションは「設定するだけで動く」と思って甘く見てたら、本番で深夜にアラートが飛んできて冷や汗をかいた経験がある。ローテーション関数の実装、思った以上に気を遣う部分が多い。

ローテーションフェーズの理解が必須

ローテーションはLambdaで実装するんだけど、以下の4ステップで呼び出される。このフロー、最初にちゃんと理解しておかないと後で痛い目を見る。

sequenceDiagram
    participant SM as Secrets Manager
    participant Lambda as Rotation Lambda
    participant RDS as RDS Aurora

    SM->>Lambda: createSecret (新しいバージョン作成)
    Lambda->>SM: AWSPENDING バージョンに新パスワード保存

    SM->>Lambda: setSecret (サービスに新認証情報を反映)
    Lambda->>RDS: ALTER USER ... IDENTIFIED BY '新パスワード'

    SM->>Lambda: testSecret (新認証情報のテスト)
    Lambda->>RDS: SELECT 1 (接続確認)
    RDS-->>Lambda: OK

    SM->>Lambda: finishSecret (バージョンの切り替え)
    Lambda->>SM: AWSCURRENT → AWSPREVIOUS, AWSPENDING → AWSCURRENT

これ、最初はLambdaの中で一気に「パスワード変えてテストして完了」みたいに実装しようとして、stepパラメータを無視してしまった。そうすると2回目の呼び出しで「setSecretフェーズなのにcreateSecretと同じ処理をしてしまう」みたいな問題が起きた。stepに応じて処理を分岐させる実装が必須。

import boto3
import json

def lambda_handler(event, context):
    arn = event['SecretId']
    token = event['ClientRequestToken']
    step = event['Step']

    client = boto3.client('secretsmanager')

    # メタデータ取得
    metadata = client.describe_secret(SecretId=arn)
    
    if not metadata['RotationEnabled']:
        raise ValueError(f"Secret {arn}: ローテーションが無効です")
    
    versions = metadata.get('VersionIdsToStages', {})
    if token not in versions:
        raise ValueError(f"Secret version {token} はこのシークレットに存在しません")
    
    if 'AWSCURRENT' in versions[token]:
        # すでにCURRENTなら何もしない(冪等性の担保)
        return
    elif 'AWSPENDING' not in versions[token]:
        raise ValueError(f"Secret version {token} はPENDINGステージにありません")

    if step == 'createSecret':
        create_secret(client, arn, token)
    elif step == 'setSecret':
        set_secret(client, arn, token)
    elif step == 'testSecret':
        test_secret(client, arn, token)
    elif step == 'finishSecret':
        finish_secret(client, arn, token)
    else:
        raise ValueError(f"不明なステップ: {step}")

def create_secret(client, arn, token):
    """新しいランダムパスワードを生成してPENDINGバージョンに保存"""
    try:
        # すでにPENDINGに保存済みなら何もしない
        client.get_secret_value(
            SecretId=arn,
            VersionStage='AWSPENDING',
            VersionId=token
        )
        return
    except client.exceptions.ResourceNotFoundException:
        pass
    
    current = json.loads(
        client.get_secret_value(SecretId=arn, VersionStage='AWSCURRENT')['SecretString']
    )
    
    # ランダムパスワード生成(KMSで生成)
    new_password = client.get_random_password(
        PasswordLength=32,
        ExcludeCharacters='/@"\\'
    )['RandomPassword']
    
    current['password'] = new_password
    
    client.put_secret_value(
        SecretId=arn,
        ClientRequestToken=token,
        SecretString=json.dumps(current),
        VersionStages=['AWSPENDING']
    )

冪等性の担保がめちゃくちゃ重要で、Lambdaはリトライされることがある。同じstepを2回呼ばれても壊れない実装にしておかないと、深夜にRDSのパスワードが半端な状態で止まって、アプリケーションが接続できなくなるという最悪の事態になる。実際にうちのチームは1回やらかした。深夜2時に叩き起こされた思い出は忘れられない。

ローテーション間隔と運用の現実

「コンプライアンス上90日ごとにローテーションを義務付ける」という要件があって、最初はそのまま90日に設定した。でも実際には、ローテーション直後にアプリのコネクションプールが古いパスワードをキャッシュして接続エラーが出ることがある。

うちのチームが対処したのは以下の2点だ:

  • ローテーション後にECSサービスをローリング再起動するEventBridgeルールを設置
  • アプリ側でSecrets Managerからシークレットを取得するコードに、接続エラー時の再取得ロジックを入れる

これをやってから、ローテーション起因の接続エラーはほぼゼロになった。正直「アプリ側も直さないといけないのか」と最初は面倒に思ったけど、一度やってしまえば安定するので早めに対処する価値がある。


KMSのコストと使用量、実際の数字で見てみた

KMSって「安いでしょ」と思ってたら、APIコール数が積み上がって意外と費用がかかることに気づいた。特にS3の暗号化でリクエストごとにKMS APIが呼ばれるパターンだと、大量のファイルを扱うシステムでは無視できないコストになる。

xychart-beta
    title "KMS APIコール数の推移(月次・万コール)"
    x-axis ["2025-05", "2025-07", "2025-09", "2025-11", "2026-01", "2026-03"]
    y-axis "APIコール数(万)" 0 --> 500
    bar [120, 145, 180, 220, 310, 280]
    line [120, 145, 180, 220, 310, 280]

2026-01のスパイクを調べたら、Lambda関数が毎回Secrets Managerからシークレットを取得していたのが主な要因だった。Lambdaの実行のたびにGetSecretValue APIが呼ばれると、その内部でKMS APIも呼ばれる。「なんでこんなに増えてるんだ」と気づくまでに2週間かかった。

対策として実装したのがインプロセスキャッシュだ:

import boto3
import json
import time

SECRET_CACHE_TTL = 300  # 5分キャッシュ
_secret_cache = {}

def get_secret(secret_id: str) -> dict:
    """TTL付きキャッシュでシークレット取得"""
    now = time.time()
    
    if secret_id in _secret_cache:
        cached_value, cached_time = _secret_cache[secret_id]
        if now - cached_time < SECRET_CACHE_TTL:
            return cached_value
    
    client = boto3.client('secretsmanager')
    response = client.get_secret_value(SecretId=secret_id)
    secret = json.loads(response['SecretString'])
    
    _secret_cache[secret_id] = (secret, now)
    return secret

# Lambda関数ハンドラ
def lambda_handler(event, context):
    # ウォームスタート時はキャッシュが生きている
    secret = get_secret('prod/myapp/db-credentials')
    # ... 処理

ただし、ローテーション後に古いキャッシュが残ると接続エラーになるので、接続エラー時にキャッシュをクリアして再取得するロジックも一緒に入れた。このキャッシュ実装で、KMS APIコールが月間で約40%削減できた。

AWS公式のSecrets Manager Python用キャッシングライブラリというのもあって、個人的にはこれを最初から使えばよかったと思っている。自前実装で詰まってから知るというのはあるあるだけど、公式ライブラリを先に探す癖をつけたい。


運用して気づいたモニタリング設計の重要性

鍵管理でモニタリングをどこまでやるかって、最初はあまり真剣に考えていなかった。でもインシデント対応のベストプラクティスでも書かれているように、「検知できなければ対応できない」という原則は鍵管理にも当てはまる。後付けで入れるのは本当に大変なので、最初から仕込んでおくことを強くすすめる。

実装したCloudWatchアラーム

# CDK(Python)でのアラーム設定例
from aws_cdk import (
    aws_cloudwatch as cw,
    aws_cloudwatch_actions as cwa,
    aws_logs as logs,
    Duration
)

# KMS Decrypt APIの異常な急増を検知
decrypt_alarm = cw.Alarm(
    scope=self,
    id='KMSDecryptAnomalyAlarm',
    metric=cw.Metric(
        namespace='AWS/KMS',
        metric_name='NumberOfRequestsSucceeded',
        dimensions_map={'KeyId': cmk_prd.key_id},
        statistic='Sum',
        period=Duration.minutes(5)
    ),
    threshold=10000,  # 5分間で1万コールを超えたら
    evaluation_periods=2,
    alarm_description='KMS復号化リクエストの異常増加 - データ漏洩の可能性',
    comparison_operator=cw.ComparisonOperator.GREATER_THAN_THRESHOLD,
    treat_missing_data=cw.TreatMissingData.NOT_BREACHING
)

# Secrets Managerのローテーション失敗
rotation_failed_rule = events.Rule(
    scope=self,
    id='SecretsManagerRotationFailed',
    event_pattern=events.EventPattern(
        source=['aws.secretsmanager'],
        detail_type=['Rotation Failure'],
    )
)
rotation_failed_rule.add_target(
    targets.SnsTopic(alert_topic)
)

実際に役に立ったのが「特定IAMロールからのKMS APIコール急増」を検知するアラームだ。正直最初は「過剰じゃないか」と思っていたけど、あるときLambda関数のバグでループが発生してKMS APIを毎秒1000回コールし続けるという事態が起きて、このアラームに救われた。コスト的にもそのまま放置していたら相当な額になっていたはず。「大げさかな」と思ったアラームが実際に火を吹く場面で機能する、というのは経験するまでなかなか実感できない。

キー使用状況のダッシュボード

SecretsManagerとKMSの健全性を一目で確認できるダッシュボードを作っておくと運用が楽になる。具体的に入れておくと便利な項目はこのあたりだ:

  • CMKごとのAPIコール数(日次)
  • ローテーション成功・失敗の件数
  • シークレットの最終アクセス日時(長期間アクセスがないものは棚卸し対象)
  • 期限切れが近づいているシークレットの数

シークレットの棚卸しは意外と盲点で、「誰も使ってないけど消せない」という状態のシークレットが気づくと溜まっていく。うちのチームでは3ヶ月アクセスがないシークレットを自動でSlack通知する仕組みを作ったが、最初に棚卸しをしたら結構な数が引っかかって少し笑えなかった。

コンテナセキュリティとの連携についてはコンテナセキュリティ完全ガイド2026も参考になる。EKSのPodからSecretsManagerにアクセスする際のIRSA設定と組み合わせると、より細かい権限制御ができる。


KMS vs SSM Parameter Store、結局どっちを使うべきか

「Secrets ManagerじゃなくてSSM Parameter Storeで良くない?」という話、チームでよく議論になる。正直、用途によって使い分けるのが正解だと思っていて、コスト感も含めて整理しておく。

比較項目Secrets ManagerSSM Parameter Store (SecureString)
費用(シークレットあたり)$0.40/月 + APIコール費用無料(Standard)〜$0.05/月(Advanced)
自動ローテーションネイティブサポートなし(自前実装が必要)
クロスアカウント共有サポート限定的
シークレットのバージョン管理AWSCURRENT/AWSPENDING/AWSPREVIOUSバージョン番号
最大サイズ65,536バイト4,096バイト(Standard)/ 8,192バイト(Advanced)
向いている用途DB認証情報、APIキーアプリ設定値、非クリティカルな設定

判断基準として使っているのは「定期的にローテーションが必要か」と「クロスアカウントでの共有が必要か」の2点。この条件を満たすものはSecrets Manager、そうでないものはSSM Parameter Store (SecureString)という棲み分けで運用している。

DB認証情報や外部APIキー(StripeのAPIキー、Twilioのトークンなど)はSecrets Manager一択だ。アプリケーションの設定値(機能フラグのJSONとか)はSSM Parameter Storeで十分なことが多い。

費用面では、Secrets Managerのシークレットが100個あると月額約$40の固定費がかかる計算になる。これに加えてAPIコール費用(10,000コールあたり$0.05)が乗ってくる。規模が大きくなるとそれなりの額になるので、棚卸しをサボると地味に痛い。「たかがシークレット管理ツールの費用」と舐めていると意外なところで予算を圧迫するので注意したい。


まとめ

1年間KMS・Secrets Managerを運用してきて、一番学んだのは「最初の設計が後から変えにくい」という点だ。CMKの粒度、キーポリシー、マルチアカウントでの共有設計は、後からリファクタリングするのがかなり辛い。最初に面倒でも丁寧に決めておくのが結局一番ラクな道だった。

要点をまとめるとこうなる:

  • CMKは「環境 × サービス」の粒度で設計する。後から分割は難しいので最初から細かく切っておく
  • Secrets Managerのローテーション実装は冪等性が命。stepパラメータの分岐処理を必ず実装し、同じstepを2回呼ばれても安全なコードにする
  • VPCエンドポイント経由のアクセスを強制。インターネット経由のKMS/SecretsManager APIコールをSCPで禁止するとより安全
  • シークレットのインプロセスキャッシュでKMS APIコストを最大40%削減できる。ただしローテーション後のキャッシュクリアロジックとセットで実装すること
  • モニタリングは後付けでは間に合わない。KMS APIの急増検知とローテーション失敗通知は最初から仕込んでおく

次にやるべきことがあるとすれば、こんな順番で手をつけるといいと思う:

  1. 既存のCMKのキーポリシーを見直して、不要なIAMプリンシパルへの権限を削除する
  2. Secrets Managerで3ヶ月以上アクセスのないシークレットを棚卸しする
  3. ローテーション関数に冪等性チェックが実装されているか確認する

正直まだ完全に最適化しきれていない部分もある。特にマルチリージョンでのキーレプリケーション設計は、DR観点で検討中の段階だ。皆さんはクロスリージョンの鍵管理、どうやってます?ここは要件次第で答えが大きく変わる部分だと思うので、知見があればぜひ聞かせてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事