0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudFront VPCオリジンで、既存のプライベートREST APIを最小限の改修でインターネット公開する

0
Posted at

はじめに

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」を想定した箇所です。
赤字が通信プロトコルです。独自ドメインを取得しない構成を考えると、こうなりました。

image.png

オリジン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 を要求する

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 がインターネットからトラフィックを受信できることを示すために必要です。インターネットゲートウェイは、サブネット内のオリジンへのトラフィックのルーティングには使用されないため、ルーティングポリシーを更新する必要はありません。

(出典)VPC オリジンを使用したアクセス制限

つまり、いわゆる「パブリックサブネット」を作ることにはならないからOK!! …ということにしておきたいと思います😅

やってみる

それでは上記の環境を実際に作って、検証していきます。

「既存のプライベートREST API」は東京リージョンで稼働しているものと想定します。
また、構築は、できる限りCloudFormationで進めていきます。

ステップ0 AWSアカウントの準備・RAM共有の設定

AWSアカウントを2つ用意します(アカウントA=プライベート環境、アカウントB=インターネット公開環境)

私は 「おひとり様」AWS Organizations を持っているので、Organizationsからアカウントを発行しました。

各アカウントへのアクセスは、IAMユーザを作らず、「OrganizationAccountAccessRole」を利用できて便利!!

今回の検証では直接関係ありませんが、私のOrganizations環境では、Organizations内のRAMリソース共有を有効化しています。
RAMのリソース共有が楽になります。
image.png

ステップ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のリソースマップはこんな感じ。
image.png

ここで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エンドポイントを確認できます。
image.png

ステップ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)から確認してください。
image.png

プライベート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で一発で設定できる①の方式を採用しました。

詳細は以下を参照してください。

ここまで作ったら、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に移ります。

まず、共有されたVPCオリジンを確認します。
image.png
ありますね。

それでは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、バックエンドそれぞれのログ・メトリクスを取得する
    • リクエストの変換を含め、障害発生時にどのレイヤーで問題が起きたか追跡できるようにする

最後までお目通しいただき、ありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?