GO Serverless! Part 2 - Terraform and AWS Lambda External CI

GO Serverless! Part 2 - Terraform and AWS Lambda External CI

If you didn't read the previous part, we highly advise you to do that!


In the previous part we have discussed the pros and cons of serverless architectures, different serverless tools and technologies, integration patterns between serverless components and more.

In this part, we are going to discuss how we can decouple Lambda code and dependencies continuous-integration from Terraform and delegate it to external CI services like Codebuild and Codepipeline.

Why Lambda CI using Terraform is a bad and difficult approach?

Difficult

There are many drawbacks when using Terraform to continuously integrate new lambda code/dependencies changes:

From a structural perspective: it is considered a bad practice to mix business logic (code) and packages (dependencies) with Terraform HCL files in the same codebase, because the engineering team building lambda code should not worry about cloning the infrastructure repository and committing to it. On the other hand, the DevOps team should have a clean Terraform codebase without any business logic.

From an infrastructure CI perspective: if your DevOps team is using Terraform Cloud for infrastructure CI, each time a backend engineer pushes a new commit to Lambda code, Terraform Cloud will have to start plan/apply runs, and if the lambda code is actively updated, for example the Lambda is an API and the engineering team is updating it many times during the day, this will slow down Terraform Cloud runs queue as terraform can only have one active run per workspace, and, eventually, the DevOps team will be frustrated because they will have to wait for the runs to finish.

No way to support releases: you can't have a QA Lambda which is updated on merges to master, and a Production Lambda which is updated if a tag/release is created, because the lambda is not version controlled on its own git repository, and it is part of the infrastructure repository.

No proper way to build lambda application dependencies: if the package's descriptor changed, there is no way to build and publish the dependencies again and the process should be done manually each time there is a change.

For compatibility: Lambda application dependencies should be built using a runtime similar to the actual Lambda runtime, and even worse than that, if you decide to build dependencies using Terraform Cloud, you can't. because you don't have control over terraform instances, and you don't know if a specific nodejs or python version is already installed.

What are we trying to achieve?

Bring Balance

Before we start implementing the solution we should agree on the set of capabilities the solution must adhere to:

Support famous runtimes: The CI process should be able to support at least the two famous lambda runtimes python and nodejs.

Support custom packages: installing custom packages that does not exist in Lambda runtime passed to CI process as a package's descriptor file path in git repository. packages.json or requirements.txt.

Caching: the integration/deployment process should be fast thanks to code and dependencies caching.

Resiliency: during deployment, the process should not break the currently published version and traffic shifting between the current and new deployment should be seamless.

Compatibility: Lambda dependencies packages should be built in a sandboxed local environment that replicates the live AWS Lambda environment almost identically – including installed software and libraries.

Loose coupling: Lambda code should be decoupled entirely from Terraform.

Interoperability: The CI process should be able to deploy Lambdas versioned in github repositories.

Support mono-repos: The solution should be able to support mono-repos and dedicated repos, so all lambdas can be placed in a single git repositories, or it can be versioned in a dedicated repository.

Alerts: The CI pipeline should notify the failure or success of the process through slack.

Prepare terraform to delegate CI to codepipeline/codebuild

are_we_realy_doing_this

In order to delegate Lambda CI to codepipeline and codebuild, we should decouple lambda code and dependencies from terraform resources.

Lambda dummy code package

The first thing we need to do is providing a dummy code package to Terraform Lambda resource and instructing Terraform to ignore any changes affecting s3_key, s3_bucket, layers and filename as they will be changed by the CI process and we don't want Terraform to revert those changes on every deployment.

In other words, the external CI process will say to terraform: Don't worry about code or dependencies, I will take care of that

data "archive_file" "dummy" {
  output_path = "${path.module}/dist.zip"
  type        = "zip"
  source {
    content  = "dummy dummy"
    filename = "dummy.txt"
  }
}

