How to use a AWS Lambda function as reverse proxy

AWS supports the targeting of a Lambda function behind an application load balancer. Most likely you probably deploy most of your Lambda functions on the AWS API Gateway (same as us). However in case you don't need custom & AWS_IAM authorisation out-of-the-box using the ALB target group for your Lambda function might be an alternative. But still, until recently I did not find a use-case myself.

Until we have an application migration project where we temporary needed a very basic reverse proxy functionality in front of the old/new back-end systems. So we could use an NGINX container/instance but wanted to see if we could do it 100% serverless. The answer is YES by the way.

AWS Lambda reverse proxy

Making the HTTPS call from AWS Lambda

There are multiple ways to perform an HTTP(s) call from NodeJS but I personally am a fan of node-fetch so I used that:

  "dependencies": {
    "node-fetch": "^2.6.0",
  }

Using the Serverless Framework

In the past you needed a serverless plugin or your own resources for ALB support but luckily since 1.45 and 1.46 the ALB targetgroup has native support within the Serverless Framework for all ALB Listener rules.

An example to map the reverseproxy handler to an ALB with some random conditions:

functions:
  reverseproxy:
    handler: lib/reverseproxy.handler
    name: terra10-${self:custom.stage}-serverless-reverseproxy
    events:
      - alb:
          listenerArn: arn:aws:elasticloadbalancing:eu-west1-1:1234567890:listener/app/t10-tst-alb/xxxx/yyyy
          priority: 10
          conditions: 
            path: 
              - '/mypath'
            host: 
              - proxy.terra10.nl
            method:
              - GET
            # ip:
            #  - 80.90.100.110

Since the AWS ALB Listener rules (and therefor serverless in the example above) allows detailed configuration regarding the HTTP requests conditions, priority of the rules with the desired Targetgroup on we match. We can implement much logic on this level to create an intelligent routing mechanism.

The AWS Lambda handler

So when you hit the  handler through the ALB Targetgroup the typescript code may look like this:

import { ALBEvent, ALBHandler, ALBResult } from 'aws-lambda';
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';

export const handler: ALBHandler = async(event: ALBEvent, context): Promise<ALBResult> => {

  console.debug('event received: ' + JSON.stringify(event));
  console.debug('context received: ' + JSON.stringify(context));

  // fetch Headers and ALB Headers are different types
  const customUrl = 'app1.terra10.nl';
  let customRequestHeader: { [header: string]: string } = [''][''];
  if ( event.headers ) {
    customRequestHeader = event.headers;
  }
  customRequestHeader['host'] = customUrl;
  customRequestHeader['proxyRequestId'] = context.awsRequestId;
  console.debug('request customHeader: ' + JSON.stringify(customRequestHeader));

  // perform fetch to https target
  try {
    const url: RequestInfo = `https://${customUrl}${event.path}`;
    const params: RequestInit = {
      method: event.httpMethod,
      headers: customRequestHeader
    };

    console.debug('request params: ' + JSON.stringify(params));
    const response: Response = await fetch(url, params);
    const textResponse = await response.text();
    console.debug('response text: ' + textResponse);

    // fetch Headers and ALB Headers are different types so code if you need Header manipulation on the response
        // console.log('response headers: ' + JSON.stringify(response.headers));
        // const customResponseHeader: { [header: string]: boolean | number | string } = { proxyRequestId: context.awsRequestId } ;
        // for (const pair of response.headers.entries()) {
        //   customResponseHeader.pair[0] = pair[1];
        // }
        // console.debug('response customHeader: ' + JSON.stringify(customResponseHeader));

    return {
      statusCode: response.status,
      statusDescription: response.statusText,
      isBase64Encoded: false,
      headers: event.headers,
      // headers: customResponseHeader,
      body: textResponse
    };
  } catch (err) {
      console.error(`Unexpected 500 | ${err.message} | ${err.detail}`);
      return {
        statusCode: 500,
        statusDescription: '500',
        isBase64Encoded: false,
        headers: event.headers,
        body: err.message
      };
  }
};

What we need to keep in mind is that the AWS ALB Event and the Node Fetch functions have different types and require some mapping (for instance the HTTP header) to do it properly within typescript.

Since we want to support HTTPS we need to set the host-header with the new target endpoint to prevent TLS errors.

References: