
Preview Environments: Hands-on mit AWS CloudFormation
Von Florian Beetz am 18.07.2021
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.
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.
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.
Zusätzlich verwenden wir unser Preview Environment um in der bereitgestellten Infrastruktur, um automatisch Integrationstests auszuführen.
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.