はじめに
API GatewayでプライベートREST APIを作って、今までは社内ネットワークからのアクセスに限定してたんだけど
色々あって、インターネット経由で同じAPIを呼べるようにしたいんだよね!!
インターネットに公開するための追加のリソース構築はしょうがないけど、
既存のアプリケーションとリソースの変更は避けたいんだよね!!
CloudFront VPCオリジンっていうやつを使えば、簡単に実現できそうだよね!!
あ、既存のAWSアカウントは、あくまで社内向けのプライベート環境って位置づけになってて、それを変えるのは面倒だから
インターネット公開に必要なCloudFrontとかWAFとかは、インターネット公開環境として別アカウントに分離するようにしてね!!
とりあえずドメインは取得せずに、cloudfront.netドメインのURLでアクセスできたらいいから!!
じゃあよろしくね!!
という状況を想定して検証してみました(本当にこんな状況があるのかはさておき)
そういえばVPCオリジンのクロスアカウント構成ってできたっけ? と不安になりましたが、ほぼ1年前からできてました!!
[アップデート] Amazon CloudFront の VPC オリジンのクロスアカウントがサポートされました
API Gatewayの「プライベートREST API」とは
Amazon VPC内からのみ呼び出せるREST APIです。
AWS公式も「プライベートREST API」と表現しているので、この記事でも同様の表現を採用します。
プライベートREST APIへアクセスするには、VPC内にインターフェイスVPCエンドポイントを作成します。
クライアントからのリクエストは、このVPCエンドポイントを経由してAPI Gatewayへ到達するため、インターネットを経由することなくAPIを呼び出せます。
また、オンプレミス環境からも、Direct Connectなどを利用してVPCと接続することで、プライベートREST APIを呼び出すことができます。
アクセス制御にはAPI Gatewayのリソースポリシーを利用でき、aws:SourceVpcやaws:SourceVpceを条件に指定することで、特定のVPCやVPCエンドポイントからのアクセスだけを許可できます。
さらに、VPCエンドポイントポリシーを利用して、VPCエンドポイントから呼び出せるAPIを制御することも可能です。
目指す構成とポイント
今回の検証で目指す全体像は次のとおりです。
グレーで網掛けしている箇所が、「既存のプライベートREST API」を想定した箇所です。
赤字が通信プロトコルです。独自ドメインを取得しない構成を考えると、こうなりました。
オリジンALBのリスナールールでリクエストを加工する
今回の検証のポイントとなったのは、CloudFrontのオリジンとなるALBでした。
(図中の「オリジンALB」・検証上のリソース名は xapi-demo-origin-alb)
このALBは負荷分散が目的ではなく、CloudFrontからのリクエストを受け、そのリクエストを加工し、API Gatewayへ流すために配置します。
「リクエストの加工」については、以下の2点を目的にしています。
①Hostヘッダーの書き換え
②ステージを含んだパスへの整形
この2点の加工を、今回はALBのリスナールールのトランスフォームに担わせました。
パスの整形はCloudFrontのオリジンパスの設定でもできるのですが、一カ所に寄せられるなら寄せたほうがきれいかなと。
①Hostヘッダーの書き換えについて
VPCエンドポイント経由でプライベートAPIを呼び出す際、API Gatewayは Host ヘッダーまたは x-apigw-api-id ヘッダーを使用して、呼び出し対象のAPIを特定します。
今回は、既存の社内からのリクエストでは x-apigw-api-id ヘッダーを使用せず、Host ヘッダーによってAPIを特定していることを想定します。
既存のリクエスト形式をできるだけ維持しながらインターネットからもアクセスできるよう、API Gatewayが要求する形式への変換をオリジンALB側で行う方針としました。
具体的には、オリジンALBのリスナールールで Host ヘッダーを xxxxx.cloudfront.net から {api-id}.execute-api.ap-northeast-1.amazonaws.com に書き換えたうえで、API Gatewayへリクエストを転送します。
②ステージを含んだパスへの整形について
API GatewayのREST APIはステージを含むパス /{stage}/{resource-path} で呼び出す必要がありますが、私個人の意見としては、利用者にステージ(例:prod、v1など)を意識させるのはアンチパターンだと考えます。
ステージはデプロイや環境管理のための内部的な概念であり、利用者が使うAPIのURL(=エンドポイント)と分離して考えられる構成にしたほうが、将来的な構成変更の影響を利用者に及ぼしにくくなるという考えです。
ということで、今回は、利用者が叩くURLでステージを意識しなくてもよいように、システム側でCloudFrontから受け取ったパスにステージを追加することにしました。
なお、今回の検証の構成では、オリジンALBはCloudFrontからの通信をHTTPで受けるようになります。
ここは当初、ALBに自己署名証明書(オレオレ証明書)を登録してCloudFrontからの通信をHTTPSで終端させたいと思ってやってみたんですが、ダメでした。
CloudFrontの証明書検証の仕様に引っかかって502 (Bad Gateway) になりました。
失効した証明書、無効な証明書、または自己署名証明書をオリジンサーバーが返したり、間違った順番の証明書チェーンを返したりした場合、CloudFront は TCP 接続を中断し、HTTP ステータスコード 502 (Bad Gateway) を返して、X-Cache ヘッダーを Error from cloudfront に設定します。中間証明書を含む、証明書チェーンが完全でない場合も、CloudFront は TCP 接続を中断します。
CloudFrontがオリジンとHTTPSで通信するとき、オリジンが返す証明書は信頼された認証機関(Mozilla収録のCA一覧)によって署名されている必要があり、自己署名証明書やプライベートCA発行の証明書は使えません。
この要件は、VPCオリジン+内部ALBだからといって変わるわけではありませんでした😇
VPCオリジンだからといってInternet Gatewayが不要ということにはならない
冒頭のこちらの要件(?)について。
あ、既存のAWSアカウントは、あくまで社内向けのプライベート環境って位置づけになってて、それを変えるのは面倒だから
インターネット公開に必要なCloudFrontとかWAFとかは、インターネット公開環境として別アカウントに分離するようにしてね!!
CloudFrontとWAFを別アカウントに切り出すことはできましたが、Internet Gateway(IGW)だけは既存環境のVPCにアタッチする必要がありました。
ただ、IGWはVPCにアタッチするだけでよくて、ルートテーブルでIGW向けのルートを書く必要はないとのこと。
VPC オリジンリソースを持つ VPC にインターネットゲートウェイを追加する必要があります。インターネットゲートウェイは VPC がインターネットからトラフィックを受信できることを示すために必要です。インターネットゲートウェイは、サブネット内のオリジンへのトラフィックのルーティングには使用されないため、ルーティングポリシーを更新する必要はありません。
つまり、いわゆる「パブリックサブネット」を作ることにはならないからOK!! …ということにしておきたいと思います😅
やってみる
それでは上記の環境を実際に作って、検証していきます。
「既存のプライベートREST API」は東京リージョンで稼働しているものと想定します。
また、構築は、できる限りCloudFormationで進めていきます。
ステップ0 AWSアカウントの準備・RAM共有の設定
AWSアカウントを2つ用意します(アカウントA=プライベート環境、アカウントB=インターネット公開環境)
私は 「おひとり様」AWS Organizations を持っているので、Organizationsからアカウントを発行しました。
各アカウントへのアクセスは、IAMユーザを作らず、「OrganizationAccountAccessRole」を利用できて便利!!
ステップ1 ネットワークとバックエンド(アカウントA / 東京)
まずVPC、サブネット、IGW、そしてバックエンドのEC2を作ります。
CloudFront VPCオリジンを後で作ることを考えて、この段階でVPCにIGWをアタッチしておきます(繰り返しですが、IGWはVPCにアタッチするだけで、ルートテーブルにIGW向けの経路は書きません)。
バックエンドは、POSTを受け取って本文をそのまま返すだけの確認用サーバです。
今回はAmazon Linux 2023で、標準で入っているPython 3の標準ライブラリでサーバを構成しました。
# 01-network-backend.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo network and backend (Account A / ap-northeast-1)
Parameters:
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.20.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags: [{ Key: Name, Value: xapi-demo-vpc }]
Igw:
Type: AWS::EC2::InternetGateway
Properties:
Tags: [{ Key: Name, Value: xapi-demo-igw }]
IgwAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref Vpc
InternetGatewayId: !Ref Igw
SubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.20.1.0/24
AvailabilityZone: ap-northeast-1a
Tags: [{ Key: Name, Value: xapi-demo-subnet-a }]
SubnetC:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.20.2.0/24
AvailabilityZone: ap-northeast-1c
Tags: [{ Key: Name, Value: xapi-demo-subnet-c }]
BackendSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: xapi-demo backend ec2 sg
VpcId: !Ref Vpc
# バックエンドALB(VPC 内)からのヘルスチェックと転送を受けるため VPC CIDR からの 80 を許可
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 10.20.0.0/16
Tags: [{ Key: Name, Value: xapi-demo-backend-sg }]
Backend:
Type: AWS::EC2::Instance
Properties:
InstanceType: t3.micro
ImageId: !Ref LatestAmiId
SubnetId: !Ref SubnetA
SecurityGroupIds: [!Ref BackendSg]
# Python 標準ライブラリだけで echo サーバを常駐させる。パッケージ取得は発生しない。
UserData:
Fn::Base64: |
#!/bin/bash
cat > /opt/echo-server.py <<'PY'
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def _send(self, body: bytes):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# ALB ヘルスチェック用(GET /echo)
def do_GET(self):
self._send(b'{"status":"ok"}')
# 受け取った本文をそのまま返す(echo)
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b'{"status":"ok"}'
self._send(body)
HTTPServer(("0.0.0.0", 80), Handler).serve_forever()
PY
cat > /etc/systemd/system/echo.service <<'UNIT'
[Unit]
Description=xapi-demo echo server
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/echo-server.py
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now echo.service
Tags: [{ Key: Name, Value: xapi-demo-backend }]
Outputs:
VpcId: { Value: !Ref Vpc, Export: { Name: xapi-demo-vpc-id } }
SubnetAId: { Value: !Ref SubnetA, Export: { Name: xapi-demo-subnet-a-id } }
SubnetCId: { Value: !Ref SubnetC, Export: { Name: xapi-demo-subnet-c-id } }
BackendIp: { Value: !GetAtt Backend.PrivateIp, Export: { Name: xapi-demo-backend-ip } }
ここでVPC内にCloudShellを立ち上げて、EC2のプライベートIPへ直接curlすることで、バックエンドのアプリの動きを確認しておきます。
# 上記で作ったVPC内でCloudShellを起動
curl http://<バックエンドEC2のプライベートIP>/echo
## '{"status":"ok"}' と返ってくればOK(ALBのヘルスチェック用のパス)
curl -X POST http://<バックエンドEC2のプライベートIP>/echo -d '{"hello":"world"}'
## POSTした内容 '{"hello":"world"}' がそのまま返ってくればOK
ステップ2 バックエンドALBとVPC Link v2(アカウントA / 東京)
次に、バックエンドの内部ALBと、API Gatewayからそれに繋ぐためのVPC Link v2を作ります。
# 02-backend-alb-vpclink.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo backend ALB and VPC Link v2 (Account A / ap-northeast-1)
Resources:
# VPC Link v2 の ENI が使うセキュリティグループ
VpcLinkSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: xapi-demo vpclink v2 eni sg
VpcId: !ImportValue xapi-demo-vpc-id
Tags: [{ Key: Name, Value: xapi-demo-vpclink-sg }]
BackendAlbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: xapi-demo backend alb sg
VpcId: !ImportValue xapi-demo-vpc-id
# バックエンドALBは VPC Link v2 の ENI からの 80 のみ受ける
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !Ref VpcLinkSg
Tags: [{ Key: Name, Value: xapi-demo-backend-alb-sg }]
BackendTg:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: xapi-demo-backend-tg
TargetType: ip
Protocol: HTTP
Port: 80
VpcId: !ImportValue xapi-demo-vpc-id
# ヘルスチェックは GET /echo
HealthCheckProtocol: HTTP
HealthCheckPath: /echo
HealthCheckPort: traffic-port
HealthCheckIntervalSeconds: 15
HealthCheckTimeoutSeconds: 5
HealthyThresholdCount: 2
UnhealthyThresholdCount: 2
Matcher: { HttpCode: '200' }
Targets:
- Id: !ImportValue xapi-demo-backend-ip
Port: 80
BackendAlb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: xapi-demo-backend-alb
Type: application
Scheme: internal
SecurityGroups: [!Ref BackendAlbSg]
Subnets:
- !ImportValue xapi-demo-subnet-a-id
- !ImportValue xapi-demo-subnet-c-id
LoadBalancerAttributes:
# Desync 緩和モードをモニタリングに
- Key: routing.http.desync_mitigation_mode
Value: monitor
# ARC ゾーンシフト統合を有効化
- Key: zonal_shift.config.enabled
Value: 'true'
BackendListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref BackendAlb
Protocol: HTTP
Port: 80
DefaultActions:
- Type: forward
TargetGroupArn: !Ref BackendTg
# VPC Link v2
VpcLink:
Type: AWS::ApiGatewayV2::VpcLink
Properties:
Name: xapi-demo-vpclink
SubnetIds:
- !ImportValue xapi-demo-subnet-a-id
- !ImportValue xapi-demo-subnet-c-id
SecurityGroupIds: [!Ref VpcLinkSg]
Outputs:
VpcLinkId: { Value: !Ref VpcLink, Export: { Name: xapi-demo-vpclink-id } }
BackendAlbArn: { Value: !Ref BackendAlb, Export: { Name: xapi-demo-backend-alb-arn } }
BackendAlbDns: { Value: !GetAtt BackendAlb.DNSName, Export: { Name: xapi-demo-backend-alb-dns } }
BackendListenerArn: { Value: !Ref BackendListener, Export: { Name: xapi-demo-backend-listener-arn } }
BackendAlbSgId: { Value: !Ref BackendAlbSg, Export: { Name: xapi-demo-backend-alb-sg-id } }
ステップ3 VPCエンドポイント、REST API、リソースポリシー(アカウントA / 東京)
execute-api のインターフェース型VPCエンドポイント、REST API本体、リソースとメソッド、そしてリソースポリシーまでを作成します。
ALBへのプライベート統合そのものもCloudFormationで作ろうとしましたが、うまくいかなかった・・・本筋ではないので諦めました😅
この記事で実践されているように、AWS::ApiGateway::Method の IntegrationTarget を使うんだと思うのですが、今回実際に IntegrationTarget を指定してデプロイすると、Invalid integration URI specified というエラーで失敗しました。。
これは私の書き方が悪いんだろうと思いますが、何が悪いのか分からない。。
という状況なので、とりあえずうまくいった手順で記事を書き進めます。
突破口が見えたらこの手順も修正します。
# 03-api-vpce.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo execute-api VPCE, REST API skeleton, resource policy (Account A / ap-northeast-1)
Resources:
VpceSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: xapi-demo execute-api vpce sg
VpcId: !ImportValue xapi-demo-vpc-id
# 簡略化のため VPC CIDR 全体から 443 を許可(同一 VPC 内のオリジンALBからの通信もここで通る)
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 10.20.0.0/16
Tags: [{ Key: Name, Value: xapi-demo-execapi-vpce-sg }]
ExecApiVpce:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcEndpointType: Interface
ServiceName: com.amazonaws.ap-northeast-1.execute-api
VpcId: !ImportValue xapi-demo-vpc-id
PrivateDnsEnabled: true
SubnetIds:
- !ImportValue xapi-demo-subnet-a-id
- !ImportValue xapi-demo-subnet-c-id
SecurityGroupIds: [!Ref VpceSg]
RestApi:
Type: AWS::ApiGateway::RestApi
Properties:
Name: xapi-demo-api
EndpointConfiguration:
Types: [PRIVATE]
# Resource ARN に自分自身(!Ref RestApi)を書くと循環参照になるため、API ID はワイルドカードで逃がす
# プライベートAPIとしての「指定 VPCE 以外は Deny、指定したものは Allow」という考え方はそのまま踏襲
Policy:
Version: "2012-10-17"
Statement:
- Effect: Deny
Principal: "*"
Action: execute-api:Invoke
Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:*/*/*/*"
Condition:
StringNotEquals:
aws:SourceVpce: !Ref ExecApiVpce
- Effect: Allow
Principal: "*"
Action: execute-api:Invoke
Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:*/*/*/*"
EchoResource:
Type: AWS::ApiGateway::Resource
Properties:
RestApiId: !Ref RestApi
ParentId: !GetAtt RestApi.RootResourceId
PathPart: echo
# POST メソッドの器だけ作る。統合は次のステップで CLI から設定
EchoMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref RestApi
ResourceId: !Ref EchoResource
HttpMethod: POST
AuthorizationType: NONE
Outputs:
RestApiId: { Value: !Ref RestApi, Export: { Name: xapi-demo-api-id } }
EchoResourceId: { Value: !Ref EchoResource, Export: { Name: xapi-demo-echo-resource-id } }
VpceId: { Value: !Ref ExecApiVpce, Export: { Name: xapi-demo-vpce-id } }
VpceSgId: { Value: !Ref VpceSg, Export: { Name: xapi-demo-vpce-sg-id } }
VPCエンドポイントの画面から、作成したVPCエンドポイントを確認できます。

