Cloud control for developers (part one)

The cloud has brought us velocity we haven’t seen before. With the wave of a command developers can provision infrastructure on the scale of a proverbial city and populate it with applications in minutes instead of days. Moreover, the city is scalable and will grow and shrink with demand, seemingly without limits. This new-found velocity is liberating, but also challenging in modern ways.

As we speed along with development we face a number of risks. We can lose track of which cities we have constructed in which locations, therefore losing overview. We can provision a new city and accidentally break its connection with the cities surrounding it, therefore breaking integration. Or we can create a metropolis that will scale until it consumes our complete construction budget, thus scaling out of control.

The most obvious solution to these problems is to impose major restrictions on developers. The argument goes that risk is minimized if developers have limited to no power over their accounts. This is without a doubt true, but by choosing this approach we tip the scale in favor of stability at the cost of velocity. We want to keep in control while allowing developers the freedom that is necessary to develop quickly.

Luckily we have the tools we need to remain in control without sacrificing speed. By using Infrastructure-as-Code we can create re-usable templates for building our infrastructure in a way that will help us maintain an overview. We can then use these templates to create temporary deployments and test the integrations so that they won’t break. And we can impose limits and construct alarms so that we don’t scale out of control.

In this first part in a series of posts on Cloud Control we will examine how we can keep in control with Infrastructure-as-Code and how we can leverage IaC to increase our confidence in our systems with tests. To make the examples more tangible we will use CDK and Java to develop an API on AWS.

Overall project structure

The example project we will look at is a typical serverless API on AWS. It consists of a serverless runtime (AWS Lambda) and an HTTP interface (API Gateway). Because CDK allows us to define infrastructure, logic and tests in a single language we can create a multi-module project in Java with Maven. This will also aid us in separating infrastructure code from the logic of our API itself. All of the code in this blog post can be found in the multi-module project in this repository. The general outline of the project can be depicted as follows:

Project structure

The infra sub-module encompasses the IaC code for an API Gateway and an AWS Lambda function. The api sub-module contains portable API code with an AWS lambda entry point, and the integration sub-module contains our integration tests.

Infrastructure-as-code for overview

In the past we named our servers, cared for them and panicked when they broke. The cloud has changed this radically. Infrastructure is available at the push of a button, and if something breaks it is possible to trivialize the work required to re-deploy it. This change is possible due to the automation that the cloud brings, and is propelled further by infrastructure-as-code.

With infrastructure-as-code we define the resources we want in the cloud in the form of, you guessed it, code. This allows us to treat our infrastructure the same way we treat our code. That way we can be consistent with every deployment and always know the configuration that was deployed.

Classic IaC on AWS

Infrastructure-as-code on AWS has existed as a native service since 2010 in the form of CloudFormation. CloudFormation allows us to define infrastructure templates in json or yaml, not unlike the following example:

AWSTemplateFormatVersion: '2010-09-09'

Resources:
  CloudControlApiFunction:
    Type: 'AWS::Lambda::Function'
    Properties:
      Runtime: 'java11'
      Role: !GetAtt 'FunctionRole.Arn'
      Handler: 'nl.p4c.CloudControlHandler'
      Code: './target/api-0.1.jar'
  
  RestApi:
    Type: 'AWS::ApiGateway::RestApi'
    Properties:
        Name: 'CloudControlApi'

  AllResource:
    Type: 'AWS::ApiGateway::Resource'
    Properties:
      RestApiId: !Ref RestApi
      ParentId: !GetAtt RestApi.RootResourceId
      PathPart: '{proxy+}'
  
  AllMethod:
    Type: 'AWS::ApiGateway::Method'
    Properties:
      AuthorizationType: 'NONE'
      HttpMethod: 'POST'
      Integration:
        IntegrationHttpMethod: 'POST'
        Type: 'AWS_PROXY'
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/
              functions/${CloudControlFunction.Arn}/invocations"
      ResourceId: !Ref AllResource
      RestApiId: !Ref RestApi

  ApiAuthorizationLambdaRequestPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: 'lambda:InvokeFunction'
      FunctionName: !GetAtt CloudControlFunction.Arn
      Principal: 'apigateway.amazonaws.com'
      SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*'

This template can then be deployed in the form of a stack, many times if necessary. Each deployment will result in the same configuration unless specified otherwise.

Improving on IaC with CDK

CloudFormation is a powerful service, but the template definitions themselves have limited expressive capabilities. The language used is either json or yaml, and is closer to a data format than a true programming language. Furthermore, the resource definitions are not abstracted in a way that is convenient for developers. Instead of defining API’s and runtimes, we define each and every building block required by Amazon API Gateway. The Serverless Application Model (AWS SAM) is an extension on CloudFormation that aims to abstract some of these concepts, but we can do even better nowadays.

Enter CDK, short for Cloud Development Kit. With CDK we use one of the supported programming languages to define our infrastructure in the form of Stacks and Constructs. These are then translated to pure CloudFormation on synthesize. This gives us a clear and readable definition of our infrastructure that is easier to write, reason about and maintain. For example, the same API can be defined as follows in Java:

public class CodeStack extends Stack {
    public CodeStack(final Construct scope, final String id, final StackProps props) {
        super(scope, id, props);

        Function apiFunction = Function.Builder.create(this, "CloudControlApiFunction")
                .runtime(Runtime.JAVA_11)
                .timeout(Duration.seconds(15))
                .memorySize(512)
                .code(Code.fromAsset("../api/target/api-0.1.jar"))
                .handler("nl.p4c.code.api.AwsHandler")
                .build();

        LambdaRestApi lambdaRestApi = LambdaRestApi.Builder.create(this, "CloudControlApi")
                .handler(apiFunction)
                .build();
  }
}

Aside from the increased clarity and comfortable level of abstraction, we have also gained additional benefits such as compiler support, type checking, autocompletion and the ability to use every feature our IDE can throw at us. All these benefits boost productivity and velocity further.

Integration testing in the cloud

Velocity and testing go hand-in-hand, or at the very least they should. Both unit tests and integration tests have their place, but as you rapidly grow a city of interconnected services you will start to rely on integration tests to make sure it all still operates. With our new-found IaC powers nothing prevents us from deploying all our infrastructure and code to the cloud, run the tests and then clean up afterward. We will start with the integration test itself and then look at how the whole process can be orchestrated.

Integration testing in a CDK project

The tests itself are not what we are interested in here, mostly because we will simply be testing an API with an http client. Given an API that reverses any string passed to it in the body, an integration test can look something like this:

package nl.p4c.code.integration;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

import static org.junit.jupiter.api.Assertions.assertEquals;

class InfraIT {

    private static final HttpClient httpClient = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    @Test
    void postRequest() throws URISyntaxException, IOException, InterruptedException {
        // Given
        String restApiUrl = System.getProperty("restApiUrl");
        String name = "Control";

        HttpRequest postRequest = HttpRequest.newBuilder()
                .uri(new URI(restApiUrl))
                .POST(HttpRequest.BodyPublishers.ofString(String.format(
                        "{\"name\": \"%s\"}", name)))
                .build();

        // When
        HttpResponse<String> response = httpClient.send(postRequest,
          HttpResponse.BodyHandlers.ofString());

        // Then
        assertEquals(200, response.statusCode());
        assertEquals("lortnoC", response.body());
    }
}

What is important to our orchestration of the process is the fact that we need to receive the API url from outside. The test expects a property called restApiUrl. This is because every deployment will result in a new, unique API url.

Orchestrating the process

The orchestration involves 1) packaging our project, 2) deploying the infrastructure, 3) retrieving our API URL, 4) running the tests with this URL and then 5) destroying our deployed resources. The trick to retrieving the url is to add what is called an Output to our Construct, and then storing all outputs in a file on deploy.

First we add the Output to our Construct:

public class CodeStack extends Stack {
    public CodeStack(final Construct scope, final String id, final StackProps props) {
        super(scope, id, props);

        // Other resources removed for readability

        LambdaRestApi lambdaRestApi = LambdaRestApi.Builder.create(this, "CloudControlApi")
                .handler(apiFunction)
                .build();

        // The Output that makes our API URL accessible
        CfnOutput.Builder.create(this, "RestApiUrl")
                .value(lambdaRestApi.getUrl())
                .exportName("RestApiUrl")
                .build();
  }
}

Then we orchestrate the whole process using bash:

#!/bin/bash
set -e

OUTPUT_FILE="./target/outputs.json"

# This will package both our infrastructure as well as our API code
mvn package

(
  cd infra || exit

  # Note the --outputs-file here
  cdk deploy --outputs-file ${OUTPUT_FILE} --require-approval="never"
)

# Retrieve the RestApiUrl from the file using jq
restApiUrl=$(jq -r ".InfraStack.RestApiUrl" "./infra/target/outputs.json")

# Pass the URL to the integration tests
mvn failsafe:integration-test -pl integration -DrestApiUrl="${restApiUrl}"

(
  cd infra || exit
  cdk destroy -f
)

Summary

The cloud gives us a new kind of superpower as developers. We can develop, test and deploy quicker than ever before on an impressive scale. With this power comes also the responsibility to not lose control over our own creations.

The proverbial cities we build can spiral out of control in three ways: we can lose overview, break something in our web of integrations or scale out of control. In this post we saw how we can keep track of everything we deploy by using Infrastructure-as-Code. Next we used IaC to our advantage to orchestrate temporary deployments for our integration tests. By applying IaC and writing tests we have made important steps towards control over our system in the cloud.

Yet we are not done, as our city can still scale well out of our control without us even noticing it. Tune in next time for a post on how we can apply limits and alarms to gain visibility and exert control over how much our system should scale.

Ilia Awakimjan

Ilia Awakimjan is na het behalen van zijn Master-titel sinds 2017 Software Engineer met specialisatie AWS in dienst bij Profit4Cloud. Ilia is AWS Certified DevOps Professional, AWS Certified Security Specialty en AWS Certified Networking Specialty gecertificeerd.