Bei Kinsta haben wir Projekte aller Größenordnungen für Anwendungs-Hosting, Datenbank-Hosting und Managed WordPress Hosting.

Mit den Cloud-Hosting-Lösungen von Kinsta kannst du Anwendungen in einer Reihe von Sprachen und Frameworks wie NodeJS, PHP, Ruby, Go, Scala und Python bereitstellen. Mit einem Dockerfile kannst du jede Anwendung bereitstellen. Du kannst dein Git-Repository (gehostet auf GitHub, GitLab oder Bitbucket) verbinden, um deinen Code direkt auf Kinsta bereitzustellen.

Du kannst MariaDB-, Redis-, MySQL- und PostgreSQL-Datenbanken out-of-the-box hosten. So hast du Zeit, dich auf die Entwicklung deiner Anwendungen zu konzentrieren, anstatt dich mit Hosting-Konfigurationen herumzuschlagen.

Und wenn du dich für unser Managed WordPress Hosting entscheidest, profitierst du von der Leistung der Google Cloud C2-Maschinen in ihrem Premium-Tier-Netzwerk und der in Cloudflare integrierten Sicherheit, die deine WordPress-Websites zu den schnellsten und sichersten auf dem Markt macht.

Überwindung der Herausforderung, Cloud-native Anwendungen in einem verteilten Team zu entwickeln

Eine der größten Herausforderungen bei der Entwicklung und Wartung von Cloud-nativen Anwendungen auf Unternehmensebene ist eine konsistente Erfahrung während des gesamten Entwicklungslebenszyklus. Dies ist noch schwieriger für Unternehmen mit verteilten Teams, die auf verschiedenen Plattformen, mit unterschiedlichen Setups und asynchroner Kommunikation arbeiten. Wir müssen eine konsistente, zuverlässige und skalierbare Lösung anbieten, die für alle funktioniert:

  • Entwickler/innen und Qualitätssicherungs-Teams, unabhängig von ihren Betriebssystemen, ein einfaches und minimales Setup für die Entwicklung und das Testen von Funktionen schaffen.
  • DevOps-, SysOps- und Infra-Teams, um Staging- und Produktionsumgebungen zu konfigurieren und zu pflegen.

Bei Kinsta verlassen wir uns stark auf Docker, um bei jedem Schritt, von der Entwicklung bis zur Produktion, eine einheitliche Erfahrung zu gewährleisten. In diesem Beitrag führen wir dich durch:

  • Wie wir Docker Desktop nutzen, um die Produktivität der Entwickler zu steigern.
  • Wie wir Docker-Images erstellen und sie über CI-Pipelines mit CircleCI und GitHub Actions in die Google Container Registry pushen.
  • Wie wir CD-Pipelines nutzen, um inkrementelle Änderungen mit Docker-Images, Google Kubernetes Engine und Cloud Deploy in die Produktion zu bringen.
  • Wie das QA-Team vorgefertigte Docker-Images nahtlos in verschiedenen Umgebungen einsetzt.

Die Verwendung von Docker Desktop zur Verbesserung der Entwicklererfahrung

Um eine Anwendung lokal auszuführen, müssen Entwickler/innen die Umgebung sorgfältig vorbereiten, alle Abhängigkeiten installieren, Server und Dienste einrichten und sicherstellen, dass sie richtig konfiguriert sind. Wenn du mehrere Anwendungen betreibst, kann das mühsam sein, vor allem wenn es sich um komplexe Projekte mit vielen Abhängigkeiten handelt. Wenn du zu dieser Variable noch mehrere Mitwirkende mit verschiedenen Betriebssystemen hinzufügst, ist das Chaos vorprogrammiert. Um das zu verhindern, verwenden wir Docker.

Mit Docker kannst du die Umgebungskonfigurationen deklarieren, die Abhängigkeiten installieren und Images bauen, bei denen alles da ist, wo es sein soll. Jeder, überall, mit jedem Betriebssystem kann die gleichen Images verwenden und genau die gleiche Erfahrung machen wie alle anderen.

Deklariere deine Konfiguration mit Docker Compose

Um loszulegen, erstellst du eine Docker Compose-Datei, docker-compose.yml. Das ist eine deklarative Konfigurationsdatei im YAML-Format, die Docker mitteilt, wie der gewünschte Zustand deiner Anwendung aussieht. Docker nutzt diese Informationen, um die Umgebung für deine Anwendung einzurichten.

