Bij Kinsta hebben we projecten van alle groottes voor Applicatie Hosting, Database Hosting en Managed WordPress Hosting.

Met de cloudhostingoplossingen van Kinsta kun je applicaties deployen in een groot aantal talen en frameworks, zoals NodeJS, PHP, Ruby, Go, Scala en Python. Elke applicatie kun je deployen met een Dockerfile. Je kunt je Git repository (gehost op GitHub, GitLab of Bitbucket) koppelen om je code direct op Kinsta te implementeren.

Je kunt MariaDB, Redis, MySQL en PostgreSQL databases out-of-the-box hosten, waardoor je tijd overhoudt om je te richten op het ontwikkelen van je applicaties in plaats van je bezig te houden met hosting configuraties.

En als je kiest voor onze Managed WordPress Hosting, ervaar je de kracht van Google Cloud C2 machines op hun Premium Tier netwerk en Cloudflare geïntegreerde beveiliging, waardoor je WordPress websites de snelste en veiligste op de markt zijn.

De uitdaging van het ontwikkelen van cloud-native applicaties binnen een gedistribueerd team overwinnen

Een van de grootste uitdagingen bij het ontwikkelen en onderhouden van cloud-native applicaties op bedrijfsniveau is een consistente ervaring gedurende de hele levenscyclus van de ontwikkeling. Dit is nog moeilijker voor bedrijven die op afstand werken met remote teams die werken op verschillende platforms, met verschillende setups en asynchrone communicatie. We moeten dus een consistente, betrouwbare en schaalbare oplossing bieden die werkt voor:

  • Developers en kwaliteitsmonitoringsteams, ongeacht hun besturingssystemen, een eenvoudige en minimale setup creëren voor het ontwikkelen en testen van features.
  • DevOps-, SysOps- en Infra-teams, voor het configureren en onderhouden van test- en productieomgevingen.

Bij Kinsta vertrouwen we sterk op Docker voor deze consistente ervaring bij elke stap, van ontwikkeling tot productie. In dit bericht leiden we je door:

  • Hoe je Docker Desktop kunt gebruiken om de productiviteit van developers te verhogen.
  • Hoe we Docker images bouwen en naar Google Container Registry pushen via CI pipelines met CircleCI en GitHub Actions.
  • Hoe we CI pipelines gebruiken om incrementele wijzigingen naar productie te promoten met behulp van Docker images, Google Kubernetes Engine en Cloud Deploy.
  • Hoe het QA team naadloos gebruik maakt van voorgebouwde Docker images in verschillende omgevingen.

Docker Desktop gebruiken om de ervaring voor developers te verbeteren

Om een applicatie lokaal te runnen, moeten developers de omgeving minutieus voorbereiden, alle dependencies installeren, servers en services instellen en ervoor zorgen dat ze goed geconfigureerd zijn. Als je meerdere applicaties runt, kan dit lastig zijn, vooral als het gaat om complexe projecten met meerdere dependencies. Als je aan deze variabele ook nog eens meerdere medewerkers met meerdere besturingssystemen toevoegt, ontstaat er chaos. Om dit te voorkomen, gebruiken we Docker.

Met Docker kun je de omgevingsconfiguraties aangeven, de dependencies installeren en images bouwen met alles op de plek waar ze horen. Iedereen, waar dan ook, met welk OS dan ook kan dezelfde images gebruiken en precies dezelfde ervaring hebben als ieder ander.

Je configuratie declaren met Docker Compose

Om te beginnen maak je een Docker Compose bestand, docker-compose.yml. Het is een declaratief configuratiebestand geschreven in YAML format dat Docker vertelt wat de gewenste status van je applicatie is. Docker gebruikt deze informatie om de omgeving voor je applicatie in te stellen.

Docker Compose bestanden zijn erg handig als je meer dan één container hebt draaien en er dependencies zijn die je gebruikt voor meerdere containers.

Om je docker-compose.yml bestand te maken:

  1. Begin met het kiezen van een image als basis voor onze applicatie. Zoek op Docker Hub en probeer een Docker image te vinden die al de dependencies van je app bevat. Zorg ervoor dat je een specifieke image tag gebruikt om fouten te voorkomen. Het gebruik van de tag latest kan onvoorziene fouten in je applicatie veroorzaken. Je kunt meerdere base images gebruiken voor meerdere dependencies. Bijvoorbeeld één voor PostgreSQL en één voor Redis.
  2. Gebruik volumes om gegevens op je host te bewaren als dat nodig is. Door gegevens op de hostmachine te bewaren voorkom je dat je gegevens kwijtraakt als docker-containers worden verwijderd of als je ze opnieuw moet maken.
  3. Gebruik networks om je opstelling te isoleren om netwerkconflicten met de host en andere containers te voorkomen. Het helpt je containers ook om elkaar gemakkelijk te vinden en met elkaar te communiceren.

Als we alles samenbrengen, hebben we een docker-compose.yml die er als volgt uitziet:

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

De applicatie containeriseren

Een Docker image bouwen voor je applicatie

Eerst moeten we een Docker image bouwen met behulp van een Dockerfile, en die callen vanuit docker-compose.yml.

Om je Dockerfile bestand te maken:

  1. Begin met het kiezen van een image als basis. Gebruik het kleinste basisimage dat werkt voor de app. Meestal zijn alpine images zeer minimaal en zijn er bijna geen extra pakketten geïnstalleerd. Je kunt beginnen met een alpine image en daarop verder bouwen:
    FROM node:18.15.0-alpine3.17
    
  2. Soms moet je een specifieke CPU architectuur gebruiken om conflicten te voorkomen. Stel bijvoorbeeld dat je een arm64-based processor gebruikt, maar je moet een amd64 image bouwen. Je kunt dat doen door de -- platform op te geven in Dockerfile:
    FROM --platform=amd64 node:18.15.0-alpine3.17
    
  3. Definieer de applicatiemap en installeer de dependencies en kopieer de uitvoer naar je hoofdmap:
    WORKDIR /opt/app 
    COPY package.json yarn.lock ./ 
    RUN yarn install 
    COPY . .
  4. Call de Dockerfile vanaf docker-compose.yml:
    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. Implementeer auto-reload zodat wanneer je iets verandert in de sourcecode, je direct een voorbeeld van je wijzigingen kunt bekijken zonder dat je de applicatie handmatig opnieuw hoeft te bouwen. Om dat te doen, bouw je eerst de image en voer je die uit in een aparte service:
    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:

Pro Tip: Merk op dat node_modules ook expliciet wordt gemount om platform-specifieke problemen met pakketten te voorkomen. Dit betekent dat in plaats van de node_modules op de host te gebruiken, de docker container de zijne gebruikt, maar deze op de host in een apart volume mapt.

Incrementeel de productie-image bouwen met continue integratie

De meeste van onze apps en diensten gebruiken CI/CD voor de deployment. Docker speelt een belangrijke rol in het proces. Elke wijziging in de main branch activeert onmiddellijk een bouwpipeline via GitHub Actions of CircleCI. De algemene workflow is heel eenvoudig: het installeert de dependencies, voert de tests uit, bouwt het docker image en pusht het naar Google Container Registry (of Artifact Registry). Het deel dat we in dit artikel bespreken is de bouwstap.

De Docker images bouwen

We gebruiken builds in meerdere fasen om veiligheids- en prestatieredenen.

Fase 1: Builder

In dit stadium kopiëren we de hele codebase met alle broncode en configuratie, installeren we alle dependencies, inclusief dev dependencies, en bouwen we de app. Er wordt een map dist/ aangemaakt en de gebouwde versie van de code wordt daarheen gekopieerd. Maar dit image is veel te groot met een enorme set footprints om te gebruiken voor productie. Omdat we private NPM registries gebruiken, gebruiken we in deze fase ook onze private NPM_TOKEN. We willen dus absoluut niet dat deze stap wordt blootgesteld aan de buitenwereld. Het enige dat we nodig hebben van deze stap is de map dist/.

Stap 2: Productie

De meeste mensen gebruiken dit stadium voor runtime, omdat het heel dicht ligt bij wat we nodig hebben om de app te draaien. We moeten echter nog steeds productie-dependencies installeren en dat betekent dat we footprints achterlaten en de NPM_TOKEN nodig hebben. Dit stadium is dus nog niet klaar voor gebruik. Let ook op yarn cache clean op regel 19. Dat kleine commando vermindert de grootte van onze afbeelding met wel 60%.

Fase 3: Runtime

De laatste stap moet zo lean mogelijk zijn met minimale footprints. Dus we kopiëren simpelweg de app uit productie en gaan verder. We laten alle yarn en NPM_TOKEN dingen achter ons en draaien alleen de app.

Dit is de uiteindelijke 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"]

Let op dat we voor alle stappen eerst de bestanden package.json en yarn.lock kopiëren, de dependencies installeren en dan de rest van de codebasis kopiëren. De reden hiervoor is dat Docker elk commando bouwt als een laag bovenop de vorige. En elke build kan de vorige lagen gebruiken als die beschikbaar zijn en alleen de nieuwe lagen bouwen omwille van de prestaties.

Stel dat je iets hebt veranderd in src/services/service1.ts zonder de pakketten aan te raken. Dat betekent dat de eerste vier lagen van builder fase onaangeroerd zijn en hergebruikt kunnen worden. Dat maakt het bouwproces ongelooflijk veel sneller.

De app naar Google Container Registry pushen via CircleCI pipelines

Er zijn verschillende manieren om een Docker image te bouwen in CircleCI pipelines. In ons geval hebben we ervoor gekozen om circleci/gcp-gcr orbs te gebruiken:

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

