Directly proxying Lambda via Cloudfront without API Gateway

This is the messy bit that picks off the lambda requests but it doesn’t have to be this way. If you want to pay for lambda@edge you can insert a little function that will rewrite the URLs. A function would let you rewrite something like ‘api/myfunc’ into ‘/2015-03-31/functions/myfunc/invocations’. I suspect APIGateway has a hidden @edge function doing just that. Both schemes work.

Note that there is no free tier for lambda@edge. You will likely run a $10/mth bill when using @edge to rewrite the URLs in a modestly active API.

Very interesting. I haven’t looked much at AWS configuration outside of what SLS shows in the documentation.

I assume that they had a good reason to use API Gateway instead of CloudFront directly, maybe for the sake of simplicity?

Regarding your caching, you could set something like this to disable it in development and enable it in the other environments, I do the same kind of stuff to define the RAM to be used by each lambda:

custom:
  webpackIncludeModules: true
  domains:
    development: 'development.simulator.studylink.fr'
    staging: 'staging.simulator.studylink.fr'
    production: 'simulator.studylink.fr'
  memorySizes: # TODO Check how much is actually needed
    development: 128
    staging: 128
    production: 128
  customDomain:
    domainName: ${self:custom.domains.${self:provider.stage}}
    basePath: 'feedback' # This will be prefixed to all routes
    stage: ${self:provider.stage}
    createRoute53Record: true

provider:
  name: aws
  runtime: nodejs6.10
  versionFunctions: false # See https://serverless.com/framework/docs/providers/aws/guide/functions#versioning-deployed-functions
  stage: ${opt:stage, 'development'}
  region: ${opt:region, 'eu-west-3'}
  memorySize: ${self:custom.memorySizes.${self:provider.stage}}

I define values for each stage, and the right value is picked up when I run the script.