Docker Compose-Dateien sind sehr nützlich, wenn du mehr als einen Container laufen hast und es Abhängigkeiten zwischen den Containern gibt.

So erstellst du deine docker-compose.yml Datei:

  1. Beginne mit der Auswahl einer image als Basis für unsere Anwendung. Suche im Docker Hub nach einem Docker-Image, das bereits die Abhängigkeiten deiner Anwendung enthält. Achte darauf, dass du einen bestimmten Image-Tag verwendest, um Fehler zu vermeiden. Die Verwendung des Tags latest kann zu unvorhergesehenen Fehlern in deiner Anwendung führen. Du kannst mehrere Basis-Images für verschiedene Abhängigkeiten verwenden. Zum Beispiel eines für PostgreSQL und eines für Redis.
  2. Verwende volumes um Daten auf deinem Host zu persistieren, wenn du sie brauchst. Das Persistieren von Daten auf dem Host-Rechner hilft dir, Datenverluste zu vermeiden, wenn Docker-Container gelöscht werden oder du sie neu erstellen musst.
  3. Verwende networks um dein Setup zu isolieren, um Netzwerkkonflikte mit dem Host und anderen Containern zu vermeiden. Außerdem hilft es deinen Containern, einander leicht zu finden und miteinander zu kommunizieren.

Wenn du alles zusammen nimmst, sieht docker-compose.yml wie folgt aus:

version: '3.8'services:
  db:
    image: postgres:14.7-alpine3.17
    hostname: mk_db
    restart: on-failure
    ports:
      - ${DB_PORT:-5432}:5432
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: ${DB_USER:-user}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
      POSTGRES_DB: ${DB_NAME:-main}
    networks:
      - mk_network
  redis:
    image: redis:6.2.11-alpine3.17
    hostname: mk_redis
    restart: on-failure
    ports:
      - ${REDIS_PORT:-6379}:6379
    networks:
      - mk_network
      
volumes:
  db_data:

networks:
  mk_network:
    name: mk_network

Containerisierung der Anwendung

Erstelle ein Docker-Image für deine Anwendung

Zuerst müssen wir mit Dockerfile ein Docker-Image erstellen und dieses dann von docker-compose.yml aus aufrufen.

So erstellst du deine Dockerfile Datei:

  1. Beginne damit, ein Image als Basis auszuwählen. Nimm das kleinste Basis-Image, das für deine Anwendung geeignet ist. Alpine-Images sind in der Regel sehr minimalistisch und haben fast keine zusätzlichen Pakete installiert. Du kannst mit einem Alpine-Image beginnen und darauf aufbauen:
    FROM node:18.15.0-alpine3.17
    
  2. Manchmal musst du eine bestimmte CPU-Architektur verwenden, um Konflikte zu vermeiden. Nimm zum Beispiel an, dass du einen arm64-based Prozessor verwendest, aber ein amd64 Image erstellen musst. Du kannst das tun, indem du -- platform in Dockerfile angibst:
    FROM --platform=amd64 node:18.15.0-alpine3.17
    
  3. Definiere das Anwendungsverzeichnis, installiere die Abhängigkeiten und kopiere die Ausgabe in dein Stammverzeichnis:
    WORKDIR /opt/app 
    COPY package.json yarn.lock ./ 
    RUN yarn install 
    COPY . .
  4. Rufe die Dockerfile von docker-compose.yml aus auf:
    services:
      ...redis
      ...db
      
      app:
        build:
          context: .
          dockerfile: Dockerfile
        platforms:
          - "linux/amd64"
        command: yarn dev
        restart: on-failure
        ports:
          - ${PORT:-4000}:${PORT:-4000}
        networks:
          - mk_network
        depends_on:
          - redis
          - db
  5. Implementiere einen automatischen Reload, damit du, wenn du etwas im Quellcode änderst, deine Änderungen sofort in der Vorschau sehen kannst, ohne die Anwendung manuell neu erstellen zu müssen. Erstelle dazu zuerst das Image und führe es dann in einem separaten Dienst aus:
    services:
      ... redis
      ... db
      
      build-docker:
        image: myapp
        build:
          context: .
          dockerfile: Dockerfile
      app:
        image: myapp
        platforms:
          - "linux/amd64"
        command: yarn dev
        restart: on-failure
        ports:
          - ${PORT:-4000}:${PORT:-4000}
        volumes:
          - .:/opt/app
          - node_modules:/opt/app/node_modules
        networks:
          - mk_network
        depends_on:
          - redis
          - db
          - build-docker
          
    volumes:
      node_modules:

Profi-Tipp: Beachte, dass node_modules auch explizit gemountet wird, um plattformspezifische Probleme mit Paketen zu vermeiden. Das bedeutet, dass der Docker-Container statt der node_modules auf dem Host seine eigene verwendet, diese aber auf dem Host in einem separaten Volume abbildet.

Inkrementelle Erstellung der Produktions-Images mit Continuous Integration

Die meisten unserer Anwendungen und Dienste nutzen CI/CD für die Bereitstellung. Docker spielt bei diesem Prozess eine wichtige Rolle. Jede Änderung im Hauptzweig löst sofort eine Build-Pipeline aus, entweder über GitHub Actions oder CircleCI. Der allgemeine Arbeitsablauf ist sehr einfach: Er installiert die Abhängigkeiten, führt die Tests durch, erstellt das Docker-Image und stellt es in der Google Container Registry (oder Artifact Registry) bereit. Der Teil, den wir in diesem Artikel besprechen, ist der Build-Schritt.

Erstellen der Docker-Images

Wir verwenden aus Sicherheits- und Leistungsgründen mehrstufige Builds.

Stufe 1: Builder

In dieser Phase kopieren wir die gesamte Codebasis mit allen Quellen und Konfigurationen, installieren alle Abhängigkeiten, einschließlich der Dev-Abhängigkeiten, und bauen die App. Es wird ein Ordner dist/ erstellt und die gebaute Version des Codes dorthin kopiert. Dieses Abbild ist jedoch viel zu groß und enthält zu viele Fußabdrücke, um für die Produktion verwendet zu werden. Da wir private NPM-Registries nutzen, verwenden wir in dieser Phase auch unsere private NPM_TOKEN. Wir wollen also auf keinen Fall, dass diese Phase für die Außenwelt sichtbar ist. Das Einzige, was wir in dieser Phase brauchen, ist der Ordner dist/.

Stufe 2: Produktion

Die meisten Leute verwenden diese Stufe für die Runtime, da sie sehr nah an dem ist, was wir für den Betrieb der Anwendung brauchen. Allerdings müssen wir noch die Abhängigkeiten für die Produktion installieren, was bedeutet, dass wir Fußspuren hinterlassen und den NPM_TOKEN benötigen. Diese Stufe ist also noch nicht bereit, um veröffentlicht zu werden. Achte auch auf yarn cache clean in Zeile 19. Dieser winzige Befehl reduziert unsere Bildgröße um bis zu 60%.

Stufe 3: Laufzeit

Die letzte Phase soll so schlank wie möglich sein und möglichst wenig Speicherplatz beanspruchen. Also kopieren wir einfach die fertige Anwendung aus der Produktion und machen weiter. Wir lassen den ganzen Kram mit Garn und NPM_TOKEN hinter uns und führen nur noch die Anwendung aus.

Dies ist die endgültige Dockerfile.production:

# Stage 1: build the source code 
FROM node:18.15.0-alpine3.17 as builder 
WORKDIR /opt/app 
COPY package.json yarn.lock ./ 
RUN yarn install 
COPY . . 
RUN yarn build 

# Stage 2: copy the built version and build the production dependencies FROM node:18.15.0-alpine3.17 as production 
WORKDIR /opt/app 
COPY package.json yarn.lock ./ 
RUN yarn install --production && yarn cache clean 
COPY --from=builder /opt/app/dist/ ./dist/ 

# Stage 3: copy the production ready app to runtime 
FROM node:18.15.0-alpine3.17 as runtime 
WORKDIR /opt/app 
COPY --from=production /opt/app/ . 
CMD ["yarn", "start"]

Beachte, dass wir in allen Phasen zuerst die Dateien package.json und yarn.lock kopieren, die Abhängigkeiten installieren und dann den Rest der Codebasis kopieren. Der Grund dafür ist, dass Docker jeden Befehl als eine Schicht über der vorherigen baut. Und jeder Build kann die vorherigen Layer verwenden, wenn sie verfügbar sind, und nur die neuen Layer aus Leistungsgründen bauen.

