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の機能では実現できませんね。 以上でした!!