resource "aws_lambda_function" "function" {
  function_name = local.prefix
  publish       = true
  role          = aws_iam_role.role.arn

  # runtime
  runtime = var.runtime
  handler = var.handler

  # resources
  memory_size = var.memory_size
  timeout     = var.timeout

  # dummy package, package is delegated to CI pipeline
  filename = data.archive_file.dummy.output_path

  environment {
    variables = var.envs
  }

  tags = merge(
    local.common_tags,
    map(
      "description", var.description
    )
  )

  # LAMBDA CI is done through codebuild/codepipeline
  lifecycle {
    ignore_changes = [s3_key, s3_bucket, layers, filename]
  }
}

The dummy package we have created and provided to Lambda resource is important, because during Lambda resource creation Terraform will not be able to create the Lambda function without a code package.

We also added a lifecycle ignore_changes meta-argument instructing terraform to ignore changes to code [s3_key, s3_bucket, filename] and dependencies [layers].

Lambda alias

A Lambda alias is like a pointer to a specific function version. We can access the function version using the alias Amazon Resource Name (ARN). Since the Lambda version will be changed constantly, we need to create a latest alias and on each deploy we have to update the alias with the new lambda version.

# Required by lambda provisioned concurrency
resource "aws_lambda_alias" "alias" {
  name             = "latest"
  description      = "alias pointing to the latest published version of the lambda"
  function_name    = aws_lambda_function.function.function_name
  function_version = aws_lambda_function.function.version

  lifecycle {
    ignore_changes = [
      description,
      routing_config
    ]
  }
}

The main advantage of Lambda alias is to avoid other resources depending on the lambda function from invoking a broken lambda function version, and they will always invoke the latest stable version that the CI process have tagged with latest alias at the end of the pipeline.

The other advantage is the ability for other services to invoke latest version without needing to update them whenever the function version changes.

We can also specify routing configuration on an alias to send a portion of the traffic to a second function version. For example, we can reduce the risk of deploying a new version by configuring the alias to send most of the traffic to the existing version, and only a small percentage of traffic to the new version.

A lambda alias is required in case you want to implement provisioned concurrency afterwards.

Calling the module

We have prepared terraform-aws-codeless-lambda reusable terraform module for you here, you can call it like this for creating Lambda functions ready for External CI:

module "lambda" {
  source      = "git::https://github.com/obytes/terraform-aws-codeless-lambda.git//modules/lambda"
  prefix      = "demo-api"
  common_tags = {env = "test", stack = "demos"}
  description = "Terraform is my creator but Codepipeline is the demon I listen to"

  envs        = {API_KEY = "not-secret"}
  runtime     = "python3.9"
  handler     = "app.handler"
  timeout     = 29
  memory_size = 1024

  policy_json            = data.aws_iam_policy_document.policy.json
  logs_retention_in_days = 14
}

The module will output the required attributes that will be used by CI process and by other components using/invoking the lambda, like API Gateway, etc.

output "lambda" {
  value = {
    name       = aws_lambda_function.function.function_name
    arn        = aws_lambda_function.function.arn
    runtime    = aws_lambda_function.function.runtime
    alias      = aws_lambda_alias.alias.name
    invoke_arn = aws_lambda_alias.alias.invoke_arn
  }
}

CI Process Workflow

PATIENCE

Before writing the CI script, we need to design the workflow of the process.

Lambda CI workflow

Check

In the first stage we checkout the new packages' descriptor, and check if the previous packages' descriptor exists in S3, which is an edge case for the first execution. And if the package descriptor exists we need to download it for next stage.

We also checkout the new application code and current source code hash that will be used in the next stage!

Bundle

If it is the first build we can jump directly and build/package the dependencies in the packages' descriptor

Otherwise, if it is not the first build, and the previous descriptor is already cached in S3, we will compare the previously cached descriptor with the new descriptor.

If they are not the same, then the descriptor is changed, and we will have to build and package the dependencies again, or else we can jump directly to the next stage.

Next, we package the new application source code, and we calculate its hash and compare it with the current source code hash. if they are different, then we have to push the new source code to S3.

For building lambda application dependencies we will use lambci/lambda docker image which is a sandboxed local environment that replicates the live AWS Lambda environment almost identically – including installed software and libraries.


Uploading artifacts