Nehmen wir an, du hast etwas in src/services/service1.ts geändert, ohne die Pakete zu verändern. Das bedeutet, dass die ersten vier Schichten der Builder-Stufe unangetastet bleiben und wiederverwendet werden können. Das macht den Build-Prozess unglaublich schnell.

Die Anwendung über CircleCI Pipelines in die Google Container Registry pushen

Es gibt mehrere Möglichkeiten, ein Docker-Image in CircleCI-Pipelines zu erstellen. In unserem Fall haben wir uns für circleci/gcp-gcr orbs entschieden:

executors:
  docker-executor:
    docker:
      - image: cimg/base:2023.03
orbs:
  gcp-gcr: circleci/[email protected]
jobs:
  ...
  deploy:
    description: Build & push image to Google Artifact Registry
    executor: docker-executor
    steps:
      ...
      - gcp-gcr/build-image:
          image: my-app
          dockerfile: Dockerfile.production
          tag: ${CIRCLE_SHA1:0:7},latest
      - gcp-gcr/push-image:
          image: my-app
          tag: ${CIRCLE_SHA1:0:7},latest

Dank Docker ist nur eine minimale Konfiguration erforderlich, um unsere Anwendung zu erstellen und zu pushen.

Die Anwendung über GitHub-Aktionen in die Google Container Registry pushen

Als Alternative zu CircleCI können wir GitHub Actions verwenden, um die Anwendung kontinuierlich zu verteilen. Wir richten gcloud ein und bauen und pushen das Docker-Image auf gcr.io:

jobs:
  setup-build:
    name: Setup, Build
    runs-on: ubuntu-latest

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

    - name: Get Image Tag
      run: |
        echo "TAG=$(git rev-parse --short HEAD)" >> $GITHUB_ENV

    - uses: google-github-actions/setup-gcloud@master
      with:
        service_account_key: ${{ secrets.GCP_SA_KEY }}
        project_id: ${{ secrets.GCP_PROJECT_ID }}

    - run: |-
        gcloud --quiet auth configure-docker

    - name: Build
      run: |-
        docker build 
          --tag "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:$TAG" 
          --tag "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:latest" 
          .

    - name: Push
      run: |-
        docker push "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:$TAG"
        docker push "gcr.io/${{ secrets.GCP_PROJECT_ID }}/my-app:latest"

Bei jeder kleinen Änderung, die wir am Hauptzweig vornehmen, bauen wir ein neues Docker-Image und pushen es in die Registry.

Bereitstellung von Änderungen an die Google Kubernetes Engine mithilfe der Google Delivery Pipelines

Fertige Docker-Images für jede einzelne Änderung zu haben, macht es auch einfacher, sie in die Produktion zu überführen oder ein Rollback durchzuführen, falls etwas schief geht. Wir verwenden Google Kubernetes Engine für die Verwaltung und Bereitstellung unserer Anwendungen und nutzen Google Cloud Deploy und Delivery Pipelines für unseren kontinuierlichen Bereitstellungsprozess.

Wenn das Docker-Image nach jeder kleinen Änderung erstellt ist (mit der oben gezeigten CI-Pipeline), gehen wir einen Schritt weiter und stellen die Änderung mit gcloud auf unserem Dev-Cluster bereit. Werfen wir einen Blick auf diesen Schritt in der CircleCI-Pipeline:

- run:
    name: Create new release
    command: gcloud deploy releases create release-${CIRCLE_SHA1:0:7} --delivery-pipeline my-del-pipeline --region $REGION --annotations commitId=$CIRCLE_SHA1 --images my-app=gcr.io/${PROJECT_ID}/my-app:${CIRCLE_SHA1:0:7}

Dadurch wird ein Release-Prozess ausgelöst, um die Änderungen in unserem Dev-Kubernetes-Cluster auszurollen. Nachdem wir die Änderungen getestet und genehmigt haben, übertragen wir sie ins Staging und dann in die Produktion. Das alles ist möglich, weil wir für jede Änderung ein schlankes, isoliertes Docker-Image haben, das fast alles enthält, was es braucht. Wir müssen dem Deployment nur mitteilen, welches Tag es verwenden soll.

