

Discover more from INUVEON
Automatisierte NextJS Builds/ Deployments (GitHub, AWS ECR, Fargate Service, ECS)- Teil 2/4
Wie im ersten Teil meines DevOps/ AWS Deployment Tutorials angekündigt, soll es diesmal um GitHub Workflows/Actions gehen.
In meinem letzten Beitrag sind wir in typische DevOps-Themen eingetaucht und haben begonnen, uns um das Deployment einer NextJS-Applikation auf AWS zu kümmern. An dieser Stelle möchte ich gleich nochmals darauf hinweisen, dass ich es für einen essenziellen Bestandteil moderner Softwareentwicklung halte, als Entwickler in der Lage zu sein, einen Service von der ersten Zeile Code bis hin zur Produktion zu begleiten.
Inhalte des Tutorials
Teil I - Docker Image/ ECR Terraform Deployment & Image pushen
Teil II - Volle GitHub Integration & TF Backend AWS/S3
Teil III - Fargate Terraform Deployment on AWS (Infrastructure as Code)
Teil IV - Optimierung des Dockerfiles für Produktion
Ein paar Gedanken…
Es ist wichtig, bei allen Entscheidungen über die Auswahl von Technologien und Frameworks auch an den Betrieb und natürlich Sicherheit und Stabilität zu denken. Die am schönsten programmierte Anwendung ist nutzlos, wenn sie nur auf der eigenen Maschine läuft. Aus Vorgehensmethoden vergangener Tage ist mir noch bekannt, dass häufig drauf los gecodet wurde, ohne das jemand daran dachte, dass irgendwann jemand die Anwendung betreiben musste. “Machen wir zum Schluss”, war dann die Aussage. Es handelte sich häufig um einen nachgelagerten Schritt, Pipelines für die Rollouts zu bauen. Aus meiner Erfahrung und meinen Erkenntnissen muss die Bereitstellung aber initialer Schritt bei Projektstart sein. Ich möchte nämlich genau wissen, wie und wo meine Anwendung laufen wird und die Ergebnisse und Zwischenstände kontinuierlich kontrollieren und präsentieren können.
Es spricht auch nichts dagegen, eine CI/CD Pipeline zu Beginn aufzusetzen. Wir verfügen über alle notwendigen Werkzeuge, um das zu tun. GitHub, Azure DevOps oder Gitlab machen es uns leicht, exakt das zu tun.
In diesem Tutorial werde ich GitHub nutzen, um den Web-App auf den AWS Elastic Container Services als Fargate Service an den Start zu bringen.
Open ID Connect
Ich habe hunderte CI/CD Pipelines gesehen, bei denen Credentials hinterlegt worden sind, um auf die Cloud-Ressourcen zugreifen zu können. Das ist selbstredend keine gute Idee. Auch wenn GitHub Secrets nach dem Speichern nicht mehr lesbar sind, müssten die Credentials zumindest eine längere Gültigkeit besitzen, wenn man sie nicht täglich austauschen will. Oder man erstellt dauerhaft gültige Credentials, was ich aber sicherheitstechnisch für bedenklich halte. Oft dafür eine Art “technischer” User eingeführt, der für die Autorisierung aus Pipelines heraus in Richtung Cloud-Provider genutzt wurde. Alles in allem keine guten Ansätze.
Seit einiger Zeit bietet GitHub allerdings OpenID Connect an, um sich gegen AWS zu authentifizieren. Die Einrichtung ist recht simple. Wir müssen zunächst einige Dinge in der AWS Console einrichten.
Zuerst muss in der AWS Console unter “Identity and Access Management” (IAM) der Identity Provider erstellt werden, falls noch keiner für GitHub existieren sollte.
Die Provider URL ist https://token.actions.githubusercontent.com und die Audience sts.amazonaws.com. Es ist auch erforderlich, den Thumbprint mit dem “Get thumbprint” Button zu erhalten.
Ist der Provider einmal erstellt, können wir die notwendige IAM Role erstellen, die uns den Zugriff aus der GitHub Action auf AWS Ressourcen ermöglichen wird.
Der Trusted Entity Type ist Web Identity. Als Identity Provider muss token.actions.githubusercontent.com ausgewählt werden. Es ist der Provider, den wir soeben erstellt haben. In der Auswahlliste für Audience sollte nur der Eintrag sts.amazonaws.com zu finden sein, der auch ausgewählt werden muss.
Im nächsten Schritt müssen die Berechtigungen zugewiesen werden. Das kann sowohl sehr dezidiert sein oder eben Vollzugriff. Es ist natürlich möglich, eine exakt abgestimmte Policy zu erstellen. Für unseren Fall halten wir es etwas offener und geben der Rolle Vollzugriff.
Im letzten Schritt muss noch der Name der Rolle vergeben werden, z.B. github-role.
Nach dem Erstellen der Rolle müssen wir diese nochmals zum Bearbeiten öffnen, um die Trust Releationships anzupassen. Dort muss die Condition korrigert werden. Der Key token.actions.githubusercontent.com:sub muss als Value das gewünschte GitHub Repo erhalten, siehe folgendes Code-Beispiel.
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:<your-github-org>/<your-repo>:*",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
Danach ist die Rolle startklar und kann im GitHub Workflow verwendet werden.
GitHub Workflow
Um einen Workflow zu integrieren, müssen wir nun in unserem Projekt die folgenden neuen Ordner anlegen.
$ mkdir .github
$ cd .github && mkdir workflows
$ cd workflows
Im Folder .github/workflows benötigen wir dann eine Datei, die unsere Actions enthalten wird.
$ touch ci-cd-pipeline.yml
Danach sollten wir folgende Projektstruktur haben.
.
|-- .github
| `-- workflows
|-- pages
| `-- api
|-- public
|-- styles
`-- terraform
`-- registry
Im ersten Schritt füge ich der ci-cd-pipeline.yml den folgenden Code hinzu.
name: CI/CD Pipeline
on:
push:
branches: [ main ]
permissions:
id-token: write
contents: read
env:
AWS_REGION: eu-central-1
Der Name der Pipeline kann frei gewählt werden und ist optional. Es ist aber durchaus sinnvoll, einen Namen zu verwenden, um in den GitHub Actions zu sehen, um welche Pipeline es sich konkret handelt.
Wichtig ist die neue Section permission. Der Eintrag “id-token” write ermöglicht, dass der OIDC JWT ID-Token angefordert werden kann. Ohne diese Einstellung ist die Verwendung im vorherigen Kapitel beschriebenen Authentifizierungsmethode nicht möglich.
Die Permission “contents: read” ist wiederum notwendig, um die Action “checkout” zu nutzen.
Unter env habe ich eine AWS_REGION gesetzt, in der die Ressourcen erstellt werden sollen.
Wir erinnern uns an den ersten Teil des Tutorials, in welchem wir bereits einen Terraform Skript zur Erstellung einer Elastic Container Registry (ECR) erstellt hatten. Diese hatten wir lokal ausgeführt, um die Ressource auf AWS anzulegen. Dafür hatten wir die AWS Credentials in den Terminal eingefügt. Wie vorab schon beschreiben, wird der OIDC Provider die Verwendung von Credentials nun vermeiden. Wir haben Vertrauen zwischen unserem GitHub Repository und AWS hergestellt und möchten dies nun bei der Erstellung der ECR mithilfe eines Schrittes in unserem GitHub-Workflow nutzen.
Dafür verwenden wir die Action aws-actions/configure-aws-credentials@v1 wie folgt:
jobs:
registry:
runs-on: ubuntu-20.04
steps:
- name: Check Out
uses: actions/checkout@v2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::<your-account-id>:oidc-provider/token.actions.githubusercontent.com
aws-region: ${{ env.AWS_REGION }}
- name: Create Registry
id: create-registry
run: |
...
Für diese Action müssen der ARN der AWS Rolle with: role-to-assume sowie aws-region eingetragen werden.
Grundsätzlich könnten wir die Pipeline nun so benutzen, da wir für die Authentifizierung mittel OIDC Token gesorgt haben.
Aber Stop!
Wenn wir unsere Terraform-Skripte nicht mehr nur alleine lokal verwenden, müssen wir dafür sorgen, dass der Terraform State zentral verfügbar ist und bei Ausführung gesperrt wird.
Dafür benötigen wir eine sogenannte Backend-Config.
Terraform State
Um den Terraform State in einer Multi-User Umgebung persistent zu machen, müssen wir zunächst in unserer /terraform/registry/main.tf in der Ressource “Terraform” folgende Zeile ergänzen.
backend "s3" { /* See the backend config in config/backend-config.tf */ }
Wie schon zu erkennen ist, werden wir einen S3 Bucket zur Speicherung des Zustandes benötigen.
Danach erstellen wir im Ordner "/terraform/registry” eine neue Datei namens “backend-config.tf”. Diese wird alle notwendigen Settings erhalten, siehe folgendes Beispiel.
bucket = "terraform-states"
key = "ci-cd-example.tfstate"
region = "eu-central-1"
encrypt = true
dynamodb_table = "terraform-locks"
Wichtig! Das S3 Bucket und die DynamoDB müssen vor Ausführung existieren. Im Grunde können diese einmalig für mehrere Pipelines auf AWS erstellt werden. Entscheidend ist, dass der Key pro Pipeline eindeutig ist.
Bei der DynamoDB Tabelle wird eine Partition Key mit dem Namen “LockID” benötigt.
Bleibt nun noch den Job, um einen Step zur Ausführung der Terraform Commands zu ergänzen:
- name: Create Registry
id: create-registry
run: |
cd terraform/registry/
terraform init \
-backend-config=config/backend-config.tf \
-reconfigure \
-input=false
terraform apply \
-var-file=vars.tfvars \
-input=false \
-auto-approve
Damit sind wir nun grundsätzlich ready, die ECR mittels GitHub Pipeline auszurollen. Werfen wir nochmals einen Blick auf den bisher erstellten GitHub Workflow.
Mit dem Git Commit & Push des Repos wird die Pipeline automatisch ausgeführt und die Ressource auf AWS erstellt. Wie zu erkennen ist, habe ich die ARN der AWS GitHub OIDC Rolle in ein GitHub Secret ausgelagert und verweise im Workflow nur darauf. Dies erhöht natürlich die Sicherheit.
Ok! Ziel erreicht. Die ECR wird nun mittels Pipeline erstellt. Der Terraform State wird sicher zentral verwaltet und bei Ausführung gesperrt.
Docker Build & Push
Lasst uns nun noch den Docker Build in einem zweiten Workflow Job in die Pipeline bringen und damit automatisieren.
Die einzelnen notwendigen Schritte hatten wir bereits im ersten Teil des Tutorials intensiv betrachtet. Nun gilt es nur noch, den Code in einen Workflow Job zu übertragen. Zudem benötigen wir noch richtige Tags, die ich aus dem Git Commit Hash beziehen möchte. Dies ist später von Bedeutung, damit der Fargate Service/Task erkennt, dass eine neue Image Version zur Verfügung steht und der Container auch erneuert wird. Desweiteren möchte ich den Namen des Repositories ungern fixieren, sondern aus dem Terraform Output beziehen.
Den Terraform Output hatten wir bereits in der /terraform/registry/main.tf definiert.
output "repository_name" {
description = "The name of the repository."
value = aws_ecr_repository.repository.name
}
Wir müssen ihn nur noch in den nächsten Workflow Job übertragen. Dafür gibt GitHub die Möglichkeit Job-Outputs festzulegen. Der erste Schritt der ECR-Erstellung muss dafür etwas ergänzt werden:
jobs:
registry:
runs-on: ubuntu-20.04
outputs:
repository-name: ${{ steps.create-registry.outputs.repository-name }}
Im Step create-registry müssen am Ende die folgenden Zeilen hinzugefügt werden, um den Terraform Output auf den Step/Job Output zu mappen.
export REPOSITORY_NAME=$(terraform output --raw repository_name)
echo "::set-output name=repository-name::$REPOSITORY_NAME"
Damit kann der Job-Output im nächsten Job wiederverwendet werden.
Es ist an der Zeit, den nächsten Job im Workflow anzulegen. Dieser Job soll nur ausgeführt werden, wenn die Erstellung der ECR erfolgreich war. GitHub Actions kennen dafür das Attribut needs. Dieses zeigt an, dass für die Ausführung des Jobs die erfolgreiche Ausführung der jeweils angegeben erforderlich ist.
In unserem Fall ist der Job “registry” erforderlich.
docker-build:
runs-on: ubuntu-20.04
needs: [registry]
Ausserdem möchten wir den Output des Registry-Jobs nutzen, um so wenig wie möglich Variablen zu duplizieren. Die Umgebungsvariable REPOSITORY_NAME wird wie folgt aus dem Output des Registry-Jobs bezogen.
docker-build:
runs-on: ubuntu-20.04
needs: [registry]
env:
REPOSITORY_NAME: ${{ needs.registry.outputs.repository-name }}
ACCOUNT_ID: {{ secrets.ACCOUNT_ID }}
Der Run-Step im Docker-Build-Job sieht dann wie folgt aus.
- name: Image build and push
id: docker-build
run: |
export IMAGE_TAG=$(git rev-parse --short HEAD)
export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
export REPOSITORY_URL=${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${REPOSITORY_NAME}
docker build --platform linux/amd64 -t ${REPOSITORY_NAME}:${IMAGE_TAG} .
docker tag ${REPOSITORY_NAME}:${IMAGE_TAG} ${REPOSITORY_URL}:${IMAGE_TAG}
docker push ${REPOSITORY_URL}:${IMAGE_TAG}
Dieser besteht analog wie schon in der vorherigen Folge beschrieben aus dem Login an der ECR und Docker Commands build, tag und push.
Den Image-Tag erstellen wir nun dynamisch aus dem Git Commit Hash, um später die Containeraktualisierungen auf dem Fargate zu initiieren.
Mit dem aktuellen Setup sind wir nun bereit, den Workflow auszuführen, um die ECR und das Docker Image vollautomatisiert erstellen zu lassen. Diese geschieht automatisch beim Push des Main-Branches nach GitHub.
In der nächsten Folge schauen wir uns dann die Erstellung eines Fargate Service auf AWS mittels Terraform und GitHub Workflow.
Bis dahin: Happy Coding!
Den gesamten Code findest du wie immer auf GitHub.