After building and packaging application code and/or dependencies, it's time to upload the packaged files to S3. we need to upload the artifacts in two different names:

  • The first package should include the word latest in the artifact name.
  • The second package should include the commit digest hash in the artifact name.

The reason for this redundancy is to ensure we don't update the existing Lambda code with broken packages, so the previous lambda version will keep pointing to the previous code/dependencies, and the newer lambda version will point to the new code with the new commit digest hash as the artifact name.

This is an example of the files that will be uploaded to S3:

  • APP version S3 KEY - lambda-ci-demo/{hash}/app.zip
  • APP latest S3 KEY - lambda-ci-demo/latest/app.zip
  • DEP version S3 KEY - lambda-ci-demo/{hash}/deps.zip
  • DEP latest S3 KEY - lambda-ci-demo/latest/deps.zip

AWS Lambda size limit is 50mb when you upload the code directly to the Lambda service. However, if the code deployment package size is more than that, we have an option to upload it to S3 and download it while triggering the function invocation. That's why we are going to use S3 to support large deployment packages.


Deploying dependencies and application

Now that we have the dependencies stored in S3, we can publish a new layer version that points to the new dependencies with publish_layer_version(..) and update the lambda function to use the new layer version with update_function_configuration(..).

After a successful deployment, we cache the new packages' descriptor in order for subsequent runs to be faster.

After that, we have to deploy the actual lambda code, so we update the Lambda function to point to the newer code package in S3 using update_function_code(..).

Publishing application

Yes, we have deployed our Lambda function with newer dependencies and newer code but the services depending on it are still pointing to the old Lambda function version tagged with latest alias, so we need to shift traffic to the new version.

First, we should create a new lambda function version using publish_version(..) and then we have to update the lambda alias to point to the newly created lambda version using update_alias(..).

Implementation

The stages in the above workflow are implemented in this package https://github.com/obytes/aws-lambda-ci and published in a public PyPi repository, you can install it with pip:

pip3 install aws-lambda-ci

Local CI with standalone CI Script

automated_ci

In case you are still developing your function locally and you want to test it but you still don't have codebuild and codepipeline resources in development environment, you can use aws-lambda-ci like this:

aws-lambda-ci \
--app-s3-bucket "demo-artifacts" \
--function-name "demo-api" \
--function-runtime "python3.9" \
--function-alias-name "latest" \
--function-layer-name "demo-api-deps" \
--app-src-path "src" \
--app-packages-descriptor-path "requirements.txt" \
--source-version "1.0.2" \
--aws-profile-name "kodhive_prd" \
--watch-log-stream

First usage will take a longer time, because it needs to pull lambci/lambda from dockerhub.

Code and dependencies changed

If both code and dependencies changed, the pipeline will publish both changes.

demo_code_changed_deps_changed

Just code changed

If code changed but not dependencies, the pipeline will publish new code and the dependencies will be left intact.

demo_just_code_changed

Nothing changed

If both code and dependencies not changed, the pipeline will not publish anything.

demo_nothing_changed

Leveraging codebuild/codepipeline for automated CI

fun_begins

After preparing the python CI package, we can now configure codebuild and codepipeline to use it.

TLDR: We've published a reusable terraform module for Lambda CI using Codepipeline/Codebuild, you can check it here.

Build specification

Let's start by creating the build specification file. It will be used by the codebuild project when the build is triggered by codepipeline. In the install phase, we instruct codebuild to use runtimes shipped with docker.

In the pre-build phase, we get the source version that triggered the build, which can be a commit hash for pushes events or SemVer tag for releases events and we install aws-lambda-ci.

Finally, during build phase, we call aws-lambda-ci that should take care of the next CI phases.

version: 0.2

phases:
  install:
    runtime-version:
      docker: 19

  pre_build:
    commands:
      - SOURCE_VERSION=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION)
      - pip3 install aws-lambda-ci

  build:
    commands:
      - |
        aws-lambda-ci \
        --source-version "$SOURCE_VERSION" \
        --function-name "$FUNCTION_NAME" \
        --function-runtime "$FUNCTION_RUNTIME" \
        --function-alias-name "$FUNCTION_ALIAS_NAME" \
        --function-layer-name "$FUNCTION_NAME-deps" \
        --app-s3-bucket "$APP_S3_BUCKET" \
        --app-src-path "$APP_SRC_PATH" \
        --app-packages-descriptor-path "$APP_PACKAGES_DESCRIPTOR_PATH"

