PrivateLink 3年本番運用で踏んだ地雷と、最初から知りたかった設計の話

「エンドポイント作れば安全でしょ」と思っていたら半年後に監査で引っかかった話。エンドポイントポリシーやDNS設定の落とし穴、マルチアカウント構成の実践知見をまとめています。

あれから3年、チームで何度も設計を壊しながら今の構成に落ち着いている。

最初に本番環境にPrivateLinkを導入したのは2023年の話で、当時は「インターネットを経由しないから安全でしょ」という雑な理解で設定した。結果として半年後に社内セキュリティ監査で引っかかり、エンドポイントポリシーをほぼ全部書き直すことになった。

その過程で踏んだ地雷と「これを最初から知っていたら」という知見を共有したい。特に2026年現在、AWSがエンドポイントポリシーの評価ロジックを一部変更したこともあって、昔の記事をそのまま参考にすると詰まるポイントがある。

なぜVPCエンドポイントだけじゃセキュリティが「完成」しないのか

エンドポイントを作って満足してしまうのが、一番ありがちな失敗だと思う。うちのチームも最初そうだった。

VPCエンドポイント(Gateway型・Interface型)を有効化するだけだと、実際には以下の問題が残る。

1. エンドポイントポリシーが「*」のまま

デフォルトのエンドポイントポリシーはフルアクセスを許可している。つまりエンドポイントを経由すれば、どのIAMプリンシパルでも、どのS3バケットにでもアクセスできてしまう。「インターネットを通ってないから大丈夫」じゃなくて、不正アクセスやクレデンシャル漏洩時の被害範囲が全く絞れていない状態だ。

2. バケットポリシー側の制御がない

エンドポイントポリシーだけ絞っても、バケットポリシーで aws:sourceVpce 条件を使った制限をかけていないと、パブリックなアクセスパスを閉じ切れない。両側から締める設計が必要なんですよね。

3. DNS設定のプライベートDNS問題

Interface型エンドポイントのプライベートDNSを有効にしていない環境では、アプリケーションが意図せずパブリックエンドポイントに接続し続けることがある。これに気づかずに「エンドポイント経由のはず」という思い込みで運用していたケースが社内でも1回あった。地味に気づきにくいやつだ。

実際に構築した多層防御アーキテクチャ

以下が現在運用しているマルチアカウント構成の概要だ。Consumer側VPCとProvider側、それをまたぐPrivateLinkの構成をベースにしている。

graph TB
  subgraph Org["AWS Organizations"]
    subgraph SharedServicesAccount["Shared Services Account"]
      subgraph SharedVPC["Shared VPC (10.0.0.0/16)"]
        subgraph AZ1["AZ: ap-northeast-1a"]
          NLB["NLB\n(内部向け)"]
          ECS1["ECS Fargate\nサービス"]
        end
        subgraph AZ2["AZ: ap-northeast-1c"]
          ECS2["ECS Fargate\nサービス"]
        end
        VPCEPService["VPC Endpoint Service\n(PrivateLink Provider)"]
      end
    end

    subgraph AppAccount["Application Account"]
      subgraph AppVPC["App VPC (10.1.0.0/16)"]
        subgraph PrivateSubnet["Private Subnet"]
          Lambda1["Lambda Function"]
          EC2App["EC2 / ECS"]
        end
        VPCE_Interface["Interface Endpoint\n(PrivateLink Consumer)"]
        VPCE_S3["S3 Gateway\nEndpoint"]
        VPCE_SSM["SSM Interface\nEndpoint"]
        SG_VPCE["Security Group\n(Endpoint SG)"]
      end
    end

    subgraph DataAccount["Data Account"]
      S3Bucket["S3 Bucket\n(データレイク)"]
    end
  end

  Lambda1 -->|PrivateLink経由| VPCE_Interface
  EC2App -->|PrivateLink経由| VPCE_Interface
  VPCE_Interface --> VPCEPService
  VPCEPService --> NLB
  NLB --> ECS1
  NLB --> ECS2
  Lambda1 -->|Gateway Endpoint経由| VPCE_S3
  VPCE_S3 --> S3Bucket
  EC2App --> VPCE_SSM
  SG_VPCE -.->|制御| VPCE_Interface

この構成のポイントは3つある。

  1. Shared Services AccountにNLB+ECSを置き、PrivateLinkでApplication Accountに公開。Application AccountのLambdaやEC2はInternet Gatewayを通らずにShared Services側のサービスを呼び出せる。
  2. S3はGateway Endpoint、SSM・ECR・CloudWatch等はInterface Endpoint。Gateway型はルートテーブルに追加されるだけでコストがかからないが、Interface型は時間課金とデータ処理課金がある。
  3. Security GroupでEndpoint自体へのアクセスを絞る。エンドポイントポリシーだけでなく、SG側でもCIDRや他SGからの通信を制限する。

エンドポイントポリシー設計、ここで詰まった

エンドポイントポリシーの書き方、最初は本当に迷った。実際に運用しているS3 GatewayエンドポイントとInterface Endpointのポリシーを順番に見ていく。

S3 Gatewayエンドポイントポリシー

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSpecificBucketsOnly",
      "Effect": "Allow",
      "Principal": "*",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-data-lake-bucket",
        "arn:aws:s3:::my-data-lake-bucket/*",
        "arn:aws:s3:::my-artifacts-bucket",
        "arn:aws:s3:::my-artifacts-bucket/*"
      ]
    },
    {
      "Sid": "AllowS3ConsoleAndCLI",
      "Effect": "Allow",
      "Principal": "*",
      "Action": [
        "s3:ListAllMyBuckets",
        "s3:GetBucketLocation"
      ],
      "Resource": "*"
    }
  ]
}

ここで詰まったのが s3:ListAllMyBucketss3:GetBucketLocation の扱いだ。これを許可しないとAWS CLIやSDKが動かないケースがある。最初は「なんで全バケットのList許可が必要なんだ」と思ったが、実際にはバケット一覧の取得はリソースを * にしないといけないAWSの仕様なので仕方ない。正直ここは毎回「本当にこれで合ってるのか」と不安になりながら書いている。

バケットポリシー側での制限(セットで書かないと意味がない)

エンドポイントポリシーだけ書いて満足するのが一番危ない。バケットポリシーで反対側を締めて初めて「閉じた」と言える。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAccessNotFromVPCEndpoint",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-data-lake-bucket",
        "arn:aws:s3:::my-data-lake-bucket/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:sourceVpce": "vpce-0123456789abcdef0"
        },
        "ArnNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::123456789012:role/AdminRole",
            "arn:aws:iam::123456789012:role/CICDDeployRole"
          ]
        }
      }
    }
  ]
}

この Deny ステートメントの ArnNotLike 除外が重要で、CI/CDのロールやAdminはVPCエンドポイント外からもアクセスできるようにしておかないと、デプロイパイプラインが死ぬ。最初にこれを入れ忘れてパイプラインが全部失敗して焦った記憶がある。あの瞬間の絶望感はなかなかのものだった。

SSM Interface Endpointポリシー

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSSMActions",
      "Effect": "Allow",
      "Principal": "*",
      "Action": [
        "ssm:DescribeAssociation",
        "ssm:GetDeployablePatchSnapshotForInstance",
        "ssm:GetDocument",
        "ssm:DescribeDocument",
        "ssm:GetManifest",
        "ssm:GetParameter",
        "ssm:GetParameters",
        "ssm:ListAssociations",
        "ssm:ListInstanceAssociations",
        "ssm:PutInventory",
        "ssm:PutComplianceItems",
        "ssm:PutConfigurePackageResult",
        "ssm:UpdateAssociationStatus",
        "ssm:UpdateInstanceAssociationStatus",
        "ssm:UpdateInstanceInformation"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalAccount": "123456789012"
        }
      }
    }
  ]
}

SSMのエンドポイントポリシーは aws:PrincipalAccount で自アカウントに絞っておくのが基本だ。マルチアカウント環境だと特に重要で、隣のアカウントのインスタンスが誤ってこのエンドポイントを使えてしまう設定ミスをチームメンバーが一度やらかした。やらかした本人も「そんな経路があるとは思っていなかった」と言っていたので、あり得ない話ではない。

エンドポイント設計の比較と選定基準

正直、Gateway型とInterface型のどちらを使うか迷ったことがある人は多いと思う。2026年時点での実務での使い分けをまとめておく。

項目Gateway型エンドポイントInterface型エンドポイント
対応サービスS3、DynamoDBS3含む大多数のAWSサービス
料金無料時間課金 ($0.01/時) + データ処理課金 ($0.01/GB)
仕組みルートテーブルへのルート追加ENIをサブネットに作成
プライベートDNS非対応対応(サービスドメインを解決)
クロスアカウント不可可(PrivateLinkとして)
セキュリティグループ不可
VPC外からのアクセス不可Direct Connect・VPN経由で可
マルチAZ対応自動各AZにENI作成が必要

S3については、Gateway型を使えるなら使うというのが正解だと思っている。コストゼロで、エンドポイントポリシーも使える。Interface型のS3エンドポイントはDirect ConnectやVPNからのアクセスが必要な場合、またはPrivate DNSが必要な場合に使う。

コスト面での実測値も出しておく。うちの環境(毎月約500GBのS3アクセス)での話だ。