API Gateway is simpler to use. API Gateway is also good for when you are selling API access. It is also not at all obvious how to directly proxy lambda with Cloudfront, it took me several months to discover how to do it. Plus if you can’t figure out this bit ‘PathPattern: /2015-03-31/*’ you will never get it to work.

With API Gateway in the way, my API calls turn around in about 150ms and they are pretty consistent. With direct Cloudfront to Lambda I run 70-130ms with far more variability. Occasionally a simple call will finish in 40ms. Note that lambda is still protected by https and AWS4 signatures.

I suspect over time API Gateway will stop being an actual machine somewhere and instead simply turn into a bunch of lambda@edge functions. As far as I can tell there isn’t anything in API Gateway that couldn’t be done via lambda@edge. This is probably why they hide their internal Cloudfront distribution from the developers.

I’m trying to achieve Cloudfront + Lambda without Api Gateway as well.
I’ve researched a lot and it seems currently you are the only one that managed it and spoke about it @jonsmirl. THANK YOU!

I’ve got few questions:

When you make an HTTP POST to xxx.cloudfront.com/2015-03-31/functions/myfunc/invocations is the call routed to HTTP POST lambda.us-east-1.amazonaws.com/2015-03-31/functions/myfunc/invocations ?

Reading your other posts on this forum I understood that you are using AWS Amplify + Cognito and when you are calling xxx.cloudfront.com/2015-03-31/functions/myfunc/invocations AWS Amplify adds a bunch of Authorization headers with the Cognito user data.

I’ve tried curl -X POST https://xxx.cloudfront.net/2015-03-31/functions/myFunc/invocations but without Authorization Header I’m getting {“message”:“Missing Authentication Token”}

Would it be possible to NOT USE Cognito and specify an Amazon IAM key and secret for the Cloudfront Distribution?

How could you handle HTTP methods other than POST? Using a Lambda@Edge to rewrite the request as a HTTP POST and passing the original http method as a parameter?

You need to sign the requests using AWS Signature 4.
https://www.npmjs.com/package/aws-signature-v4

Could you share some snippet code for the lambda@edge rewrite @jonsmirl?

I managed to add the authorization header using aws4 but the Lambda Custom Origin is returning the error:

504 ERROR
The request could not be satisfied.
CloudFront attempted to establish a connection with the origin, but either the attempt failed or the origin closed the connection. 
For more information on how to troubleshoot this error, please refer to the CloudFront documentation (https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/http-504-gateway-timeout.html). 

//lamba@edge requestRewriter.js
const aws4 = require('aws4');

exports.handler = function (event, context, callback) {

	const request = event.Records[0].cf.request;

	let opts = aws4.sign({
		service: 'lambda',
		method: 'POST',
		path: '/2015-03-31/functions/myFunc/invocations',
	}, {
		accessKeyId: 'MY_ACCESS_KEY',
		secretAccessKey: 'MY_SECRET_KEY',
	});

	request.method = opts.method;
	request.uri = opts.path;
	request.headers = Object.keys(opts.headers).reduce((headers, key) => {

		if (key !== 'Host') {
			headers[key.toLowerCase()] = [{
				key,
				value: opts.headers[key]
			}];
		}

		return headers;

	}, {
		host: request.headers.host
	});

	callback(null, request);

};

I don’t use lambda at edge. I sign the requests in the browser and send them directly to lambda.us-east-1.amazonaws.com. No cloudfront at all. In my measurement cloudfront made lambda calls slower. Cloudfront is great for caching your static stuff.

AWS Amply can generate the sigv4 for you.

Amplify.configure({
Auth: {
identityPoolId: config.IdentityPoolId,
region: ‘us-east-1’,
userPoolId: config.UserPoolId,
userPoolWebClientId: config.UserPoolClientId,
},
API: {
endpoints: [
{
name: ‘api’,
endpoint: ‘https://lambda.us-east-1.amazonaws.com’,
service: ‘lambda’,
},
],
},
});

Is it actually possible to use CloudFront with a Lambda as Custom Origin at all?

The problem is that the Host header forwarded from CloudFront to the Lambda cannot be changed. So even if you use a lambda@edge to add authorizations headers signed with aws4 signature the Host header will be stuck at ‘xxx.cloudfront.com’ or your custom domain name and will not match the Host used in the aws4 signature.

With Amplify or a HTTP POST you can call directly the HTTP POST lambda.us-east-1.amazonaws.com/2015-03-31/functions/myfunc/invocations but you cannot call a CloudFront distribution in front of the lambda for the same problem with the Host header I described above.

You need to make a distribution in cloudfront that points to lambda.us-east-1.amazonaws.com

Maybe this is more what you are looking for…

Another article on a similar technique

Yes I did use a CloudFront Distribution and the serverless-plugin-cloudfront-lambda-edge.
The problem is the authorization headers part though. You need to sign the request with aws4 but CloudFront won’t allow you to replace the Host header and so the aws4 signature will not match and return

{“message”:“The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.”}

I also tried generasting the aws4 signature with the cloudfront Host header but then I would get:
AccessDeniedException>
  Unable to determine service/operation name to be authorized
// serverless.yml
{
    "service": "service",
    "provider": {
        "name": "aws",
        "runtime": "nodejs8.10",
        "stage": "${opt:stage, 'development'}",
        "region": "us-east-1",
        "memorySize": "512",
        "timeout": "10"
    },
    "functions": {

        "index": {
            "handler": "handler.index",
            "memorySize": 256
        },
        "requestRewriter": {
            "handler": "requestRewriter.handler",
            "memorySize": 128,
            "timeout": 1,
            "lambdaAtEdge": {
                "distribution": "CloudfrontDistributionProduction",
                "eventType": "viewer-request"
            }
        }

    },
    "resources": {
        "Resources": {
            "CloudfrontDistributionProduction": {
                "Type": "AWS::CloudFront::Distribution",
                "Properties": {
                    "DistributionConfig": {
                        "Origins": [{
                            "DomainName": "lambda.us-east-1.amazonaws.com",
                            "Id": "api",
                            "CustomOriginConfig": {
                                "HTTPPort": "80",
                                "HTTPSPort": "443",
                                "OriginProtocolPolicy": "match-viewer"
                            }
                        }],
                        "Enabled": "true",
                        "Aliases": ["*.mydomain.com"],
                        "DefaultCacheBehavior": {
                            "AllowedMethods": ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"],
                            "DefaultTTL": 0,
                            "MaxTTL": 0,
                            "MinTTL": 0,
                            "TargetOriginId": "api",
                            "ForwardedValues": {
                                "QueryString": false,
                                "Headers": ["*"],
                            },
                            "ViewerProtocolPolicy": "allow-all",
                            "Compress": true
                        },
                        "ViewerCertificate": {
                        	"AcmCertificateArn": "arn:aws:acm:us-east-xxx",
                        	"SslSupportMethod": "sni-only"
                        }
                    }
                }
            }
        }
    },
    "plugins": [
        "serverless-plugin-cloudfront-lambda-edge"
    ],
    "custom": {
        "webpackIncludeModules": {
            "forceExclude": ["aws-sdk"]
        }
    }

}

There is definitely a way to do this, I remember getting stuck at this exact issue about a year ago when I tired this. This is some trick with setting up the CF distribution that allows this to work. But, after I got it working I concluded that all Cloudfront was doing was making my API call take an extra 60ms to complete. Since I abandoned this method I’ve forgotten how I did it.

Maybe this was it?
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/forward-custom-headers.html

If you read below there is a section that states:

You can't configure CloudFront to forward the following custom headers to your origin:
Host

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/forward-custom-headers.html#forward-custom-headers-blacklist

And Lambda@Edge can only Read but not write the Host header:

Read-only Headers for CloudFront Viewer Request Events
Host

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-requirements-limits.html#lambda-read-only-headers

So maybe you did place the CloudFront Distribution in front of a Lambda using the Custom Origin but then you still called the Lambda directly using AWS Amplify or a HTTP POST @jonsmirl

I did not need to use Edge at all. There is a way to configure the distribution behavior to do this.

I think you might have setup the CloudFront Distribution in front of the Lambda using the Custom Origin but then you might have called directly the Lambda anyway not finding out that the CloudFront Distribution wouldn’t allow accept the aws4 signing.

Could you recreate a basic github repository project demonstrating how to place a CloudFront Distribution in front of a Lambda and having them called not from Amplify/Cognito but by making an HTTP POST request to the CloudFront url xxx.cloudfront.com?

I looked in my git log, I think this was the configuration I used. But note that I concluded that doing this gained nothing but slowing down your lambda access.

## Specifying the CloudFront Distribution to server your Web Application
WebAppCloudFrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      PriceClass: PriceClass_100
      Origins:
        - DomainName: $<self:provider.environment.WEBBUCKET>.s3.amazonaws.com
          ## An identifier for the origin which must be unique within the distribution
          Id: WebApp
          ## In case you want to restrict the bucket access use S3OriginConfig and remove CustomOriginConfig
          S3OriginConfig:
             OriginAccessIdentity:
              Ref: AWS::NoValue
        - DomainName: lambda.us-east-1.amazonaws.com
          Id: api
          CustomOriginConfig:
            HTTPPort: '80'
            HTTPSPort: '443'
            OriginProtocolPolicy: https-only
      Enabled: 'true'
      ## Uncomment the following section in case you are using a custom domain
      Aliases:
        - $<self:provider.environment.WEBBUCKET>
      DefaultRootObject: index.html
      ## Since the Single Page App is taking care of the routing we need to make sure ever path is served with index.html
      ## The only exception are files that actually exist e.h. app.js, reset.css
      CustomErrorResponses:
        - ErrorCode: 404
          ResponseCode: 200
          ResponsePagePath: /index.html
      DefaultCacheBehavior:
        DefaultTTL: 0
        MaxTTL: 0
        MinTTL: 0
        Compress: true
        AllowedMethods:
          - GET
          - HEAD
          - OPTIONS
        ## The origin id defined above
        TargetOriginId: WebApp
        ## Defining if and how the QueryString and Cookies are forwarded to the origin which in this case is S3
        ForwardedValues:
          QueryString: 'false'
          Cookies:
            Forward: none
        ## The protocol that users can use to access the files in the origin. To allow HTTP use `allow-all`
        ViewerProtocolPolicy: redirect-to-https
      CacheBehaviors:
      - AllowedMethods:
        - HEAD
        - DELETE
        - POST
        - GET
        - OPTIONS
        - PUT
        - PATCH
        DefaultTTL: 0
        MaxTTL: 0
        MinTTL: 0
        TargetOriginId: api
        ForwardedValues:
          QueryString: 'false'
          Headers:
            - '*'
        ViewerProtocolPolicy: redirect-to-https
        PathPattern: /2015-03-31/*
        Compress: true
      ## The certificate to use when viewers use HTTPS to request objects.
      ViewerCertificate:
        AcmCertificateArn: arn:aws:acm:us-east-1:298225384574:certificate/f55c98f2-13e7-482c-b9e3-05549617c345
        SslSupportMethod: sni-only
      ## Uncomment the following section in case you want to enable logging for CloudFront requests
      #Logging:
      #  IncludeCookies: 'false'
      #  Bucket: logs.digispeaker.com.s3.amazonaws.com
      #  Prefix: digi

That’s definitely the configuration that allows you to directly call lambda from cloudfront. I think the key bit was using a custom SSL certificate. It is just a free AWS certificate with my domain name in it.

I am going to add this in here that API Gateway actually does a helluva lot for you to justify its price. For one, its already solved the problem of calling Lambdas via Cloudfront for you. Other than that it allows for ease of integration with authorizers to protect your API’s behind credentials, throttling of requests to prevent your architecture from getting flooded, API key control to 3rd parties to allow you to control their usage limits, caching of queries, generation of swagger definitions and with that SDK’s for your clients and a lot more.

As a counter to APIGateway – it adds cost and slows every call down. If you are using Sig4 as your authorizer you don’t need it. If you are not using Sig4 you have to have it. Similar throttling can be achieved by setting lambda currency limits. This throttling is not DOS protection - AWS Shield addresses that.

One thing you missed, lambda can only return JSON. API Gateway can use transforms to change that JSON into other things. For example if a lambda returns HTML as a JSON result, API Gateway can strip the JSON and return an HTML page. If you are selling gateway access to a third party, APIGateway is definitely the way to go.

API caching is a mixed bag. For our situation the calls are rarely repeated (they are unique to the user) so there isn’t much to cache. Plus our lambda response is very fast so I am not sure that a cache response from API gateway would matter. We don’t generate content dynamically for non-logged in users. That content is generated once (when it changes) into S3 where it then gets picked up by cloudfront.

I also believe a majority of mobile apps call lambda directly. If you use the AWS SDK in the mobile app, then you are calling lambda directly. I stumbled into this initially because we wanted to make a web app that mimicked our phone app. The phone app used the AWS SDK so it called lambda directly. When I went to work on the web app I simply make it work like the phone app.

2 Likes

Have you investigated requests/responses with binary body in this context? It’s apparently possible through API Gateway, but perhaps isn’t through the invoke SDK?

As far as I know the only thing you can return from a lambda is JSON. In my case I catch that JSON in the browser and then turn it back into whatever form I need it in.