Codebuild

After preparing the build specification file, we can now pass it to codebuild project along with environment variables that will be forwarded to aws-lambda-ci.

It's important to set the source to CODEPIPELINE as codepipeline will trigger the codebuild project in response to Github webhook and will prevent concurrent builds that can cause issues during deployment.

resource "aws_codebuild_project" "default" {
  name          = local.prefix
  description   = "Build ${var.github_repository.branch} of ${var.github_repository.name} and deploy"
  build_timeout = var.build_timeout
  service_role  = aws_iam_role.role.arn

  source {
    type      = "CODEPIPELINE"
    buildspec = file("${path.module}/templates/buildspec.yml")
  }

  artifacts {
    type = "CODEPIPELINE"
  }

  cache {
    type  = "LOCAL"
    modes = ["LOCAL_DOCKER_LAYER_CACHE"]
  }

  environment {
    compute_type    = var.compute_type
    image           = var.image
    type            = var.type
    privileged_mode = var.privileged_mode

    # Bucket
    # ------
    environment_variable {
      name  = "APP_S3_BUCKET"
      value = var.s3_artifacts.bucket
    }

    # Build
    # -----
    environment_variable {
      name  = "APP_SRC_PATH"
      value = var.app_src_path
    }

    environment_variable {
      name  = "APP_PACKAGES_DESCRIPTOR_PATH"
      value = var.packages_descriptor_path
    }

    # Function
    # --------
    environment_variable {
      name  = "FUNCTION_NAME"
      value = var.lambda.name
    }

    environment_variable {
      name  = "FUNCTION_RUNTIME"
      value = var.lambda.runtime
    }

    environment_variable {
      name  = "FUNCTION_ALIAS_NAME"
      value = var.lambda.alias
    }

    environment_variable {
      name  = "FUNCTION_LAYER_NAME"
      value = "${var.lambda.name}-deps"
    }
  }

  tags = local.common_tags
}

Codepipeline

Now that we have prepared the codebuild project, we will create the pipeline that will orchestrate the CI runs/stages.

During Source stage, we set up codepipeline to detect any change to target git branch if the environment is a pre-release like qa or staging, checkout the lambda git repository and send it to next stage through artifacts bucket.

In BuildAndDeploy stage, codepipeline will trigger codebuild and wait for codebuild execution to deploy and publish the lambda function.

resource "aws_codepipeline" "default" {
  name     = local.prefix
  role_arn = aws_iam_role.role.arn

  ##########################
  # Artifact Store S3 Bucket
  ##########################
  artifact_store {
    location = var.s3_artifacts.bucket
    type     = "S3"
  }

  #########################
  # Pull source from Github
  #########################
  stage {
    name = "Source"

    action {
      name             = "Source"
      category         = "Source"
      owner            = "AWS"
      provider         = "CodeStarSourceConnection"
      version          = "1"
      output_artifacts = ["code"]

      configuration = {
        FullRepositoryId     = "${var.github.owner}/${var.github_repository.name}"
        BranchName           = var.github_repository.branch
        DetectChanges        = var.pre_release
        ConnectionArn        = var.github.connection_arn
        OutputArtifactFormat = "CODEBUILD_CLONE_REF"
      }
    }
  }

  #########################
  # Build & Deploy to S3
  #########################
  stage {
    name = "BuildAndDeploy"

    action {
      name             = "BuildAndDeploy"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["code"]
      output_artifacts = ["package"]
      version          = "1"

      configuration = {
        ProjectName = var.codebuild_project_name
      }
    }
  }
}

Release webhook

To support triggering the pipeline in response to github releases, we have to configure webhook resources that will only be created if var.pre_release is set to false.