xychart-beta
  title "VPCエンドポイント月間コスト比較(概算)"
  x-axis ["Gateway型(S3)", "Interface型(S3)", "Interface型(ECR)", "Interface型(SSM)", "Interface型(Secrets)", "PrivateLink(NLB)"]
  y-axis "月間コスト(USD)" 0 --> 80
  bar [0, 22, 18, 15, 14, 72]

PrivateLinkのNLB経由サービスが思ったより高い。NLB自体の料金がかかるので、通信量が少ないサービスをPrivateLinkで公開するのはコスト的に微妙なことがある。正直これは導入前に試算しておけばよかったと後悔している。「セキュアにしたら月のコストが予想の倍になった」という話、笑えないくらいよくある。

2026年時点で特に気をつけたいセキュリティ設定

VPCエンドポイント通知とEventBridge連携

2025年後半からAWSがInterface Endpointの接続状態をEventBridgeに通知できるようになった。これを使うと、エンドポイントへの予期しない接続試行を検出できる。

import boto3
import json

def create_endpoint_monitoring_rule():
    events_client = boto3.client('events', region_name='ap-northeast-1')
    
    # VPCエンドポイントへの接続イベントを監視
    rule_response = events_client.put_rule(
        Name='vpc-endpoint-connection-monitor',
        EventPattern=json.dumps({
            "source": ["aws.ec2"],
            "detail-type": [
                "AWS API Call via CloudTrail"
            ],
            "detail": {
                "eventSource": ["ec2.amazonaws.com"],
                "eventName": [
                    "CreateVpcEndpoint",
                    "ModifyVpcEndpoint",
                    "DeleteVpcEndpoint",
                    "AcceptVpcEndpointConnections",
                    "RejectVpcEndpointConnections"
                ]
            }
        }),
        State='ENABLED',
        Description='VPCエンドポイント設定変更の監視'
    )
    
    print(f"Rule ARN: {rule_response['RuleArn']}")
    return rule_response

if __name__ == "__main__":
    create_endpoint_monitoring_rule()

エンドポイントポリシーのドリフト検出

手動変更によるポリシードリフトを検出するため、AWS Configカスタムルールを使っている。CDK Aspects + cdk-nagとの組み合わせについてはCDK Aspects・Nag本番導入3ヶ月で気づいた、自動セキュリティ検証の現実で詳しく書いたので参考にしてほしい。

import boto3
import json

def lambda_handler(event, context):
    """VPCエンドポイントポリシーが'*'になっていないか確認するConfigルール"""
    config = boto3.client('config')
    ec2 = boto3.client('ec2')
    
    # 評価対象のエンドポイントIDを取得
    invoking_event = json.loads(event['invokingEvent'])
    configuration_item = invoking_event.get('configurationItem', {})
    
    if configuration_item.get('resourceType') != 'AWS::EC2::VPCEndpoint':
        return
    
    endpoint_id = configuration_item['resourceId']
    
    # エンドポイントポリシーを取得
    try:
        response = ec2.describe_vpc_endpoints(
            VpcEndpointIds=[endpoint_id]
        )
        endpoint = response['VpcEndpoints'][0]
        policy_doc = json.loads(endpoint.get('PolicyDocument', '{}'))
        
        compliance_type = 'COMPLIANT'
        annotation = 'エンドポイントポリシーは適切に制限されています'
        
        # 全許可ポリシーのチェック
        for statement in policy_doc.get('Statement', []):
            principal = statement.get('Principal', '')
            effect = statement.get('Effect', '')
            actions = statement.get('Action', [])
            
            if (effect == 'Allow' and 
                (principal == '*' or principal == {'AWS': '*'}) and
                (actions == '*' or actions == ['*'])):
                compliance_type = 'NON_COMPLIANT'
                annotation = f'エンドポイント {endpoint_id} のポリシーがフルアクセス許可になっています'
                break
        
        config.put_evaluations(
            Evaluations=[
                {
                    'ComplianceResourceType': 'AWS::EC2::VPCEndpoint',
                    'ComplianceResourceId': endpoint_id,
                    'ComplianceType': compliance_type,
                    'Annotation': annotation,
                    'OrderingTimestamp': configuration_item['configurationItemCaptureTime']
                }
            ],
            ResultToken=event['resultToken']
        )
    except Exception as e:
        print(f"Error evaluating endpoint {endpoint_id}: {e}")
        raise

このLambdaをAWS Configカスタムルールとして登録しておくと、誰かがエンドポイントポリシーを * に変更したときに即座にNON_COMPLIANTとして検出できる。SOC2対応を進めているチームには特に有効で、SOC2審査でCloudTrail・Config設計が半年泥沼になった実録でも触れているが、こういう細かい制御の積み重ねが審査での差になる。

CloudTrailでのエンドポイント経由アクセスの可視化

