[アップデート] Amazon Bedrock AgentCore Gatewayがプライベート認証局(CA)の証明書に対応したので、VPC内のMCPサーバーに接続してみた

[アップデート] Amazon Bedrock AgentCore Gatewayがプライベート認証局(CA)の証明書に対応したので、VPC内のMCPサーバーに接続してみた

Amazon Bedrock AgentCore Gatewayがプライベート認証局(CA)で署名されたTLS証明書に対応しました!VPC内のMCPサーバーにALBなしで接続してみました!
2026.10.19

はじめに

こんにちは、チーズナンが好きなコンサル部の神野です。

2026年10月2日のアップデートで、Amazon Bedrock AgentCore Gatewayがプライベート認証局(CA)で署名されたTLS証明書に対応しました!(少し前のアップデートですが・・・)

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/agentcore-gateway-private-tls-vpc/

対象となるのは、VPC Latticeのプライベートエンドポイントを経由するMCPサーバー、OpenAPI、HTTPプロキシの各ターゲットです。今回はVPC内に配置したMCPサーバーを使って試してみます!

何が嬉しいか

これまではプライベートCAの証明書を使うサーバーに接続する場合、公開ドメインとACMのパブリック証明書を用意した上で、前段に内部ALBを挟む構成をとる必要がありました。

今回のアップデートにより、CA証明書のPEMをS3かSecrets Managerに配置してターゲットに指定すれば、VPC Lattice経由で直接接続できるようになりました!

これまでは内部ALBと公開証明書が必要だったが、今回はS3に置いたCA証明書のPEMを指定してVPC Lattice経由で直接つなげる比較図

ちょっとニッチではありますが、接続構成の選択肢が広がった形ですね!
例えば、次のような環境では扱いやすくなるはずです。

  • プライベートCAで証明書を一元発行しており、VPC内のMCPサーバーやAPIもその証明書で運用している
  • 閉域ネットワーク環境のため、公開ドメインの取得やACMのDNS検証を進めづらい
  • GatewayとのTLS接続を成立させる目的で内部ALBを配置していたため、余分なリソースを減らして構成をシンプルにしたい

今回試す構成

プライベートサブネットに配置したEC2上でMCPサーバーをHTTPS起動し、プライベートCAで署名した証明書を割り当てます。ドメインの mcp.internal.example.com はRoute 53のプライベートホストゾーンを使ってVPC内から名前解決できるようにしています。

プライベートCAのPEMをS3から参照し、VPC Lattice経由でVPC内のMCPサーバーに接続するGatewayの構成図

今回は手軽にEC2で検証していますが、VPCからアクセスできるHTTPSエンドポイントであれば他のリソースでも問題ありません。公式ブログでは、GatewayからプライベートなAPI Gatewayへつなぐ構成も紹介されています。

https://aws.amazon.com/blogs/machine-learning/configuring-amazon-bedrock-agentcore-gateway-for-secure-access-to-private-resources/

API Gatewayならターゲット機能で直接指定できそうに思えますよね。GatewayにはAPI Gatewayターゲットが用意されており、REST APIのステージを指定すればそのままツールとして組み込めます。

https://dev.classmethod.jp/articles/amazon-bedrock-agentcore-gateway-new-target-amazon-api-gateway/

ただ、API Gatewayターゲットはパブリック(リージョン)エンドポイントを前提にしており、プライベートAPIには対応していません。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-api-gateway.html

そのため、VPC内から呼び出すプライベートAPIを使う場合は、OpenAPIターゲットとプライベートエンドポイントを組み合わせることになります。プライベートCAの証明書を独自ドメインに当てて運用しているAPIであれば、今回のアップデートがそのまま役立ちます!

さらにTransit Gatewayを挟んだマルチVPCやハイブリッド構成にも対応しているとのことなので、ECSやEKSで動かしているサーバーでも、VPC経由で届くエンドポイントがあれば同じように連携できるはずです。

前提

下記前提で進めていきます!

項目 内容
リージョン ap-northeast-1
MCPサーバー Python 3.14 + mcp 1.30.0(EC2 t4g.small、Amazon Linux 2023)
証明書の作成 OpenSSL 3.6.3(macOS)
環境の構築 AWS CDK(aws-cdk-lib 2.273.0、TypeScript、Node.js 26 + pnpm)
ターゲットの登録 AWS CLI 2.37.13
ツールの呼び出し curl 8.7.1(--aws-sigv4)
接続モード managed Lattice(managedVpcResource)