# Webhooks (Only for github releases)
resource "aws_codepipeline_webhook" "default" {
  count           = var.pre_release ? 0:1
  name            = local.prefix
  authentication  = "GITHUB_HMAC"
  target_action   = "Source"
  target_pipeline = aws_codepipeline.default.name

  authentication_configuration {
    secret_token = var.github.webhook_secret
  }

  filter {
    json_path    = "$.action"
    match_equals = "published"
  }
}

resource "github_repository_webhook" "default" {
  count      = var.pre_release ? 0:1
  repository = var.github_repository.name

  configuration {
    url          = aws_codepipeline_webhook.default.0.url
    secret       = var.github.webhook_secret
    content_type = "json"
    insecure_ssl = true
  }

  events = [ "release" ]
}

Slack Notifications

To receive slack alerts for pipeline's failure and success events, we will add two notification rules. the first one will notify started and succeeded events and the second will notify failure events.

resource "aws_codestarnotifications_notification_rule" "notify_info" {
  name        = "${local.prefix}-info"
  resource    = aws_codepipeline.default.arn
  detail_type = "FULL"
  status      = "ENABLED"

  event_type_ids = [
    "codepipeline-pipeline-pipeline-execution-started",
    "codepipeline-pipeline-pipeline-execution-succeeded"
  ]

  target {
    type    = "AWSChatbotSlack"
    address = "${local.chatbot}/${var.ci_notifications_slack_channels.info}"
  }
}

resource "aws_codestarnotifications_notification_rule" "notify_alert" {
  name        = "${local.prefix}-alert"
  resource    = aws_codepipeline.default.arn
  detail_type = "FULL"
  status      = "ENABLED"

  event_type_ids = [
    "codepipeline-pipeline-pipeline-execution-failed",
  ]

  target {
    type    = "AWSChatbotSlack"
    address = "${local.chatbot}/${var.ci_notifications_slack_channels.alert}"
  }
}

Put it all together

finally

We can structure the CI components into two modules, one for the build and another for the pipeline.

module "code_build_project" {
  source             = "../../components/build"
  prefix             = local.prefix
  common_tags        = var.common_tags

  # S3
  s3_artifacts         = var.s3_artifacts
  lambda               = var.lambda

  # App
  app_src_path                = var.app_src_path
  packages_descriptor_path    = var.packages_descriptor_path

  # Github
  connection_arn    = var.github.connection_arn
  github_repository = var.github_repository
}

module "code_pipeline_project" {
  source      = "../../components/pipeline"
  prefix      = local.prefix
  common_tags = local.common_tags

  # Github
  github            = var.github
  pre_release       = var.pre_release
  github_repository = var.github_repository

  # S3
  s3_artifacts = var.s3_artifacts

  # Codebuild
  codebuild_project_name = module.code_build_project.codebuild_project_name

  ci_notifications_slack_channels = var.ci_notifications_slack_channels
}

Finally, we will wrap the two modules into a single module and call it like this.

module "lambda_ci" {
  source      = "git::https://github.com/obytes/terraform-aws-lambda-ci.git//modules/ci"
  prefix      = "demo-api-ci"
  common_tags = {env = "test", stack = "demos-ci"}

  lambda                   = module.lambda.lambda
  app_src_path             = "src"
  packages_descriptor_path = "requirements.txt"

  # Github
  s3_artifacts = {
    arn    = aws_s3_bucket.artifacts.arn
    bucket = aws_s3_bucket.artifacts.bucket
  }
  pre_release  = true
  github       = {
    owner          = "obytes"
    token          = "gh_123456789876543234567845678"
    webhook_secret = "not-secret"
    connection_arn = "[GH_CODESTAR_CONNECTION_ARN]"
  }
  github_repository = {
    name   = "demo-api"
    branch = "main"
  }

  # Notifications
  ci_notifications_slack_channels = {
    info  = "ci-info"
    alert = "ci-alert"
  }
}

What's next?

Now that we have implemented our Lambda CI reusable pipeline, in the next part we will use it to deploy nodejs and flask APIs.

Share the article if you find it useful! See you next time.

party-over-star-wars
Hamza Adami
Hamza Adami
2021-09-29 | 16 min read
Share article

More articles