post-image

Preview Environments: Hands-on mit AWS CloudFormation


Was Preview Environments sind haben wir bereits in unserem Blogartikel zu GitOps und Preview Environments gezeigt. In diesem Artikel wird gezeigt, wie Preview Environments einfach mit GitHub Actions und AWS Cloud Formation realisiert werden können.

Nachfolgend finden Sie eine Übersicht über die Komponenten, die wir in diesem Artikel erstellen. Eine Übersicht über den Code dieses Artikels finden Sie auch auf GitHub.

Übersicht über die Architektur des Artikels

Schritt 1: Ein Repository mit Anwendungscode erstellen

Zunächst benötigen wir eine einfache Anwendung, für die wir Preview Environments bereitstellen möchten. Für diesen Artikel verwenden wir eine einfach Go Anwendung.

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		_, err := fmt.Fprintln(w, "Hello World!")
		if err != nil {
			panic(err)
		}
	})
	log.Fatalf("error: %s", http.ListenAndServe(":8080", nil))
}

Da wir unsere Anwendung im Elastic Container Service von AWS bereitstellen möchten, müssen wir außerdem ein Container Image bauen. Wir verwenden für das Docker Image eine Builder-Stage mit den Go Tools. Die eigentliche Laufzeitumgebung verwendet scratch also ein leeres Image als Basis.

FROM golang:1.16-alpine as builder
ENV CGO_ENABLED=0
WORKDIR /app
COPY . /app/
RUN go build -o server

FROM scratch
ENTRYPOINT ["/server"]
COPY --from=builder /app/server /

Schritt 2: Den Cloud Formation Stack erstellen

Als nächstes erstellen wir ein Cloud Formation Template für unsere Stacks. Der ECS Cluster ist nicht Teil des Templates und muss vorher angelegt werden. Das hat den Vorteil, dass alle unsere Preview Deployments in dem gleichen Cluster laufen, allerdings erfordert es auch, dass das Template entsprechend mit der Cluster ARN, der VPC ID und der IDs der Subnetze parametrisiert wird.

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  ClusterArn:
    Type: String
    Description: ARN of the cluster
  Namespace:
    Type: String
    Description: Name of the environment
  AccountId:
    Type: String
    Description: ID of the AWS account
  Vpc:
    Type: String
    Default: vpc-00aa46df83ae04b6e
  Subnets:
    Type: CommaDelimitedList
    Default: subnet-0d73a1d8e54aa99ea,subnet-0cf4a9eeae2e1c6d8

Resources:
  ApplicationService: # ...
  ApplicationTaskDefinition: # ...
  LoadBalancer: # ...
  LoadBalancerTargetGroup: # ...
  LoadBalancerListenerHTTP: # ...
  LoadBalancerSecurityGroup: # ...
  ApplicationExecutionRole: # ...
  ApplicationLogGroup: # ...
  ApplicationTaskRole: # ...
  ExecutionRole: # ...

Outputs:
  DeploymentUrl:
    Description: URL of the deployed application.
    Value: !Join ["", ["http://", !GetAtt LoadBalancer.DNSName]]

Ansonsten besteht der Stack lediglich aus einer ECS Task Definition und dem zugehörigen Service, einem Load Balancer, der Loggruppe und nötigen Security Groups und IAM Rollen.

Cloud Formation Stack für Preview Environments im Designer

Das komplette Template für den Stack kann auf GitHub abgerufen werden.

Schritt 3: Per GitHub Actions Preview Environments bereitstellen

Der nächste Schritt besteht darin, die Anwendung nun automatisch für jeden Feature Branch bereitzustellen. Defür benötigen wir GitHub Workflows, die angestoßen werden, wenn neue Feature Branches erstellt oder aktualisiert werden, beziehungsweise wenn Pull Requests für Feature Branches geschlossen werden.

Preview Environments für Feature Branches deployen

Zunächst erstellen wir den Workflow um Preview Environments bereitzustellen. Dieser wird ausgelößt, wenn neue Pull Requests für Feature Branches (feat/xyz) geöffnet werden oder neue Commits auf solche Branches gepusht werden.