Wie das Qualitätssicherungs-Team von diesem Prozess profitiert

Das QA-Team benötigt meistens eine Vorproduktions-Cloud-Version der zu testenden Anwendungen. Manchmal muss es jedoch eine vorgefertigte Anwendung (mit allen Abhängigkeiten) lokal ausführen, um eine bestimmte Funktion zu testen. In diesen Fällen wollen oder müssen sie sich nicht die Mühe machen, das gesamte Projekt zu klonen, npm-Pakete zu installieren, die Anwendung zu bauen, mit Entwicklerfehlern zu kämpfen und den gesamten Entwicklungsprozess durchzugehen, um die Anwendung zum Laufen zu bringen. Jetzt, wo alles bereits als Docker-Image in der Google Container Registry verfügbar ist, brauchen sie nur noch einen Dienst in der Docker Compose-Datei:

services:
  ...redis
  ...db
  
  app:
    image: gcr.io/${PROJECT_ID}/my-app:latest
    restart: on-failure
    ports:
      - ${PORT:-4000}:${PORT:-4000}
    environment:
      - NODE_ENV=production
      - REDIS_URL=redis://redis:6379
      - DATABASE_URL=postgresql://${DB_USER:-user}:${DB_PASSWORD:-password}@db:5432/main
    networks:
      - mk_network
    depends_on:
      - redis
      - db

Mit diesem Dienst können sie die Anwendung auf ihren lokalen Rechnern mit Docker-Containern zum Laufen bringen, indem sie ihn ausführen:

docker compose up

Dies ist ein großer Schritt zur Vereinfachung der Testprozesse. Selbst wenn die QA beschließt, ein bestimmtes Tag der Anwendung zu testen, kann sie das Image-Tag in Zeile 6 einfach ändern und den Docker Compose-Befehl erneut ausführen. Selbst wenn sie beschließen, verschiedene Versionen der Anwendung gleichzeitig zu vergleichen, können sie das mit ein paar Änderungen leicht erreichen. Der größte Vorteil ist, dass unser QA-Team nicht mit den Herausforderungen der Entwickler konfrontiert wird.

Vorteile der Verwendung von Docker

  • Fast kein Fußabdruck für Abhängigkeiten: Wenn du dich jemals entscheidest, die Version von Redis oder Postgres zu aktualisieren, kannst du einfach eine Zeile ändern und die Anwendung neu starten. Du musst nichts an deinem System ändern. Und wenn du zwei Anwendungen hast, die beide Redis benötigen (vielleicht sogar in unterschiedlichen Versionen), kannst du beide in ihrer eigenen isolierten Umgebung laufen lassen, ohne dass es zu Konflikten kommt.
  • Mehrere Instanzen der Anwendung: Es gibt viele Fälle, in denen wir die gleiche Anwendung mit einem anderen Befehl ausführen müssen. Zum Beispiel, um die DB zu initialisieren, Tests durchzuführen, DB-Änderungen zu beobachten oder Nachrichten abzuhören. In jedem dieser Fälle fügen wir einfach einen weiteren Dienst mit einem anderen Befehl in die Docker-Compose-Datei ein, da wir das fertige Image bereits haben – fertig.
  • Leichtere Testumgebung: In den meisten Fällen musst du nur die Anwendung ausführen. Du brauchst den Code, die Pakete oder lokale Datenbankverbindungen nicht. Du willst nur sicherstellen, dass die Anwendung richtig funktioniert oder brauchst eine laufende Instanz als Backend-Service, während du an deinem eigenen Projekt arbeitest. Das kann auch der Fall sein für QA, Pull Request Reviewer oder sogar UX-Leute, die sicherstellen wollen, dass ihr Design richtig umgesetzt wurde. Unser Docker-Setup macht es für sie alle sehr einfach, die Dinge in Gang zu bringen, ohne dass sie sich mit zu vielen technischen Fragen beschäftigen müssen.

Dieser Artikel wurde ursprünglich auf Docker veröffentlicht.

Amin Choroomi

Softwareentwickler bei Kinsta. Er hat eine Leidenschaft für Docker und Kubernetes und ist auf Anwendungsentwicklung und DevOps-Praktiken spezialisiert.