ステップ4 ALBへのプライベート統合とデプロイ(アカウントA / 東京)
REST APIのメソッドからバックエンドALBへつなぐ統合を、AWS CLIの put-integration で作成し、そのあとステージ v1 へデプロイします。
# 以下のコマンドは、CloudShell(VPCに関連付けられていない普通のCloudShell!!)での実行を想定しています
API_ID=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-api-id'].Value" --output text)
RES_ID=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-echo-resource-id'].Value" --output text)
VPCLINK_ID=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-vpclink-id'].Value" --output text)
BACKEND_ALB_ARN=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-backend-alb-arn'].Value" --output text)
BACKEND_ALB_DNS=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-backend-alb-dns'].Value" --output text)
# ALB へのプライベート統合。--integration-target はリスナー ARN ではなく ALB 本体の ARN。
aws apigateway put-integration \
--rest-api-id "$API_ID" \
--resource-id "$RES_ID" \
--http-method POST \
--type HTTP_PROXY \
--integration-http-method POST \
--connection-type VPC_LINK \
--connection-id "$VPCLINK_ID" \
--integration-target "$BACKEND_ALB_ARN" \
--uri "http://${BACKEND_ALB_DNS}/echo" \
--response-transfer-mode STREAM \
--region ap-northeast-1
aws apigateway put-method-response \
--rest-api-id "$API_ID" \
--resource-id "$RES_ID" \
--http-method POST \
--status-code 200 \
--response-models '{"application/json":"Empty"}' \
--region ap-northeast-1
aws apigateway put-integration-response \
--rest-api-id "$API_ID" --resource-id "$RES_ID" \
--http-method POST --status-code 200 --region ap-northeast-1
# ステージ v1 へデプロイ(統合を当てたあとに実行する)
aws apigateway create-deployment \
--rest-api-id "$API_ID" \
--stage-name v1 \
--region ap-northeast-1
統合タイプ HTTP_PROXY に --connection-type VPC_LINK を組み合わせた形が、いわゆるプライベート統合です。
ここで、VPC内に立てたCloudShellからexecute-api VPCエンドポイント経由でプライベートAPIを叩いてみましょう。
# VPC内でCloudShellを起動
curl -X POST https://<API ID>.execute-api.ap-northeast-1.amazonaws.com/v1/echo -d '{"hello":"world"}'
## POSTした内容 '{"hello":"world"}' がそのまま返ってくればOK
API IDはマネコンから確認するか、「api-vpce」スタックの出力(Outputs)から確認してください。