name: Deploy Preview Environment
on:
  pull_request:
    types:
      - opened
      - reopened
    branches:
      - feat/*
  push:
    branches:
      - feat/*

Der erste Job in diesem Workflow klont das Repository, baut das Docker Image und pusht dieses in die AWS Elastic Container Registry.

jobs:
  build-image:
    name: Build Image
    runs-on: ubuntu-20.04

    steps:
      - name: Checkout
        uses: actions/checkout@v2.3.4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-central-1.amazonaws.com
          ECR_REPOSITORY: prev-application
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:${{ github.sha }} .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{ github.sha }}

Der zweite Job aktualisiert den Stack, um das zuvor gebaute Image zu verwenden, und stellt dieses anschließend mittels Cloud Formation bereit. Prinzipiell ist es auch möglich, das tag des Images einfach als Parameter an den Stack zu übergeben. Als letztes kommentiert der Workflow den Pull Request und informiert uns, dass ein Preview Environment bereitsteht.

jobs:
  build-image: # ...
  deploy:
    name: Deploy to AWS
    runs-on: ubuntu-20.04
    needs:
      - build-image
    steps:
      - name: Disallow Concurrent Runs
        uses: byu-oit/github-action-disallow-concurrent-runs@v2
        with:
          token: ${{ github.token }}

      - name: Checkout
        uses: actions/checkout@v2.3.4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Extract branch name
        shell: bash
        run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/feat/})"
        id: extract_branch

      - name: Update Image Version in Stack
        id: update-stack
        run: |
          wget https://github.com/mikefarah/yq/releases/download/v4.6.1/yq_linux_amd64.tar.gz -O - | tar xz && sudo mv yq_linux_amd64 /usr/bin/yq
          yq e '.Resources.ApplicationTaskDefinition.Properties.ContainerDefinitions[0].Image = "${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-central-1.amazonaws.com/prev-application:${{ github.sha }}"' -i deployments/stack.yml

      - name: Deploy AWS Stack
        id: backend-stack
        uses: aws-actions/aws-cloudformation-github-deploy@v1
        with:
          name: ${{ steps.extract_branch.outputs.branch }}-Application
          template: deployments/stack.yml
          no-fail-on-empty-changeset: "1"
          capabilities: CAPABILITY_NAMED_IAM,CAPABILITY_IAM
          parameter-overrides: "Namespace=${{ steps.extract_branch.outputs.branch }},ClusterArn=${{ secrets.CLUSTER_ARN }},AccountId=${{ secrets.AWS_ACCOUNT_ID }}"

      - name: Add PR Comment
        uses: mshick/add-pr-comment@v1
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          repo-token-user-login: 'github-actions[bot]'
          message: |
            I created a new preview environment at ${{ steps.backend-stack.outputs.DeploymentUrl }}

Preview Environments entfernen

Nun können wir Preview Environments erstellen, jedoch sollen die Resourcen auch wieder freigegeben werden, sobald ein Pull Request gemergt oder abgelehnt wurde. Dafür benötigen wir einen weiteren GitHub Workflow, der ausgelößt wird, wenn Pull Requests von Feature Branches geschlossen werden.

name: Decomission Preview Environment

on:
  pull_request:
    types:
      - closed

Leider lassen sich Pull Request Closed Events nur mit dem Namen des Zielbranches filtern. Deswegen wird eine zusätzliche Bedingung für den Job benötigt, die nur zutrifft, wenn ein Branch mit feat/ beginnt. In dem Fall wird der Name des geschlossenen Branches extrahiert und der zugehörige AWS Cloud Formation Stack gelöscht.

jobs:
  cleanup:
    name: Delete Stack
    runs-on: ubuntu-20.04

    if: ${{ startsWith(github.event.pull_request.head.ref, 'feat/') }}

    steps:
      - name: Checkout
        uses: actions/checkout@v1

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Extract branch name
        uses: frabert/replace-string-action@v1.2
        id: extract_branch
        with:
          pattern: feat/(.*)
          string: ${{ github.event.pull_request.head.ref }}
          replace-with: $1

      - name: Delete Stack
        run: |
          aws cloudformation delete-stack --stack-name ${{ steps.extract_branch.outputs.replaced }}-Application
          aws cloudformation wait stack-delete-complete --stack-name ${{ steps.extract_branch.outputs.replaced }}-Application

Schritt 4: Integrationstests gegen die Preview Environments ausführen

Mit den letzten Schritten haben wir bereits automatisch bereitgestellte Preview Environments implementiert und für viele Anwendungszwecke ist das bereits ausreichend. In diesem Artikel möchten wir allerdings als letzten Schritt auch Integrationstests durchführen.

Dafür implementieren wir einen einfachen Integrationstest mit Python.

import requests
import sys

EXPECTED_RESPONSE = "Hello World!\n"


def check_response(url):
    response = requests.get(url)

    return response.status_code == 200 and response.text == EXPECTED_RESPONSE


if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("Required parameter url missing.")
        sys.exit(1)

    if not check_response(sys.argv[1]):
        print("Checks failed.")
        sys.exit(1)
    else:
        print("Checks passed.")

Nun müssen wir den Test noch automatisch nach dem Bereitstellen der Anwendung ausführen. Dafür muss unser GitHub Workflow erweitert werden. In dem anschließenden Job wird das Python Skript ausgeführt. Falls die Tests fehlschlagen, beendet das Skript mit dem Exit Code 1, so dass der GitHub Workflow dann fehlschlägt.

jobs:
  deploy: # ...
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-20.04
    needs:
      - deploy

    steps:
      - name: Checkout
        uses: actions/checkout@v2.3.4

      - name: Verify Integration Test Results
        run: |
          sleep 60 # wait for deployment to finish
          pip install -r tests/integration/requirements.txt
          python tests/integration/integration-tests.py ${{ needs.deploy.outputs.DeploymentUrl }}

Zusammenfassung

In diesem Artikel haben wir mit GitHub Actions und AWS Cloud Formation automatische Preview Envrionments für Feature Branches implementiert. Sobald die Infrastruktur deployed ist, werden wir per Kommentar an jedem Pull Request für Feature branches informiert, mit welcher URL wir unsere Anwendung erreichen können.

Automatischer Kommentar an Pull Request

Zusätzlich verwenden wir unser Preview Environment um in der bereitgestellten Infrastruktur, um automatisch Integrationstests auszuführen.

Integrationstests werden automatisch nach Deployment ausgeführt

Für jeden neuen Commit auf dem Branch wird die Umgebung neu bereitgestellt, und sobald der Pull Request geschlossen wird, wird die Umgebung auch wieder abgerissen. Damit können wir automatisiert prüfen, ob neue Features oder geänderte Infrastruktur unsere Anwendung brechen, und haben zusätzlich noch die Möglichkeit, neue Features in einem produktionsähnlichen Umfeld zu zeigen.

Zurück