CloudTrailログには vpcEndpointId フィールドが含まれるので、Athenaで以下のようなクエリを使って「エンドポイントを経由していないS3アクセス」を定期検出している。

-- エンドポイントを経由していないS3アクセスを検出
SELECT
  useridentity.principalid,
  useridentity.arn,
  sourceipaddress,
  eventname,
  requestparameters,
  eventtime,
  errorcode
FROM cloudtrail_logs_table
WHERE
  eventsource = 's3.amazonaws.com'
  AND eventname IN ('GetObject', 'PutObject', 'DeleteObject')
  AND (vpcendpointid IS NULL OR vpcendpointid = '')
  AND sourceipaddress NOT LIKE '10.%'  -- 内部IPを除外
  AND sourceipaddress NOT IN (
    SELECT DISTINCT sourceipaddress 
    FROM cloudtrail_logs_table 
    WHERE useridentity.type = 'AssumedRole'
    AND useragent LIKE '%codepipeline%'
  )
  AND year = '2026'
  AND month = '07'
ORDER BY eventtime DESC
LIMIT 100;

これを毎日定期実行して結果が出たらSlack通知する仕組みにしている。VPCフローログとの組み合わせについてはVPCフローログ、有効化して放置してませんか?Athena×Grafana構成を1年半運用してわかったことに詳しく書いたので参考にしてもらえると。

PrivateLinkのマルチアカウント展開時の注意点

マルチアカウントでPrivateLinkを使うとき、VPC Endpoint ServiceのAWS Organizations単位での許可設定を使うと運用が楽になる。アカウント単位でポチポチ管理するのはスケールしないので、早めにOU単位に切り替えるのがおすすめだ。

import boto3

def configure_endpoint_service_permissions():
    ec2 = boto3.client('ec2', region_name='ap-northeast-1')
    
    # 既存のVPC Endpoint Serviceを取得(NLBに紐づけて事前作成済み)
    endpoint_service_id = 'vpce-svc-0123456789abcdef0'
    
    # Organizations全体への許可(代わりにOU単位も可能)
    response = ec2.modify_vpc_endpoint_service_permissions(
        ServiceId=endpoint_service_id,
        AddAllowedPrincipals=[
            # 特定のOUに属するアカウントのみ許可
            'arn:aws:organizations::123456789012:ou/o-xxxx/ou-yyyy-zzzz'
        ]
    )
    
    # 自動承認を有効化(許可済みアカウントからの接続は自動承認)
    ec2.modify_vpc_endpoint_service_configuration(
        ServiceId=endpoint_service_id,
        AcceptanceRequired=False  # 許可済みプリンシパルは自動承認
    )
    
    print(f"Updated permissions: {response}")
    return response

configure_endpoint_service_permissions()

AcceptanceRequired=False にすると許可されたプリンシパルからの接続要求が自動承認されるが、これを有効にする前に 許可リストが正確かどうかを必ず確認してほしい。自動承認にしてから「想定外のアカウントが接続できてしまった」という話を身近で聞いたことがある。好みが分かれるところだが、うちのチームでは本番Providerの AcceptanceRequiredtrue に固定している。自動承認の便利さより、一回承認フローを挟む安心感の方が勝った。

まとめ

3年間の本番運用で見えてきたことを振り返ると、PrivateLinkとVPCエンドポイントのセキュリティは「設定したら終わり」じゃなくて継続的な管理が必要なものだと痛感している。

#やること理由
1エンドポイントポリシーとリソースポリシーをセットで設計するエンドポイントポリシーだけ絞っても、S3バケットポリシーで aws:sourceVpce 条件を入れないと片側しか締まっていない
2デフォルトの「フルアクセス」ポリシーを即座に書き換える作成直後のエンドポイントは Principal: * の全許可。AWS Configカスタムルールで定期監視するのが現実的
3Interface Endpointのコストを事前試算する特にPrivateLink経由のNLBは月額固定+データ転送でじわじわ高くなる
4マルチアカウント環境ではOrganizations連携での許可設定を使うアカウント単位のホワイトリスト管理は破綻しやすい。OU単位での管理に切り替えると運用コストが大幅に下がる
5CloudTrailでエンドポイント経由でないアクセスを定期検出するDNS設定ミスに気づかないまま運用し続けるリスクがある

次のアクション:

  • まず既存のエンドポイントポリシーが * になっていないかを確認する
  • S3バケットポリシーに aws:sourceVpce 条件が入っているか確認する
  • AWS Configのマネージドルール vpc-endpoint-enabled と、カスタムポリシーチェックを有効化する

皆さんの環境ではエンドポイントポリシーの管理、どうやってますか? Terraformで管理しているチームと手動でやっているチームで運用のしやすさがだいぶ違うと思うので、またどこかで書きたいと思っている。

U

Untanbaby

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

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

関連記事