プライベートREST APIの呼び出し方法(アクセス先の指定方式)はいくつかありますが、今回は「プライベート DNS 名を使用してプライベート API を呼び出す」方式を取りました(FQDNがシンプルだから)
詳細は以下を参照してください。
(参考)プライベート API の呼び出し
期待どおりのレスポンスが返れば、ようやく「既存のプライベートREST API」の環境構築が終わりです。
検証の前提となる環境構築が長かった!!!笑
あとはCloudFrontとオリジンALBを足すだけです。
ステップ5 オリジンALBとリクエスト整形(アカウントA / 東京)
CloudFrontの受け皿になるオリジンALB xapi-demo-origin-alb を作ります。
冒頭でも触れましたが、これは負荷分散が目的ではなく、CloudFrontからのリクエストを受け、そのリクエストを以下の2点で加工し、API Gatewayへ流すためのALBです。
- Hostヘッダーを
{API_ID}.execute-api.ap-northeast-1.amazonaws.comに書き換える - ステージ名
/v1をパスの先頭に付ける
この2つの整形は、ALBのリスナールールのトランスフォームで行います。
ターゲットグループ xapi-demo-origin-tg はIPターゲットのHTTPS:443で、execute-api VPCエンドポイントのENIのIPを登録します。
ヘルスチェックの成功コードには 200,403 を入れます。
VPCエンドポイントはパスなしのヘルスチェックに対して403を返すので、403も正常とみなして到達性だけを確認する狙いです。
CloudFormationのパラメータに入れるVPCエンドポイントのENIのIPとAPI IDは、次のコマンドで取得できます(API IDはさっきも確認したけど、一応ここにも含めておく)
# 以下のコマンドは、CloudShell(VPCに関連付けられていない普通のCloudShell!!)での実行を想定しています
# VPCエンドポイントのENIのIP
VPCE_ID=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-vpce-id'].Value" --output text)
ENI_IDS=$(aws ec2 describe-vpc-endpoints --vpc-endpoint-ids "$VPCE_ID" \
--region ap-northeast-1 \
--query "VpcEndpoints[0].NetworkInterfaceIds" --output text)
for ENI in $ENI_IDS; do
aws ec2 describe-network-interfaces --network-interface-ids "$ENI" \
--region ap-northeast-1 \
--query "NetworkInterfaces[0].PrivateIpAddress" --output text
done
# API ID
aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-api-id'].Value" --output text
取得したIPと、Host書き換え先に使うAPI IDをパラメータで渡してオリジンALBを作ります。
# 05-origin-alb.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo origin ALB for CloudFront VPC origin (Account A / ap-northeast-1)
Parameters:
VpceIp1: { Type: String }
VpceIp2: { Type: String }
ApiId: { Type: String } # Host 書き換え先に使う API ID(ステップ3の xapi-demo-api-id)
Mappings:
# aws ec2 describe-managed-prefix-lists --region <REGION> | jq -r '.PrefixLists[] | select (.PrefixListName == "com.amazonaws.global.cloudfront.origin-facing") | .PrefixListId'
AWSRegions2PrefixListID:
ap-northeast-1:
PrefixList: pl-58a04531
ap-northeast-3:
PrefixList: pl-31a14458
us-east-1:
PrefixList: pl-3b927c52
Resources:
OriginAlbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: xapi-demo origin alb sg
VpcId: !ImportValue xapi-demo-vpc-id
SecurityGroupIngress:
# VPC内からのアクセス許可(切り分け用の直叩きなど)
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 10.20.0.0/16
# CloudFront VPC Originからのアクセス許可
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourcePrefixListId: !FindInMap [AWSRegions2PrefixListID, !Ref 'AWS::Region', PrefixList]
Tags: [{ Key: Name, Value: xapi-demo-origin-alb-sg }]
OriginTg:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: xapi-demo-origin-tg
TargetType: ip
Protocol: HTTPS
Port: 443
VpcId: !ImportValue xapi-demo-vpc-id
HealthCheckProtocol: HTTPS
HealthCheckPath: /
Matcher: { HttpCode: '200,403' }
Targets:
- Id: !Ref VpceIp1
Port: 443
- Id: !Ref VpceIp2
Port: 443
OriginAlb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: xapi-demo-origin-alb
Type: application
Scheme: internal
SecurityGroups: [!Ref OriginAlbSg]
Subnets:
- !ImportValue xapi-demo-subnet-a-id
- !ImportValue xapi-demo-subnet-c-id
# HTTP:80 で受ける。CloudFront→オリジンALB間は HTTP。
# (HTTPS にするには信頼済み CA 署名の証明書=独自ドメインが必要。記事末尾の留意事項を参照)
OriginListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref OriginAlb
Protocol: HTTP
Port: 80
DefaultActions:
- Type: forward
TargetGroupArn: !Ref OriginTg
# リクエスト整形をトランスフォームで宣言。
# 1) Host を execute-api のエンドポイントへ書き換え(どの API 宛てか判別させる)
# 2) パス先頭にステージ名 /v1 を前置(/echo -> /v1/echo)
# 以前コンソール手作業だった Host 書き換えも、ここに畳み込んで宣言的に管理する。
OriginRule:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
ListenerArn: !Ref OriginListener
Priority: 10
Conditions:
- Field: path-pattern
PathPatternConfig:
Values: ['/*']
Actions:
- Type: forward
TargetGroupArn: !Ref OriginTg
Transforms:
- Type: host-header-rewrite
HostHeaderRewriteConfig:
Rewrites:
- Regex: '^.*$'
Replace: !Sub '${ApiId}.execute-api.${AWS::Region}.amazonaws.com'
- Type: url-rewrite
UrlRewriteConfig:
Rewrites:
- Regex: '^/(.*)$'
Replace: '/v1/$1'
Outputs:
OriginAlbDns: { Value: !GetAtt OriginAlb.DNSName, Export: { Name: xapi-demo-origin-alb-dns } }
OriginAlbArn: { Value: !Ref OriginAlb, Export: { Name: xapi-demo-origin-alb-arn } }
OriginAlbSgId: { Value: !Ref OriginAlbSg, Export: { Name: xapi-demo-origin-alb-sg-id } }
OriginListenerArn: { Value: !Ref OriginListener, Export: { Name: xapi-demo-origin-listener-arn } }
VPC OriginのオリジンALBでは、CloudFrontからの通信のみを許可するようにセキュリティグループを設定します。
CloudFront VPC Originでは、以下の2つの方法でアクセス元を制限できます。
①CloudFrontのマネージドプレフィックスリストからの通信を許可する
②VPC Origin作成時に自動作成されるCloudFront-VPCOrigins-Service-SGからの通信を許可する
AWS公式ドキュメント上も、この2方式が明確に選択肢として示されており、CloudFront-VPCOrigins-Service-SG方式は「さらに制限される」と説明されています。
今回はCloudFormationで一発で設定できる①の方式を採用しました。
詳細は以下を参照してください。
- VPC オリジンを使用したアクセス制限
-
Amazon CloudFront用のAWS マネージドプレフィックスリストを使用したオリジンへのアクセス制限
※CloudFormationテンプレートの書き方はこちらのブログを参考にしました
ここまで作ったら、CloudFrontを作る前に、VPC内のCloudShellからオリジンALBへ直接curlして、リクエスト整形とターゲットの到達性を確認しておきましょう。
# VPC内でCloudShellを起動
# /echo で叩くと ALB のトランスフォームが Host を書き換え、/v1/echo に整形してくれる。
curl -X POST http://<オリジンALBのDNS名>/echo -d '{"hello":"world"}'
## POSTした内容 '{"hello":"world"}' がそのまま返ってくればOK
# 逆にステージ(v1)を指定すると・・・
curl -X POST http://internal-xapi-demo-origin-alb-1304927316.ap-northeast-1.elb.amazonaws.com/v1/echo -d '{"hello":"world"}'
## {"message":"Missing Authentication Token"} と返ってくるはず
ステップ6 VPCオリジンとRAM共有(アカウントA / バージニア北部)
バージニア北部リージョンでVPCオリジンを作成し、アカウントBにRAM共有します。
VPCオリジンのプロトコルは、CloudFront→オリジンALB間をHTTPにする方針に合わせ、OriginProtocolPolicy を http-only、HTTPポートを80にします。
それではCloudFormationで作っていきましょう!!
…と思ったら、CloudFormationの !ImportValue は同一リージョン内でしか参照できないため、ALBのARNはExport参照ではなく パラメータで明示的に渡す必要がありました😂
まず、東京側のExportからALBのARNとDNS名を取得します。
# 以下のコマンドは、CloudShell(VPCに関連付けられていない普通のCloudShell!!)での実行を想定しています
ORIGIN_ALB_ARN=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-origin-alb-arn'].Value" --output text)
ORIGIN_ALB_DNS=$(aws cloudformation list-exports --region ap-northeast-1 \
--query "Exports[?Name=='xapi-demo-origin-alb-dns'].Value" --output text)
echo "$ORIGIN_ALB_ARN"
echo "$ORIGIN_ALB_DNS"
そういえば、CloudFormation の新組み込み関数 Fn::GetStackOutput で依存関係のないクロスリージョン・クロスアカウント参照が可能になりましたってアップデートがあったな~~~と、ここまで書いて思い出しました、、
それ使えばよかった、、
取得した値をパラメータで渡し、次のスタックをバージニア北部(us-east-1)でデプロイします。
# 06-vpc-origin-share.yaml (us-east-1 にデプロイ)
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo VPC origin and RAM share (Account A / us-east-1)
Parameters:
OriginAlbArn: { Type: String } # 東京の ALB の ARN を明示的に渡す(クロスリージョンなので ImportValue は使えない)
OriginAlbDns: { Type: String } # 東京の ALB の DNS名 を明示的に渡す(クロスリージョンなので ImportValue は使えない)
AccountBId: { Type: String } # アカウントBのアカウントID
Resources:
VpcOrigin:
Type: AWS::CloudFront::VpcOrigin
Properties:
VpcOriginEndpointConfig:
Name: xapi-demo-vpc-origin
Arn: !Ref OriginAlbArn
HTTPPort: 80
OriginProtocolPolicy: http-only # CloudFront→オリジンALB間は HTTP
VpcOriginShare:
Type: AWS::RAM::ResourceShare
DependsOn: VpcOrigin
Properties:
Name: xapi-demo-vpco-share
ResourceArns: [!GetAtt VpcOrigin.Arn]
Principals: [!Ref AccountBId]
AllowExternalPrincipals: false
Outputs:
VpcOriginId: { Value: !GetAtt VpcOrigin.Id }
VpcOriginArn: { Value: !GetAtt VpcOrigin.Arn }
OriginAlbDns: { Value: !Ref OriginAlbDns }
CloudFront側のディストリビューションの設定で必要になるので、VPCオリジンのID(スタックでVpcOriginIdで出力)と、オリジンALBのプライベートDNS名(スタックでOriginAlbDnsで出力)を控えておきます。
ステップ7 WAFとCloudFront(アカウントB / バージニア北部)
アカウントBに移ります。
それではWAFとCloudFrontを作っていきます。
許可したいアクセス元CIDRをIPセットに入れます。
IPセットはIPv4を想定してCloudFormationテンプレートを作成しましたが、IPv6環境であればCloudFormationのテンプレートを修正してください。
CloudFrontのオリジン設定は、クロスアカウントで共有されたVPCオリジンを使う場合、同一アカウントのときと異なります。
共有VPCオリジンを参照するには次の3つを明示する必要があります。
- VPCオリジンのID(
VpcOriginId、ARNではなくID) - オリジンを所有するアカウント(
OwnerAccountId、ここではアカウントAのID) - オリジンALBのプライベートDNS名(
OriginAlbDns)
同一アカウントなら DomainName は自動で埋まりますが、共有オリジンでは手動で渡す必要がある、というのがクロスアカウント特有の仕様です。
# 07-waf-cloudfront.yaml (us-east-1 にデプロイ)
AWSTemplateFormatVersion: '2010-09-09'
Description: xapi-demo WAF and CloudFront (Account B / us-east-1)
Parameters:
VpcOriginId: { Type: String } # ステップ6の VpcOriginId(ARN ではなく ID)
OwnerAccountId: { Type: String } # アカウントAのアカウントID(VPC オリジン所有アカウント)
OriginAlbDns: { Type: String } # オリジン ALB のプライベート DNS 名
AllowCidr: { Type: String } # アクセス元拠点のCIDR(例:203.0.113.0/24)
Resources:
AllowIpSet:
Type: AWS::WAFv2::IPSet
Properties:
Name: xapi-demo-allow-ipset
Scope: CLOUDFRONT
IPAddressVersion: IPV4
Addresses:
- !Ref AllowCidr
WebAcl:
Type: AWS::WAFv2::WebACL
Properties:
Name: xapi-demo-cf-acl
Scope: CLOUDFRONT
DefaultAction:
Block: {}
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: xapi-demo-cf-acl
Rules:
- Name: allow-office-ip
Priority: 0
Action:
Allow: {}
Statement:
IPSetReferenceStatement: { Arn: !GetAtt AllowIpSet.Arn }
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: allow-office-ip
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Comment: xapi-demo-distribution
Enabled: true
PriceClass: PriceClass_200 # 北米・欧州・アジアなど主要地域に限定(コスト削減のため)
WebACLId: !GetAtt WebAcl.Arn
HttpVersion: http2and3
ViewerCertificate:
CloudFrontDefaultCertificate: true
Origins:
- Id: xapi-demo-origin
DomainName: !Ref OriginAlbDns
VpcOriginConfig:
VpcOriginId: !Ref VpcOriginId
OwnerAccountId: !Ref OwnerAccountId # クロスアカウント時に必須
# VPC Originの場合、CustomOriginConfig は指定不可。
# プロトコル/ポートは VPC オリジン側の定義(http-only / 80)が使われる。
DefaultCacheBehavior:
TargetOriginId: xapi-demo-origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # AWS マネージドの CachingDisabled ポリシー
# Logging:
# Bucket: "aws-waf-logs-xxxxx-s3.s3.amazonaws.com"
# Prefix: "CloudFront/"
ステップ8 動作確認
AllowCidr に含めた拠点から、CloudFrontの標準ドメイン xxxxxxxx.cloudfront.net へPOSTします。
クライアントは /echo を叩き、オリジンALBのトランスフォームがステージ名を前置して /v1/echo に整形し、API Gatewayのステージへ届く、という流れです。
curl -X POST https://<作成したディストリビューションドメイン名>/echo -d '{"hello":"world"}' -i
## 私のPCだと、IPv6でのアクセスになってしまってWAF ACLにブロックされたので、-4 オプションでIPv4 を強制。。
curl -X POST https://<作成したディストリビューションドメイン名>/echo -d '{"hello":"world"}' -i -4
200 と、バックエンドのエコーサーバが返す {"hello":"world"} がそのまま見えれば、CloudFront(アカウントB)から共有VPCオリジン、オリジンALB、execute-api VPCエンドポイント、プライベートAPI、VPC Link v2、バックエンドALB、EC2まで問題なく通ったことになります!!
終わりに
CloudFrontだけを別アカウントに置きつつ、プライベートなREST APIをインターネット公開する、という構成をイチから組んで動かしてみました。
もともと確かめたかった「既存構成を変えずにCloudFrontを頭に置けるか」という問いに対して、どうにかしたら実現可能であることを確認できたのは収穫でした。
ただ、あくまで検証のための構成でしたので、今回の構成を本番環境で利用する場合は、以下のような点を検討する必要があると思います。
-
可用性
- 各コンポーネントを複数AZに配置する
- VPCエンドポイント、ALB、バックエンドなどの障害時もサービスを継続できる構成にする
-
通信のセキュリティ
- CloudFrontからオリジンALBへの通信をHTTPSにする
- WAFでIP制限だけでなく、マネージドルールやレート制限なども検討する
- セキュリティグループやリソースポリシーによるアクセス制御を適切に設定する
-
APIの認証・認可
- 現在は認証なしの構成としているため、本番ではAPIの用途に応じた認証・認可方式を検討する
-
公開URLとAPI Gatewayのステージ設計
-
/v1のようなパスを公開URLに含める必要があるかを改めて検討する - API Gatewayのステージ(例:
v1、prod)と、利用者に公開するAPIのバージョンは必ずしも同じではない - ステージを利用者から隠すのであれば、どのレイヤーでステージを付与するか検討する(今回のようにALBリスナーでやるのか、CloudFront側でやるのか等)
-
-
CloudFrontのキャッシュ
- APIの性質に応じてキャッシュの要否を検討する
- 認証情報やユーザーごとのレスポンスを誤ってキャッシュしないようにする
-
監視・ログ
- CloudFront、WAF、ALB、API Gateway、バックエンドそれぞれのログ・メトリクスを取得する
- リクエストの変換を含め、障害発生時にどのレイヤーで問題が起きたか追跡できるようにする
最後までお目通しいただき、ありがとうございました。



