Hi, I'm Joseph

View Original

From Scratch: An API Gateway

I tried to think of the best way to describe building an app from scratch in the cloud, but there are so many different ways you can easily get lost in all the options. So I decided I would start by showcasing what is arguably the easiest app you can make from a computing perspective. It offers the least amount of operational overhead, but at the same time, it's the most expensive option on paper.

AWS API Gateway

When I think of simple I think API. That might just be because I'm so used to them at this point, but there are also some direct comparisons to other compute solutions which we'll get into later in other posts as we get into more complex cloud compute ideas. Right now, though, we're going to use AWS API Gateway to deploy the first version of the API we're going to make for the rest of this series. This is a great choice if you want to spend as little time maintaining your gateway as possible. There are no load balancers to deal with. You can optionally put an edge cache (CloudFront) in front of it easily, and it connects to other serverless technology in AWS seamlessly.

Setting up the gateway

For the example I'm building, I'm going to actually use a terraform module that already has all the pieces we need. The first thing we need to do is add the module to our project by editing the cdktf.json file to include the module.

"terraformModules": [
 {
    "name": "apigateway",
    "source": "terraform-aws-modules/apigateway-v2/aws",
    "version": "~> 5.2.0"
 }
],

Later on, we're going to add another module but for now, that's all we need to build the gateway.

Defining the gateway

I'm going to make a serverless folder and put an api.ts to hold all the work we'll need to get the app up and working. Inside our class, we have to import the module we made and then create the gateway.

import { Apigateway } from "../.gen/modules/apigateway";
let gw = new Apigateway(scope, `${name}-gw`, {
  name: `${name}-gw`,
  protocolType: "HTTP",
  corsConfiguration: {
    allowHeaders: [
      "content-type",
      "x-amz-date",
      "authorization",
      "x-api-key",
      "x-amz-security-token",
      "x-amz-user-agent",
    ],
    allowMethods: ["*"],
    allowOrigins: ["*"],
  },

  createDomainName: true,
  createDomainRecords: true,
  createCertificate: true,

  domainName: config.domain,
  hostedZoneName: config.hostedZone,

  routes: {
    "ANY /": {
      integration: {
        uri: lambdaArn,
        payload_format_version: "2.0",
        timeout_milliseconds: 3000,
      },
    },
    $default: {
      integration: {
        uri: lambdaArn,
        payload_format_version: "2.0",
        timeout_milliseconds: 3000,
      },
    },
  },
});

In my case, I already had a hosted zone set up in my AWS account for a subdomain aws.josephbulger.com. I decided to go ahead and add this gateway to that so the hosted zone in this example is aws.josephbulger.com and the API's domain is apigw.examples.how2cloud.aws.josephbulger.com.

You don't have to set this up with a domain. If you don't AWS will generate a random URL to host your gateway instead, and you can use that for making your API calls.

CORS

I have the API's cors configuration set up to allow traffic from anywhere because, with this kind of setup, I'm assuming the front end that would potentially use this is not hosted by the same app. This is a common approach I use for my systems. I don't like to have the API backend be served by the same system that serves the front end. I like to keep them separate.

Routes

Finally, you need to define the routes that the gateway will serve. In my case, I'm going to send all my traffic to a single lambda, which we'll talk about in a second. For now, we just need to know to send all the traffic over to the lambda, and let it figure out what to do with it.

Stages

Additionally, we have to define the default stage. Stages in API Gateway are kind of a weird concept. It's beyond the scope of what I want to cover in this post, but for this purpose let's just say you have to define a default one, so the gateway knows which stage gets the traffic. In our case, we're just going to set it up the same way we set up the route we made.

Lambda

The lambda is the part that makes this computing solution simple and easy. It does this because it's truly serverless. We don't have to worry about running a server, neither as a virtual machine an EC2 instance somewhere, nor as compute sitting on top of an orchestration plan like ECS or k8s. We just create the app, and give it to the lambda. The largest amount of operational overhead with the lambda boils down to dealing with how much CPU and memory to allocate to it, and these days you only specify the memory because the CPU is adjusted based on how much memory you choose. The price we pay for this ease of use is, well, the actual sticker price. All things considered, you can expect to pay anywhere from double to up to 6 times the price of other compute solutions in AWS for the equivalent compute time, but that's on paper. In reality, there are some very compelling reasons to use Lambda, including financially.

Setting up your Lambda

So to get using the lambda we first need to add the module into cdktf the same way we did the gateway.

"terraformModules": [
 {
    "name": "lambda",
    "source": "terraform-aws-modules/lambda/aws",
    "version": "~> 7.14.0"
 }
],

Defining the Lambda

Now we can add the lambda to our API.

let lambda = new Lambda(scope, `${name}-function`, {
  functionName: `${name}-function`,
  createPackage: false,
  packageType: "Image",
  architectures: ["x86_64"],
  imageUri: `${config.ecrUri}:${config.tag}`,
  publish: true,
});

const lambdaArn = lambda.lambdaFunctionArnOutput;

We used the lambdaArn earlier where we defined the gateway. This is actually where it came from, as an output from the lambda module. Now the last thing we need to do to connect the two is set up the lambda's triggers. The trick to them is that the lambda needs to know about the gateway, but we have to define the lambda first. What we do to get around this is just define the triggers after we make both the lambda and the gateway and then add the triggers back into the lambda.

It looks something like this:

let apiExecArn = gw.apiExecutionArnOutput;

let triggers = {
  APIGatewayAny: {
    service: "apigateway",
    source_arn: `${apiExecArn}/*/*`,
  },
};

lambda.allowedTriggers = triggers;

Now that the lambda is connected to the gateway we need to create the app that the lambda is going to run.

Docker

I created a small server in examples that creates a simple go web server built using docker. What's nice about Go is that you can actually deploy it from a scratch image so the overall size of the image is really small (~8 MB). Perfect for running on a lambda.

The docker file looks something like this:

# syntax=docker/dockerfile:1

FROM golang:1.23 AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY *.go ./

ARG TARGETARCH=amd64
ENV GOARCH=$TARGETARCH

ARG TARGETOS=linux
ENV GOOS=$TARGETOS

RUN CGO_ENABLED=0 go build -o /api

FROM scratch

COPY --from=builder /api /api

EXPOSE 8080

ENTRYPOINT ["/api"]

We'll be building it using docker buildx bake so we need to set up a bake file too:

group "default" {
 targets = ["api"]
}

variable "IMAGE_URI" {
 default = "api"
}
variable "IMAGE_TAG" {
 default = "local"
}

target "api" {
 dockerfile = "Dockerfile"
 context = "."
 tags = ["${IMAGE_URI}:${IMAGE_TAG}"]
 platforms = ["linux/amd64"]
 args = {
 TARGETARCH = "amd64"
 TARGETOS = "linux"
 }
 attest = []
 provenance = false
}

Provenance

One issue with deploying to lambdas, at least for now, is that they don't support multi-architecture docker images. So when we deploy our image to ECR we have to disable provenance so docker knows to only build the image in the format we specify with multi-mode turned off.

That's why in our GitHub action we turn provenance off when we do the docker build and push:

- name: Build and push
  uses: docker/bake-action@v5
  with:
    push: true
    workdir: examples/api
    provenance: false
  env:
    IMAGE_URI: [[ECR_URL]]/api
    IMAGE_TAG: ${{ github.ref_name }}

That's It

Now you have a fully functioning API. You can see the one I've deployed at https://apigw.examples.how2cloud.aws.josephbulger.com/. There's also a route you can play around with the changes in the response based on what you give it: https://apigw.examples.how2cloud.aws.josephbulger.com/some/kind/of/trick.

Show me the code

If you want to follow along with the code that I used to build this, or any other part of the "from scratch" series, check out my how to cloud repo on github.