検証に使用したコードは、以下のリポジトリにまとめています。

https://github.com/yuu551/agentcore-gateway-tls

一から構築する場合は、作業用ディレクトリを作成したうえで cdk/ 配下にCDKアプリを初期化します。

環境のセットアップ
mkdir agentcore-gateway-tls && cd agentcore-gateway-tls
mkdir cdk && cd cdk && pnpm dlx aws-cdk@latest init app --language typescript --generate-only
pnpm install && cd ..

証明書を作る

検証で使う証明書を作成します。プライベートCAを立てて、そのCAで署名したサーバー証明書を用意する構成です。

CA証明書にはCAであることを示す CA:TRUE が必須で、サーバー証明書のSAN(Subject Alternative Name)にはGatewayの接続先ホスト名を設定します。

certs.sh(抜粋)
# プライベートCA
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
  -subj "/CN=Demo Private CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -out ca.pem

# サーバー証明書(SANに接続先のホスト名を入れる)
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
  -days 365 -sha256 \
  -extfile <(printf "subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n" "$HOST") \
  -out server.pem
certs.sh(全体)
certs.sh
#!/usr/bin/env bash
# 検証用の証明書を certs/ に作る(OpenSSL 3.x)
set -euo pipefail
HOST=mcp.internal.example.com
mkdir -p certs && cd certs

# プライベートCA:basicConstraintsをCA:TRUEにして自己署名する
openssl genrsa -out ca.key 2048
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
  -subj "/CN=Demo Private CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -out ca.pem

# サーバー証明書:SANに接続先ホスト名を入れ、CAで署名する
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=$HOST" -out server.csr
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
  -days 365 -sha256 \
  -extfile <(printf "subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n" "$HOST") \
  -out server.pem

# ---- おまけ:プライベートAPI Gatewayのカスタムドメイン用 ----
API_HOST=api.internal.example.com
openssl genrsa -out api.key 2048
openssl req -new -key api.key -subj "/CN=$API_HOST" -out api.csr
openssl x509 -req -in api.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
  -days 365 -sha256 \
  -extfile <(printf "subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nkeyUsage=critical,digitalSignature,keyEncipherment\n" "$API_HOST") \
  -out api.pem

スクリプトを実行して、作成された証明書の中身を確認します。

実行コマンド
chmod +x certs.sh && ./certs.sh
openssl x509 -in certs/ca.pem -noout -subject -ext basicConstraints
openssl x509 -in certs/server.pem -noout -subject -issuer -ext subjectAltName
openssl verify -CAfile certs/ca.pem certs/server.pem
実行結果
subject=CN=Demo Private CA
X509v3 Basic Constraints: critical
    CA:TRUE
subject=CN=mcp.internal.example.com
issuer=CN=Demo Private CA
X509v3 Subject Alternative Name: 
    DNS:mcp.internal.example.com
certs/server.pem: OK

問題なく作成できましたね!
続いてAWS側のリソースを作っていきましょう。

環境を用意する

証明書の準備ができたので、ここからは実際に動かす環境を作っていきます。

MCPサーバー本体のコード(server.py)と、VPCやEC2、プライベートホストゾーン、GatewayをまとめたCDKスタック(cdk/lib/ec2-stack.ts)を用意しました。

server.py
server.py
# /// script
# requires-python = ">=3.14,<3.15"
# dependencies = ["mcp==1.30.0"]
# ///
import os
import socket

import uvicorn
from mcp.server.fastmcp import FastMCP

# 0.0.0.0 を指定すると、Hostヘッダーの制限(DNSリバインディング対策)が自動では有効にならない
mcp = FastMCP("private-tls-demo", host="0.0.0.0", stateless_http=True, json_response=True)

@mcp.tool()
def whoami() -> str:
    """このMCPサーバーが動いているホスト名を返す"""
    return f"hello from {socket.gethostname()}"

if __name__ == "__main__":
    uvicorn.run(
        mcp.streamable_http_app(),
        host="0.0.0.0",
        port=int(os.environ.get("PORT", "443")),
        ssl_certfile=os.environ.get("CERT", "server.pem"),
        ssl_keyfile=os.environ.get("KEY", "server.key"),
    )
cdk/lib/ec2-stack.ts
cdk/lib/ec2-stack.ts
import * as fs from 'fs';
import * as path from 'path';
import * as cdk from 'aws-cdk-lib/core';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

const CERTS = path.join(__dirname, '../../certs');
const read = (name: string) => fs.readFileSync(path.join(CERTS, name), 'utf8');

export class Ec2Stack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // パブリックサブネット(NAT用)と、MCPサーバーを置くプライベートサブネット
    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
        { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
      ],
    });

    // Gateway(VPC Lattice)が作るENIに付けるSG
    const latticeSg = new ec2.SecurityGroup(this, 'LatticeSg', { vpc, allowAllOutbound: true });

    // MCPサーバーは、リソースゲートウェイのENIからの443だけを受け付ける
    const serverSg = new ec2.SecurityGroup(this, 'ServerSg', { vpc, allowAllOutbound: true });
    serverSg.addIngressRule(latticeSg, ec2.Port.tcp(443));

    // サーバー証明書・MCPサーバーのコードと、Gatewayが参照するCA証明書のPEM
    const bucket = new s3.Bucket(this, 'Bucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    const serverFiles = new s3deploy.BucketDeployment(this, 'ServerFiles', {
      destinationBucket: bucket,
      destinationKeyPrefix: 'server',
      sources: [
        s3deploy.Source.data('server.pem', read('server.pem')),
        s3deploy.Source.data('server.key', read('server.key')),
        s3deploy.Source.asset(path.join(__dirname, '../..'), { exclude: ['*', '!server.py'] }),
      ],
    });
    // Gatewayが参照するCA証明書のPEM
    new s3deploy.BucketDeployment(this, 'CaFiles', {
      destinationBucket: bucket,
      destinationKeyPrefix: 'ca',
      sources: [s3deploy.Source.data('private-ca.pem', read('ca.pem'))],
    });

    // MCPサーバー(起動時にS3から証明書とコードを取得し、systemdで起動する)
    const role = new iam.Role(this, 'ServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    bucket.grantRead(role, 'server/*');
    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      'set -euxo pipefail',
      'mkdir -p /opt/mcp && cd /opt/mcp',
      `aws s3 cp s3://${bucket.bucketName}/server/ . --recursive`,
      'chmod 600 server.key',
      'export HOME=/root',
      'curl -LsSf https://astral.sh/uv/0.9.26/install.sh | sh',
      `cat > /etc/systemd/system/mcp.service <<'UNIT'
[Unit]
Description=Private MCP server
After=network-online.target
[Service]
WorkingDirectory=/opt/mcp
Environment=HOME=/root
ExecStart=/root/.local/bin/uv run server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT`,
      'systemctl daemon-reload',
      'systemctl enable --now mcp',
    );
    const server = new ec2.Instance(this, 'Server', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, availabilityZones: [vpc.availabilityZones[0]] },
      instanceType: new ec2.InstanceType('t4g.small'),
      machineImage: ec2.MachineImage.latestAmazonLinux2023({ cpuType: ec2.AmazonLinuxCpuType.ARM_64 }),
      securityGroup: serverSg,
      role,
      userData,
    });
    server.node.addDependency(serverFiles);

    // VPC内だけで名前解決できるドメイン
    const zone = new route53.PrivateHostedZone(this, 'Zone', { zoneName: 'internal.example.com', vpc });
    new route53.ARecord(this, 'McpRecord', {
      zone,
      recordName: 'mcp',
      target: route53.RecordTarget.fromIpAddresses(server.instancePrivateIp),
      ttl: cdk.Duration.seconds(60),
    });

    // Gatewayのサービスロール(CA証明書の読み取り)
    const gatewayRole = new iam.Role(this, 'GatewayRole', {
      assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', {
        conditions: {
          StringEquals: { 'aws:SourceAccount': this.account },
          ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:gateway/*` },
        },
      }),
    });
    bucket.grantRead(gatewayRole, 'ca/*');
    gatewayRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ['secretsmanager:GetSecretValue'],
        resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:gw-tls-demo-ca-*`],
      }),
    );

    // IAM認証のMCPゲートウェイ(ターゲットはCLIで登録する)
    const gateway = new agentcore.CfnGateway(this, 'Gateway', {
      name: 'private-tls-demo',
      protocolType: 'MCP',
      authorizerType: 'AWS_IAM',
      roleArn: gatewayRole.roleArn,
    });

    // ターゲット登録時に --private-endpoint へそのまま渡すJSON
    const privateEndpoint = {
      managedVpcResource: {
        vpcIdentifier: vpc.vpcId,
        subnetIds: vpc.privateSubnets.map((s) => s.subnetId),
        endpointIpAddressType: 'IPV4',
        securityGroupIds: [latticeSg.securityGroupId],
      },
    };

    new cdk.CfnOutput(this, 'GatewayId', { value: gateway.attrGatewayIdentifier });
    new cdk.CfnOutput(this, 'GatewayUrl', { value: gateway.attrGatewayUrl });
    new cdk.CfnOutput(this, 'PrivateEndpoint', { value: this.toJsonString(privateEndpoint) });
    new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
    new cdk.CfnOutput(this, 'ServerInstanceId', { value: server.instanceId });
  }
}
cdk/bin/app.ts
cdk/bin/app.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { Ec2Stack } from '../lib/ec2-stack';
import { ApigwStack } from '../lib/apigw-stack';

const app = new cdk.App();
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'ap-northeast-1' };

// 本編:プライベートCAの証明書を使うMCPサーバー(EC2)
new Ec2Stack(app, 'GwTlsEc2', { env });

// おまけ:プライベートCAの証明書を付けたプライベートAPI Gateway
// ACMにインポートした証明書のARNを渡したときだけ作る(cdk deploy GwTlsApigw -c certificateArn=...)
const certificateArn = app.node.tryGetContext('certificateArn');
if (certificateArn) {
  new ApigwStack(app, 'GwTlsApigw', { env, certificateArn });
}

MCPサーバー側のセキュリティグループでは、Lattice用SGからの443番ポートへの通信を許可しています。また、GatewayのサービスロールにはS3の ca/ 配下にあるCA証明書を読み取る権限を付けておきます。Gateway自体もCDKで作成し、後のターゲット登録で使う設定値をスタックの出力に含めました。

cdk/lib/ec2-stack.ts(抜粋)
// MCPサーバーは、リソースゲートウェイのENIからの443だけを受け付ける
serverSg.addIngressRule(latticeSg, ec2.Port.tcp(443));
bucket.grantRead(gatewayRole, 'ca/*');

// IAM認証のMCPゲートウェイ(ターゲットはCLIで登録する)
const gateway = new agentcore.CfnGateway(this, 'Gateway', {
  name: 'private-tls-demo',
  protocolType: 'MCP',
  authorizerType: 'AWS_IAM',
  roleArn: gatewayRole.roleArn,
});

サーバー証明書と server.py はCDKによってS3へ配置され、EC2の起動時に server/ 配下のファイルを取得してsystemdでMCPサーバーが立ち上がる仕組みです。スタックをデプロイしたら、後続のコマンドで利用する各パラメータを変数に読み込んでおきます。

スタックのデプロイ
(cd cdk && pnpm cdk deploy GwTlsEc2)

out() { aws cloudformation describe-stacks --region ap-northeast-1 --stack-name GwTlsEc2 \
  --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text; }
GW_ID=$(out GatewayId)        # GatewayのID
GW_URL=$(out GatewayUrl)      # GatewayのURL
PE=$(out PrivateEndpoint)     # --private-endpoint に渡すJSON(VPC・サブネット・SG)
B=$(out BucketName)
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)

ターゲットを登録する

ターゲットの登録にはAWS CLIを使います。執筆時点ではCloudFormation(CDK)のターゲットリソースが certificateConfigurations に対応していなかったためです。

アップデートで追加された --certificate-configurations オプションに、CA証明書を配置したS3 URIを指定します。

with-ca ターゲットの作成
aws bedrock-agentcore-control create-gateway-target --region ap-northeast-1 \
  --gateway-identifier "$GW_ID" \
  --name with-ca \
  --target-configuration '{"mcp": {"mcpServer": {"endpoint": "https://mcp.internal.example.com/mcp"}}}' \
  --private-endpoint "$PE" \
  --certificate-configurations "[{\"s3\": {\"uri\": \"s3://$B/ca/private-ca.pem\", \"bucketOwnerAccountId\": \"$ACCOUNT\"}}]"

証明書の指定先には s3 または secretsManager を選べます。今回は s3 を指定しました。

作成直後のステータスは CREATING です。進捗は get-gateway-target で確認できます。

ステータスの確認
aws bedrock-agentcore-control get-gateway-target --region ap-northeast-1 \
  --gateway-identifier "$GW_ID" --target-id <ターゲットID>

約3分半待つと READY になりました!

get-target の結果(抜粋)
{
  "status": "READY",
  "name": "with-ca",
  "privateEndpointManagedResources": [
    {
      "domain": "mcp.internal.example.com",
      "resourceGatewayArn": "arn:aws:vpc-lattice:ap-northeast-1:<アカウントID>:resourcegateway/rgw-0bd23d3dff8cd1f64"
    }
  ],
  "certificateConfigurations": [
    {
      "s3": {
        "uri": "s3://agentcore-gw-tls-<アカウントID>/ca/private-ca.pem",
        "bucketOwnerAccountId": "<アカウントID>"
      }
    }
  ]
}

CA証明書のPEMを指定して、VPC内のMCPサーバーをターゲットとして無事に登録できました。

続いてツールを実行してみます。

ツールを呼び出す

ターゲットが利用可能になったので、実際にツールを呼び出してみます。curlの --aws-sigv4 オプションでSigV4署名を付与して、MCPのJSON-RPCリクエストを直接送信します。

ツールの呼び出し
eval "$(aws configure export-credentials --format env)"

curl -s "$GW_URL" \
  --aws-sigv4 "aws:amz:ap-northeast-1:bedrock-agentcore" \
  --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
  -H "x-amz-security-token: $AWS_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "with-ca___whoami", "arguments": {}}}'
tools/call の結果(抜粋)
{
  "result": {
    "content": [
      {"type": "text", "text": "hello from ip-x-xx-xxx-xxx.ap-northeast-1.compute.internal"}
    ],
    "isError": false
  }
}

EC2のホスト名が無事返ってきました!ツールの呼び出しにも成功し、ALBなしでVPC内のサーバーへしっかりつながっていることが確認できましたね。

CA証明書を指定しないとどうなるか

正常に動くことが確認できたので、続けてCA証明書を指定しなかった場合の挙動も試してみます。

--certificate-configurations を省いて no-ca という名前でターゲットを作成します。

no-ca ターゲットの作成
aws bedrock-agentcore-control create-gateway-target --region ap-northeast-1 \
  --gateway-identifier "$GW_ID" \
  --name no-ca \
  --target-configuration '{"mcp": {"mcpServer": {"endpoint": "https://mcp.internal.example.com/mcp"}}}' \
  --private-endpoint "$PE"

実行から約4分後、ステータスが FAILED になりました。

get-target の結果(抜粋)
{
  "status": "FAILED",
  "statusReasons": [
    "Failed to connect and fetch tools from the provided MCP target server. Error - (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target"
  ]
}

デフォルトでは公開CAしか信頼しないので、プライベートCAの証明書は検証エラーになります。
当たり前ですが失敗しますね。

うまくいかないパターン

ほかにも、うまくいかないパターンをいくつか試したので結果をまとめておきます。

試したこと 結果
CAではない証明書(サーバー証明書)を指定 FAILED(Certificate is not a CA certificate)
期限切れのCAを指定 FAILED(Certificate has expired)
S3にないオブジェクトを指定 FAILED(Unable to access S3 object)
別のCAを指定 FAILED(no-ca と同じPKIXのエラー)
SANと合わないホスト名で接続 READY になるが、ツールの呼び出しでエラー(Hostname verification failed)
動作中のターゲットを期限切れのCAに更新 UPDATE_UNSUCCESSFUL。元の証明書のまま動き続けた

ターゲット作成時にCA証明書そのもの、ツール一覧の取得時に証明書チェーン、ツールの呼び出し時にホスト名が確認され、それぞれで失敗したパターンを並べた図

CA証明書そのものの問題は、作成時に理由つきで返ってくるので分かりやすいですね。気をつけたいのはSANの不一致で、READY になってもツールを呼び出すまで気づけません。作成後に一度ツールを呼び出して確認しておくのが安全です。

Secrets Managerに置いたCA証明書を参照する

ここまではS3を使ってきましたが、CA証明書はSecrets Managerにも配置できます。PEM形式のテキストをそのままシークレットに保存して、ターゲット側の設定で secretsManager にシークレットのARNを指定する形です。

シークレットの作成とターゲットの登録
SECRET_ARN=$(aws secretsmanager create-secret --region ap-northeast-1 \
  --name gw-tls-demo-ca --secret-string file://certs/ca.pem --query ARN --output text)

aws bedrock-agentcore-control create-gateway-target --region ap-northeast-1 \
  --gateway-identifier "$GW_ID" \
  --name with-secret \
  --target-configuration '{"mcp": {"mcpServer": {"endpoint": "https://mcp.internal.example.com/mcp"}}}' \
  --private-endpoint "$PE" \
  --certificate-configurations "[{\"secretsManager\": {\"secretArn\": \"$SECRET_ARN\"}}]"

この構成でもターゲットのステータスは READY に遷移し、ツールの呼び出しまで無事に成功しました。

証明書の期限をCloudWatchで確認する

運用面で気にしておきたいのが証明書の有効期限ですが、CA証明書の残り日数は、CloudWatchメトリクスの EarliestCertificateDaysToExpiry(名前空間は AWS/Bedrock-AgentCore)に出力されます。(用意されているんですね・・・)

ツールを呼び出して数分待つとメトリクスが確認できるようになりました。

実行コマンド
aws cloudwatch get-metric-statistics --region ap-northeast-1 \
  --namespace AWS/Bedrock-AgentCore --metric-name EarliestCertificateDaysToExpiry \
  --dimensions Name=Resource,Value=<GatewayのARN> Name=Operation,Value=InvokeGateway \
               Name=Method,Value=tools/call Name=Protocol,Value=MCP Name=Name,Value=with-ca___whoami \
  --start-time <開始時刻> --end-time <終了時刻> --period 60 --statistics Minimum
実行結果(抜粋)
Minimum: 3649.0

有効期間10年のCA証明書を当日に作成したため、値は3649日になっています。期限が切れるとツールの呼び出しに失敗するため、残り日数が少なくなったら通知するCloudWatchアラームを設定しておくと安心ですね。

後片付け

Gatewayはターゲットが残っている状態では削除できないため、ターゲットを先にすべて削除してからCDKのスタックを削除します。

後片付け
for t in $(aws bedrock-agentcore-control list-gateway-targets --region ap-northeast-1 \
    --gateway-identifier "$GW_ID" --query 'items[].targetId' --output text); do
  aws bedrock-agentcore-control delete-gateway-target --region ap-northeast-1 \
    --gateway-identifier "$GW_ID" --target-id "$t"
  sleep 5  # まとめて消すとスロットリングされることがあった
done
aws secretsmanager delete-secret --region ap-northeast-1 --secret-id gw-tls-demo-ca --force-delete-without-recovery
(cd cdk && pnpm cdk destroy GwTlsEc2)

ターゲットがなくなると、VPC LatticeのリソースゲートウェイもGateway側で自動的に削除されます。

プライベートなAPI Gatewayでも試してみた

冒頭で触れたプライベートなAPI Gatewayでも試してみました。

想定するのは、APIをVPC内から呼び出す構成にしていて、独自ドメイン api.internal.example.com をプライベートCAの証明書で設定しているケースです。プライベートなAPIにはAPI Gatewayターゲットが使えないため、OpenAPIターゲットとして登録します。デフォルトのエンドポイント(execute-api)を使う構成であればAWSの公開証明書が適用されるので、CA証明書の指定は不要です!

プライベートCAのPEMを指定したOpenAPIターゲットから、VPC Latticeとexecute-apiのVPCエンドポイント経由でプライベートAPI Gatewayのカスタムドメインに接続する構成図

APIはMock統合でJSONを返す構成とし、VPC、VPCエンドポイント、API、Gatewayは本編と同様にCDKで構築しました。ACMにインポートしたプライベートCA署名の証明書を、プライベートなカスタムドメインに紐付けています。

cdk/lib/apigw-stack.ts
cdk/lib/apigw-stack.ts
import * as path from 'path';
import * as cdk from 'aws-cdk-lib/core';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

export interface ApigwStackProps extends cdk.StackProps {
  certificateArn: string;
}

const DOMAIN = 'api.internal.example.com';

export class ApigwStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ApigwStackProps) {
    super(scope, id, props);

    // NATなしのプライベートサブネットだけのVPC
    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.1.0.0/16'),
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [{ name: 'private', subnetType: ec2.SubnetType.PRIVATE_ISOLATED }],
    });

    // Gateway(VPC Lattice)が作るENIに付けるSG
    const latticeSg = new ec2.SecurityGroup(this, 'LatticeSg', { vpc, allowAllOutbound: true });

    // execute-apiのVPCエンドポイントは、リソースゲートウェイのENIからの443だけを受け付ける
    const vpceSg = new ec2.SecurityGroup(this, 'VpceSg', { vpc, allowAllOutbound: false });
    vpceSg.addIngressRule(latticeSg, ec2.Port.tcp(443));
    const vpce = vpc.addInterfaceEndpoint('ExecuteApi', {
      service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY,
      privateDnsEnabled: false,
      securityGroups: [vpceSg],
      open: false,
    });

    // VPCエンドポイント経由の呼び出しだけを許可するポリシー
    const fromVpce = new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          principals: [new iam.AnyPrincipal()],
          actions: ['execute-api:Invoke'],
          resources: ['execute-api:/*'],
          conditions: { StringEquals: { 'aws:SourceVpce': vpce.vpcEndpointId } },
        }),
      ],
    });

    // Mock統合でJSONを返すだけのプライベートAPI
    const api = new apigw.RestApi(this, 'Api', {
      restApiName: 'gw-tls-private-api',
      endpointTypes: [apigw.EndpointType.PRIVATE],
      policy: fromVpce,
      deployOptions: { stageName: 'prod' },
    });
    api.root.addResource('hello').addMethod(
      'GET',
      new apigw.MockIntegration({
        requestTemplates: { 'application/json': '{"statusCode": 200}' },
        integrationResponses: [
          {
            statusCode: '200',
            responseTemplates: { 'application/json': '{"message": "hello from private API Gateway"}' },
          },
        ],
      }),
      { methodResponses: [{ statusCode: '200' }] },
    );

    // プライベートCAで署名した証明書を付けたプライベートなカスタムドメイン
    const domain = new apigw.CfnDomainNameV2(this, 'PrivateDomain', {
      domainName: DOMAIN,
      certificateArn: props.certificateArn,
      endpointConfiguration: { types: ['PRIVATE'] },
      securityPolicy: 'TLS_1_2',
      policy: fromVpce.toJSON(),
    });
    new apigw.CfnBasePathMappingV2(this, 'Mapping', {
      domainNameArn: domain.attrDomainNameArn,
      restApiId: api.restApiId,
      stage: api.deploymentStage.stageName,
    });
    new apigw.CfnDomainNameAccessAssociation(this, 'AccessAssociation', {
      domainNameArn: domain.attrDomainNameArn,
      accessAssociationSource: vpce.vpcEndpointId,
      accessAssociationSourceType: 'VPCE',
    });

    // VPC内だけで名前解決できるドメインを、VPCエンドポイントに向ける
    const zone = new route53.PrivateHostedZone(this, 'Zone', { zoneName: 'internal.example.com', vpc });
    new route53.ARecord(this, 'ApiRecord', {
      zone,
      recordName: DOMAIN,
      target: route53.RecordTarget.fromAlias(new targets.InterfaceVpcEndpointTarget(vpce)),
    });

    // Gatewayが参照するCA証明書のPEM
    const bucket = new s3.Bucket(this, 'CaBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    new s3deploy.BucketDeployment(this, 'CaPem', {
      destinationBucket: bucket,
      destinationKeyPrefix: 'ca',
      sources: [s3deploy.Source.asset(path.join(__dirname, '../../certs'), { exclude: ['*', '!ca.pem'] })],
    });
    // OpenAPIターゲットに渡すAPIの定義(serversにカスタムドメインを書く)
    new s3deploy.BucketDeployment(this, 'OpenApi', {
      destinationBucket: bucket,
      destinationKeyPrefix: 'openapi',
      sources: [s3deploy.Source.asset(path.join(__dirname, '../../openapi'))],
    });

    // Gatewayのサービスロール(CA証明書とOpenAPI定義の読み取り)
    const gatewayRole = new iam.Role(this, 'GatewayRole', {
      assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', {
        conditions: {
          StringEquals: { 'aws:SourceAccount': this.account },
          ArnLike: { 'aws:SourceArn': `arn:aws:bedrock-agentcore:${this.region}:${this.account}:gateway/*` },
        },
      }),
    });
    bucket.grantRead(gatewayRole, 'ca/*');
    bucket.grantRead(gatewayRole, 'openapi/*');

    // IAM認証のMCPゲートウェイ(ターゲットはCLIで登録する)
    const gateway = new agentcore.CfnGateway(this, 'Gateway', {
      name: 'private-tls-apigw',
      protocolType: 'MCP',
      authorizerType: 'AWS_IAM',
      roleArn: gatewayRole.roleArn,
    });

    // ターゲット登録時に --private-endpoint へそのまま渡すJSON
    const privateEndpoint = {
      managedVpcResource: {
        vpcIdentifier: vpc.vpcId,
        subnetIds: vpc.isolatedSubnets.map((s) => s.subnetId),
        endpointIpAddressType: 'IPV4',
        securityGroupIds: [latticeSg.securityGroupId],
      },
    };

    new cdk.CfnOutput(this, 'GatewayId', { value: gateway.attrGatewayIdentifier });
    new cdk.CfnOutput(this, 'GatewayUrl', { value: gateway.attrGatewayUrl });
    new cdk.CfnOutput(this, 'PrivateEndpoint', { value: this.toJsonString(privateEndpoint) });
    new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
  }
}
cdk/lib/apigw-stack.ts(抜粋)
// プライベートCAで署名した証明書を付けたプライベートなカスタムドメイン
const domain = new apigw.CfnDomainNameV2(this, 'PrivateDomain', {
  domainName: 'api.internal.example.com',
  certificateArn: props.certificateArn,
  endpointConfiguration: { types: ['PRIVATE'] },
  securityPolicy: 'TLS_1_2',
  policy: fromVpce.toJSON(),
});

証明書は、本編で使ったプライベートCAで api.internal.example.com 用のサーバー証明書を作成し(certs.sh の末尾)、ACMへインポートしています。インポートした証明書のARNをパラメータに渡してスタックをデプロイします。

証明書のインポートとスタックのデプロイ
CERT_ARN=$(aws acm import-certificate --region ap-northeast-1 \
  --certificate fileb://certs/api.pem --private-key fileb://certs/api.key \
  --certificate-chain fileb://certs/ca.pem --query CertificateArn --output text)
(cd cdk && pnpm cdk deploy GwTlsApigw -c certificateArn=$CERT_ARN)

out() { aws cloudformation describe-stacks --region ap-northeast-1 --stack-name GwTlsApigw \
  --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text; }
GW_ID=$(out GatewayId); GW_URL=$(out GatewayUrl); PE=$(out PrivateEndpoint); B=$(out BucketName)
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)

OpenAPIの定義ファイルでは servers にカスタムドメインを指定し、CDK経由でS3へアップロードしています。

openapi/private-api.json
{
  "openapi": "3.0.1",
  "info": {"title": "private-api", "version": "1.0"},
  "servers": [{"url": "https://api.internal.example.com"}],
  "paths": {
    "/hello": {
      "get": {
        "operationId": "getHello",
        "description": "プライベートAPI Gatewayからあいさつを返す",
        "responses": {"200": {"description": "OK"}}
      }
    }
  }
}

ターゲットの登録は本編と同様にCLIから実行します。--target-configuration にOpenAPI定義のS3 URIを指定します。

OpenAPIターゲットの作成
aws bedrock-agentcore-control create-gateway-target --region ap-northeast-1 \
  --gateway-identifier "$GW_ID" \
  --name with-ca \
  --target-configuration "{\"mcp\": {\"openApiSchema\": {\"s3\": {\"uri\": \"s3://$B/openapi/private-api.json\"}}}}" \
  --private-endpoint "$PE" \
  --certificate-configurations "[{\"s3\": {\"uri\": \"s3://$B/ca/ca.pem\", \"bucketOwnerAccountId\": \"$ACCOUNT\"}}]"

およそ4分でステータスが READY に変わりました。本編と同じcurlコマンドで with-ca___getHello を呼び出すと、APIからの応答がしっかり返ってきました!

tools/call の結果(抜粋)
{
  "result": {
    "isError": false,
    "content": [
      {"type": "text", "text": "{\"message\":\"hello from private API Gateway\"}"}
    ]
  }
}

CA証明書を指定しないターゲット(no-ca)も作成して比較してみました。こちらもターゲット自体は READY に遷移しますが、ツールを実行した段階でエラーが返されます。

tools/call の結果(no-ca、抜粋)
{
  "result": {
    "isError": true,
    "content": [
      {"type": "text", "text": "An internal error occurred. Please retry later."}
    ]
  }
}

MCPサーバーターゲットの場合は登録処理の中でツール一覧を取りに行くため、CAの検証に通らないと作成時点で FAILED になります。一方、OpenAPIターゲットは定義ファイルからツール一覧を生成する仕組みなので、作成段階では実際のバックエンドへ疎通を確認していないようです。

エラーメッセージにも証明書に関する具体的な記述が出ないため、原因の切り分けで迷いやすいポイントなため、OpenAPIターゲットを使用する際も、作成を終えたら一度ツールを呼び出して疎通を確かめておくのが安心かと思います!

おわりに

CA証明書のPEMを指定し、プライベートCAの証明書を使うMCPサーバーにALBなしでつながりました!少しニッチなアップデートですがターゲットの幅が広がり、使えるケースもあるのではないでしょうか!

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事