Dankzij Docker is er een minimale configuratie nodig om onze app te bouwen en te pushen.

De app naar Google Container Registry pushen via GitHub acties

Als alternatief voor CircleCI kunnen we GitHub Actions gebruiken om de applicatie continu te deployen. We stellen gcloud in en bouwen en pushen de Docker image naar 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"

Bij elke kleine wijziging die naar de main branch wordt gepushed, bouwen en pushen we een nieuwe Docker image naar het register.

Wijzigingen deployen naar Google Kubernetes Engine met behulp van Google Delivery pipelines

Het hebben van kant-en-klare Docker images voor elke wijziging maakt het ook gemakkelijker om te deployen naar productie of terug te rollen als er iets mis gaat. We gebruiken Google Kubernetes Engine om onze apps te beheren en te serveren en gebruiken Google Cloud Deploy en Delivery Pipelines voor ons Continuous Deployment proces.

Wanneer de Docker image is gebouwd na elke kleine wijziging (met de CI pipeline hierboven getoond) gaan we nog een stap verder en deployen we de wijziging naar ons dev cluster met behulp van gcloud. Laten we eens kijken naar die stap in de 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}

Dit activeert een releaseproces om de wijzigingen uit te rollen in ons dev Kubernetes cluster. Na het testen en het verkrijgen van de goedkeuringen, promoten we de wijziging naar test en vervolgens productie. Dit is allemaal mogelijk omdat we voor elke wijziging een lean geïsoleerd Docker image hebben dat bijna alles heeft wat het nodig heeft. We hoeven de deployment alleen maar te vertellen welke tag hij moet gebruiken.

Hoe het Quality Assurance team van dit proces profiteert

Het QA team heeft meestal een pre-productie cloudversie van de te testen apps nodig. Soms moeten ze echter een vooraf gebouwde app lokaal draaien (met alle dependencies) om een bepaalde feature te testen. In deze gevallen willen of hoeven ze niet al die moeite te doen om het hele project te klonen, npm-pakketten te installeren, de app te bouwen, developersfouten tegen te komen en het hele ontwikkelproces te doorlopen om de app werkend te krijgen. Nu alles al beschikbaar is als Docker image op Google Container Registry, hebben ze alleen nog een service nodig in het Docker compose bestand:

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

Met deze service kunnen ze de applicatie op hun lokale machines opstarten met Docker containers door deze uit te voeren:

docker compose up

Dit is een enorme stap in het vereenvoudigen van testprocessen. Zelfs als QA besluit om een specifieke tag van de app te testen, kunnen ze eenvoudig de image tag op regel 6 wijzigen en het Docker compose commando opnieuw uitvoeren. Zelfs als ze besluiten om verschillende versies van de app tegelijkertijd te vergelijken, kunnen ze dat eenvoudig bereiken met een paar tweaks. Het grootste voordeel is dat ons QA team niet wordt geconfronteerd met de uitdagingen die developers op moeten lossen.

Voordelen van het gebruik van Docker

  • Bijna geen footprints voor dependencies: Als je ooit besluit om de versie van Redis of Postgres te upgraden, kun je simpelweg 1 regel veranderen en de app opnieuw uitvoeren. Je hoeft niets op je systeem te veranderen. Bovendien kun je, als je twee apps hebt die allebei Redis nodig hebben (misschien zelfs met verschillende versies), ze allebei in hun eigen geïsoleerde omgeving laten draaien zonder conflicten met elkaar.
  • Meerdere instances van de app: Er zijn veel gevallen waarin we dezelfde app met een ander commando moeten uitvoeren. Zoals het initialiseren van de DB, het uitvoeren van tests, het bekijken van DB veranderingen of het luisteren naar berichten. In elk van deze gevallen, omdat we de gebouwde image al klaar hebben, voegen we gewoon een andere service toe aan het Docker compose bestand met een ander commando, en we zijn klaar.
  • Eenvoudigere testomgeving: Vaker wel dan niet hoef je alleen maar de app te draaien. Je hebt de code, de pakketten of lokale databaseverbindingen niet nodig. Je wilt alleen zeker weten dat de app goed werkt of je hebt een draaiende instantie nodig als backend service terwijl je aan je eigen project werkt. Dat kan ook het geval zijn voor QA, Pull Request reviewers, of zelfs UX mensen die willen controleren of hun ontwerp goed is geïmplementeerd. Onze docker setup maakt het voor al deze mensen heel gemakkelijk om aan de slag te gaan zonder al te veel technische problemen.

Dit artikel is oorspronkelijk gepubliceerd op Docker.

Amin Choroomi

Software developer bij Kinsta. Hij heeft een passie voor Docker en Kubernetes en is gespecialiseerd in de ontwikkeling van applicaties en DevOps-praktijken.