Application Load BalancerのターゲットにLambda Functionを使用して高度なリダイレクトを行う方法

はじめに

おはようございます、加藤です。 昔話から始まりますが、2018年7月のアップデートでAWS Application Load Balancer(以降、ALB)のリスナールールでリダイレクトを行えるようになりました。

当時は条件として以下をサポートしていました。

  • ホストヘッダー
  • パスパターン

[新機能]Webサーバでの実装不要!ALBだけでリダイレクト出来るようになりました! | Developers.IO

その後、2019年3月にアップデートがあり、下記も条件としてサポートされました。

  • HTTPヘッダー
  • HTTPリクエストメソッド
  • クエリ文字列
  • 送信元IPアドレス

[新機能] HTTPヘッダーやクエリ文字列などなどでルーティングができちゃう!!AWS ALBで高度なリクエストルーティングが可能になりました! | Developers.IO

条件としては、リダイレクトの際に下記を任意の固定値に指定する事が可能です。

  • ホスト
  • パス
  • クエリ

さらに、予約済みキーワードとして下記を使用できます。

  • protocol 元のプロトコルを保持。プロトコルとクエリのコンポーネントで使用できる。
  • host 元のドメインを保持。ホスト名、パス、およびクエリのコンポーネントで使用できる。
  • port 元のポートを保持。ポート、パス、およびクエリのコンポーネントで使用できる。
  • path 元のパスを保持。パスとクエリのコンポーネントで使用できる。
  • query 元のクエリパラメータを保持。クエリのコンポーネントで使用する。

これらによって、例えば以下のような処理をALBに行わせる事が可能でした。

  • httpアクセスをhttpsにリダイレクト
  • 一時的にルールを作成しメンテナンス中でもオフィス(特定IPアドレス)からはアクセス可能
  • マイクロサービスの為にhttpメソッドに応じてルーティング

しかし、元々のパス等を書き換える様なリダイレクトは現状では行えません。

  • OK: /before/path → /after/before/path
  • NG: /before/path → /after/path

こういった要件は、2018年のre:Inventで発表されたALBに関連付くターゲットグループにAWS Lambda Function(以降、Lambda関数)を使用する事で満たすことが可能です。

ALBのバックエンドにLambdaを選択してみた! #reinvent | Developers.IO

要件の整理

行いたいリダイレクトは下記の通りです。

これを実現する為には、Lambda関数が関連付いたターゲットグループを追加し、リスナールールにパスが/before/*の場合に、このターゲットグループへルーティングする用に設定すれば良いです。

やってみた

疑似環境の作成

検証する為に、コア部分以外の疑似環境を作成します。

マネージメントコンソールやCloud Formation(以降、CFn)から作成しても良いでの最近、AWS CDK(以降、CDK)がマイブームなのでCDKを使用します。CDKに慣れている方はCDKを、そうでない方はCDKで生成したCFnテンプレートも記載するので、そちらを使用して疑似環境を作成してください。

CDKのStack部分

import * as cdk from "@aws-cdk/core";
import {Peer, Port, SecurityGroup, SubnetType, Vpc} from "@aws-cdk/aws-ec2";
import {ApplicationLoadBalancer as Alb, ContentType} from "@aws-cdk/aws-elasticloadbalancingv2";
import {CfnOutput} from "@aws-cdk/core";

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

    const vpc = new Vpc(this, 'PublicOnlyVpc', {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [{name: 'Public', subnetType: SubnetType.PUBLIC}]
    });
    const securityGroup = new SecurityGroup(this, 'Sg', {vpc, allowAllOutbound: true});
    securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(80), 'allow http access from any ipv4');

    const vpcSubnets = {subnets: vpc.publicSubnets};
    const alb = new Alb(this, 'Alb', {vpc, securityGroup, vpcSubnets, internetFacing: true});

    const listener = alb.addListener('Listener', {port: 80, open: true});
    listener.addFixedResponse('DefaultResponse', {
      pathPattern: '/*',
      priority: 0,
      contentType: ContentType.TEXT_PLAIN,
      messageBody: 'This is default page',
      statusCode: '200'
    });
    listener.addFixedResponse('RedirectedResponse', {
      pathPattern: '/after/*',
      priority: 1,
      contentType: ContentType.TEXT_PLAIN,
      messageBody: 'Redirected!!',
      statusCode: '200'
    });

    new CfnOutput(this, "AlbDnsName", {
      value: alb.loadBalancerDnsName,
      description: 'Application Load Balancer\'s dns name'
    })
  }
}

生成されたCFnテンプレート

Resources:
  PublicOnlyVpc70520523:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/Resource
  PublicOnlyVpcPublicSubnet1Subnet95BA5FA7:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.0.0/17
      VpcId:
        Ref: PublicOnlyVpc70520523
      AvailabilityZone: ap-northeast-1a
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc/PublicSubnet1
        - Key: aws-cdk:subnet-name
          Value: Public
        - Key: aws-cdk:subnet-type
          Value: Public
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet1/Subnet
  PublicOnlyVpcPublicSubnet1RouteTableCB078C72:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId:
        Ref: PublicOnlyVpc70520523
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc/PublicSubnet1
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet1/RouteTable
  PublicOnlyVpcPublicSubnet1RouteTableAssociation87A06197:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: PublicOnlyVpcPublicSubnet1RouteTableCB078C72
      SubnetId:
        Ref: PublicOnlyVpcPublicSubnet1Subnet95BA5FA7
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet1/RouteTableAssociation
  PublicOnlyVpcPublicSubnet1DefaultRoute330AD2F8:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PublicOnlyVpcPublicSubnet1RouteTableCB078C72
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId:
        Ref: PublicOnlyVpcIGW5FDE497C
    DependsOn:
      - PublicOnlyVpcVPCGW6D24F5B6
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet1/DefaultRoute
  PublicOnlyVpcPublicSubnet2Subnet14102853:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.128.0/17
      VpcId:
        Ref: PublicOnlyVpc70520523
      AvailabilityZone: ap-northeast-1c
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc/PublicSubnet2
        - Key: aws-cdk:subnet-name
          Value: Public
        - Key: aws-cdk:subnet-type
          Value: Public
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet2/Subnet
  PublicOnlyVpcPublicSubnet2RouteTable26AC6139:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId:
        Ref: PublicOnlyVpc70520523
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc/PublicSubnet2
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet2/RouteTable
  PublicOnlyVpcPublicSubnet2RouteTableAssociation1626B7EE:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: PublicOnlyVpcPublicSubnet2RouteTable26AC6139
      SubnetId:
        Ref: PublicOnlyVpcPublicSubnet2Subnet14102853
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet2/RouteTableAssociation
  PublicOnlyVpcPublicSubnet2DefaultRoute56ACD62D:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: PublicOnlyVpcPublicSubnet2RouteTable26AC6139
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId:
        Ref: PublicOnlyVpcIGW5FDE497C
    DependsOn:
      - PublicOnlyVpcVPCGW6D24F5B6
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/PublicSubnet2/DefaultRoute
  PublicOnlyVpcIGW5FDE497C:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: AlbStack/PublicOnlyVpc
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/IGW
  PublicOnlyVpcVPCGW6D24F5B6:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId:
        Ref: PublicOnlyVpc70520523
      InternetGatewayId:
        Ref: PublicOnlyVpcIGW5FDE497C
    Metadata:
      aws:cdk:path: AlbStack/PublicOnlyVpc/VPCGW
  SgD4954771:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: AlbStack/Sg
      SecurityGroupEgress:
        - CidrIp: 0.0.0.0/0
          Description: Allow all outbound traffic by default
          IpProtocol: "-1"
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          Description: allow http access from any ipv4
          FromPort: 80
          IpProtocol: tcp
          ToPort: 80
      VpcId:
        Ref: PublicOnlyVpc70520523
    Metadata:
      aws:cdk:path: AlbStack/Sg/Resource
  Alb16C2F182:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Scheme: internet-facing
      SecurityGroups:
        - Fn::GetAtt:
            - SgD4954771
            - GroupId
      Subnets:
        - Ref: PublicOnlyVpcPublicSubnet1Subnet95BA5FA7
        - Ref: PublicOnlyVpcPublicSubnet2Subnet14102853
      Type: application
    DependsOn:
      - PublicOnlyVpcPublicSubnet1DefaultRoute330AD2F8
      - PublicOnlyVpcPublicSubnet2DefaultRoute56ACD62D
    Metadata:
      aws:cdk:path: AlbStack/Alb/Resource
  AlbListener86261768:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - FixedResponseConfig:
            ContentType: text/plain
            MessageBody: This is default page
            StatusCode: "200"
          Type: fixed-response
      LoadBalancerArn:
        Ref: Alb16C2F182
      Port: 80
      Protocol: HTTP
    Metadata:
      aws:cdk:path: AlbStack/Alb/Listener/Resource
  AlbListenerRedirectedResponseRule8BCB50AE:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - FixedResponseConfig:
            ContentType: text/plain
            MessageBody: Redirected!!
            StatusCode: "200"
          Type: fixed-response
      Conditions:
        - Field: path-pattern
          Values:
            - /after/*
      ListenerArn:
        Ref: AlbListener86261768
      Priority: 1
    Metadata:
      aws:cdk:path: AlbStack/Alb/Listener/RedirectedResponseRule/Resource
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Modules: aws-cdk=1.20.0,@aws-cdk/aws-cloudwatch=1.20.0,@aws-cdk/aws-ec2=1.20.0,@aws-cdk/aws-elasticloadbalancingv2=1.20.0,@aws-cdk/aws-iam=1.20.0,@aws-cdk/aws-ssm=1.20.0,@aws-cdk/core=1.20.0,@aws-cdk/cx-api=1.20.0,@aws-cdk/region-info=1.20.0,jsii-runtime=node.js/v12.14.1
Outputs:
  AlbDnsName:
    Description: Application Load Balancer's dns name
    Value:
      Fn::GetAtt:
        - Alb16C2F182
        - DNSName

CFnスタックの作成が完了すると、ALBのDNS名がアウトプットされます。後ほど使用するのでメモしてください。

Lambda関数を使用して高度なリダイレクトを行う

Lambda関数を新規作成します。 関数名は任意、ランタイムはNode.js 12.xを選択し、関数の作成を選択します。

インラインでコードを編集します。下記をコピー&ペーストしてください。 また、処理内容にはコメントを確認してください。

const dnsName = process.env["DNS_NAME"];

exports.handler = async (event) => {
    
    // パスを配列として取得し、先頭から2要素を削除する
    // 例: "/before/path1/"" → [""", "before", "path1", ""] → ["path1", ""]
    const paths = event.path.split("/");
    paths.splice(0, 2);

    // パス配列を"/"区切りで結合して文字列にする
    const suffix = paths.join("/");
    
    // Alb用にレスポンスを生成する
    const response = {
        statusCode: 301,
        headers: {
            "Location": `http://${dnsName}/after/${suffix}`
        }
    };
    return response;
};

ちなみに、Google Chrome 79.0.3945.117からアクセスした場合、ALBからLambda関数は下記のイベントを受け取ります。(アカウント番号など一部置換しています)

{
  "requestContext": {
    "elb": {
      "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:${AWS_ACCOUNT_ID}:targetgroup/Redirect/01d9489ef8b930f7"
    }
  },
  "httpMethod": "GET",
  "path": "/before/path1/path2",
  "queryStringParameters": {},
  "headers": {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "accept-encoding": "gzip, deflate",
    "accept-language": "ja,en-US;q=0.9,en;q=0.8",
    "connection": "keep-alive",
    "host": "${DNS_NAME}",
    "upgrade-insecure-requests": "1",
    "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36",
    "x-amzn-trace-id": "Root=1-5e172729-9ba14e2060707d40b9abcca0",
    "x-forwarded-for": "192.0.2.1",
    "x-forwarded-port": "80",
    "x-forwarded-proto": "http"
  },
  "body": "",
  "isBase64Encoded": false
}

環境変数を1つ設定します、キーをDNS_NAMEとし、バリューはCFnスタック作成完了時に出力されたALBのDNS名に設定してください。

ターゲットグループを作成します。

ターゲットグループ名は任意、ターゲットの種類はLambda 関数で作成したLambda関数を選択します。 今回は、検証なのでバージョン:$LATESTを選択しています。 プロダクション環境で利用する場合は、Lambda関数作成時にバージョンの発行と発行したバージョンに対してエイリアスを設定し、それを選択する事を強くおすすめします。 バージョン:$LATESTを選択している場合、Lambda関数に対する変更が即座に適用されてしまいます。 さらに、一度ターゲットグループにLambda関数を関連付けると後から別の関数・バージョン指定・エイリアス指定をする事はできません。 変更する度にバージョンを発行し、例えばRELEASEとエイリアスを設定し、バージョンが発行される度に、エイリアスの参照バージョンを更新する事で安全に改修ができ、切り戻しも迅速に行えます。

ALBのリスナールールに作成したターゲットグループを関連付けます。 リスナータブを開き、デフォルトリスナーのルールの表示/編集を選択します。

+タブを開き、+ルールの挿入を選択すると、ルールが編集可能になります。 +条件の追加を選択しパス...を選び、/before/*の場合を条件に指定しチェックアイコンを選択して確定します。 +アクションの追加を選択し転送先...を選び、ターゲットグループに作成したターゲットグループを指定しチェックアイコンを選択して確定します。 最後に保存を選択します。

動作確認

まず、curlコマンドを使って動作を確認します。

DNS_NAME=[ALBのDNS名]

curl -v http://${DNS_NAME}/before/path
*   Trying 192.0.2.1...
* TCP_NODELAY set
* Connected to ${DNS_NAME} (192.0.2.1) port 80 (#0)
> GET /before/path HTTP/1.1
> Host: ${DNS_NAME}
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Server: awselb/2.0
< Date: Thu, 09 Jan 2020 15:16:26 GMT
< Content-Type: application/octet-stream
< Content-Length: 0
< Connection: keep-alive
< Location: http://${DNS_NAME}/after/path
<
* Connection #0 to host ${DNS_NAME} left intact
* Closing connection 0

意図した通り、http://${DNS_NAME}/after/pathにリダイレクトされています! ブラウザでもhttp://${DNS_NAME}/before/pathにアクセスすると、http://${DNS_NAME}/after/pathにリダイレクトされる事を確認できました!

あとがき

想像力が足りないよと言われてしまいそうですが、ALBのターゲットグループでLambda関数が使用できる用にアップデートされた時は、「誰が喜ぶんだ...?」という感想でした。同じDNS名でALB配下のサービスとLambda関数を併用できますが、「サブドメイン作成して、API Gateway & Lambdaでやれば良いのでは?」と思っていました。 しかし、こういった高度なリダイレクトという用途だと、わざわざAPI Gatewayを作る程ではなく、かといってALBの機能では実現できませんね。 以上でした!!