In Kinsta abbiamo progetti di tutte le dimensioni per l’Hosting di Applicazioni, l’Hosting di Database e l’Hosting WordPress Gestito.

Con le soluzioni di cloud hosting di Kinsta, si possono distribuire applicazioni in diversi linguaggi e framework, come NodeJS, PHP, Ruby, Go, Scala e Python. Con un file Docker, potrete distribuire qualsiasi applicazione. Potete collegare il vostro repository Git (ospitato su GitHub, GitLab o Bitbucket) per distribuire il codice direttamente su Kinsta.

Potete ospitare i database MariaDB, Redis, MySQL e PostgreSQL in modo immediato, risparmiando tempo per concentrarvi sullo sviluppo delle vostre applicazioni piuttosto che sulle configurazioni di hosting.

Inoltre, se scegliete il nostro Hosting WordPress Gestito, potrete usufruire della potenza delle macchine di Google Cloud C2 sulla loro rete di livello Premium e della sicurezza integrata in Cloudflare, rendendo i vostri siti web WordPress i più veloci e sicuri del mercato.

Superare la sfida dello sviluppo di applicazioni cloud-native in un team distribuito

Una delle sfide più grandi dello sviluppo e della manutenzione di applicazioni cloud-native a livello aziendale è quella di avere un’esperienza coerente durante l’intero ciclo di vita dello sviluppo. Questo è ancora più difficile per le aziende remote con team distribuiti che lavorano su piattaforme diverse, con configurazioni diverse e comunicazioni asincrone. Dobbiamo fornire una soluzione coerente, affidabile e scalabile che funzioni per:

  • Sviluppatori e team di controllo qualità, indipendentemente dai loro sistemi operativi, per creare una configurazione semplice e minima per lo sviluppo e il test delle funzionalità.
  • I team DevOps, SysOps e Infra, per configurare e mantenere gli ambienti di staging e di produzione.

In Kinsta, ci affidiamo molto a Docker per ottenere un’esperienza coerente in ogni fase, dallo sviluppo alla produzione. In questo post vedremo:

  • Come sfruttare Docker Desktop per aumentare la produttività del team di sviluppo.
  • Come costruiamo le immagini Docker e le inviamo a Google Container Registry tramite pipeline CI con CircleCI e GitHub Actions.
  • Come usiamo le pipeline CD per promuovere modifiche incrementali alla produzione utilizzando immagini Docker, Google Kubernetes Engine e Cloud Deploy.
  • Come il team QA usa senza problemi le immagini Docker precostituite in diversi ambienti.

Usare Docker Desktop per migliorare l’esperienza di sviluppo

L’esecuzione di un’applicazione in locale richiede che gli sviluppatori preparino meticolosamente l’ambiente, installino tutte le dipendenze, impostino i server e i servizi e si assicurino che siano configurati correttamente. Quando si eseguono più applicazioni, tutto questo può essere complicato, soprattutto quando si tratta di progetti complessi con molteplici dipendenze. Quando a questa variabile si aggiungono più collaboratori con più sistemi operativi, si scatena il caos. Per evitarlo, usiamo Docker.

Con Docker si possono dichiarare le configurazioni dell’ambiente, installare le dipendenze e creare immagini con ogni cosa al suo posto. Chiunque, ovunque, con qualsiasi sistema operativo, può usare le stesse immagini e avere la stessa esperienza di tutti gli altri.

Dichiarare la configurazione con Docker Compose

Per iniziare, creiamo un file Docker Compose, docker-compose.yml. Si tratta di un file di configurazione dichiarativo scritto in formato YAML che indica a Docker lo stato desiderato dell’applicazione. Docker usa queste informazioni per configurare l’ambiente per l’applicazione.

I file Docker Compose sono molto utili quando si ha più di un container in esecuzione e ci sono dipendenze tra i container.

Per creare il file docker-compose.yml:

  1. Iniziamo scegliendo un file image come base per la nostra applicazione. Cerchiamo su Docker Hub e cerchiamo di trovare un’immagine Docker che contenga già le dipendenze dell’applicazione. Assicuriamoci di usare un tag immagine specifico per evitare errori. L’utilizzo del tag latest può causare errori imprevisti nell’applicazione. Possiamo usare più immagini di base per più dipendenze. Per esempio, una per PostgreSQL e una per Redis.
  2. Usiamo volumes per persistere i dati sull’host, se necessario. La persistenza dei dati sul computer host aiuta a non perdere i dati se i container docker vengono cancellati o se è necessario ricrearli.
  3. Usiamo networks per isolare la configurazione ed evitare conflitti di rete con l’host e gli altri container. Inoltre, aiutiamo i nostri container a trovarsi e a comunicare facilmente tra loro.

Riunendo il tutto, abbiamo un docker-compose.yml che si presenta in questo modo:

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

Containerizzare l’applicazione

Creare un’immagine Docker per l’applicazione

Per prima cosa, dobbiamo creare un’immagine Docker utilizzando Dockerfile e poi richiamarla da docker-compose.yml.

Per creare il Dockerfile:

  1. Iniziamo scegliendo un’immagine di base. Usiamo l’immagine di base più piccola possibile per l’applicazione. Di solito, le immagini Alpine sono molto minimali e non hanno quasi nessun pacchetto extra installato. Possiamo iniziare con un’immagine Alpine e costruirci sopra:
    FROM node:18.15.0-alpine3.17
    
  2. A volte è necessario usare un’architettura CPU specifica per evitare conflitti. Per esempio, supponiamo di usare un processore arm64-based ma di dover creare un’immagine amd64. Possiamo farlo specificando -- platform in Dockerfile:
    FROM --platform=amd64 node:18.15.0-alpine3.17
    
  3. Definiamo la directory dell’applicazione, installiamo le dipendenze e copiamo il risultato nella directory principale:
    WORKDIR /opt/app
    COPY package.json yarn.lock ./
    RUN yarn install
    COPY . .
  4. Chiamiamo il sito Dockerfile da 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. Implementiamo il caricamento automatico in modo che quando viene modificato qualcosa nel codice sorgente, si possa vedere immediatamente l’anteprima delle modifiche senza dover ricostruire manualmente l’applicazione. Per farlo, costruiamo prima l’immagine e poi eseguiamola in un servizio separato:
    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:

Suggerimento: node_modules viene montato esplicitamente per evitare problemi specifici della piattaforma con i pacchetti. Ciò significa che invece di usare node_modules sull’host, il contenitore docker usa il proprio ma lo mappa sull’host in un volume separato.

Costruire in modo incrementale le immagini di produzione con l’integrazione continua

La maggior parte delle applicazioni e dei servizi usa la CI/CD per la distribuzione. Docker svolge un ruolo importante in questo processo. Ogni modifica apportata al branch principale attiva immediatamente una pipeline di build attraverso GitHub Actions o CircleCI. Il flusso di lavoro generale è molto semplice: installa le dipendenze, esegue i test, costruisce l’immagine docker e la invia al Google Container Registry (o Artifact Registry). La parte di cui parliamo in questo articolo è la fase di build.

Creazione delle immagini Docker

Per motivi di sicurezza e di prestazioni, usiamo build in più fasi.

Fase 1: Costruttore

In questa fase copiamo l’intero codice base con tutti i sorgenti e la configurazione, installiamo tutte le dipendenze, comprese quelle di dev, e costruiamo l’applicazione. Viene creata una cartella dist/ e in cui si copia la versione costruita del codice. Ma questa immagine è troppo grande, con un’enorme serie di impronte, per essere utilizzata in produzione. Inoltre, dato che ci serviamo di registri NPM privati, usiamo anche il nostro NPM_TOKEN privato in questa fase. Quindi, non vogliamo assolutamente che questo stadio sia esposto al mondo esterno. L’unica cosa di cui abbiamo bisogno in questa fase è la cartella dist/.

Fase 2: Produzione

La maggior parte delle persone usa questo stage per il runtime perché è molto vicino a ciò che serve per far funzionare l’applicazione. Tuttavia, dobbiamo ancora installare le dipendenze di produzione e questo significa che lasciamo delle impronte e abbiamo bisogno di NPM_TOKEN. Quindi questo stadio non è ancora pronto per essere esposto. Merita inoltre particolare attenzione yarn cache clean alla riga 19. Questo piccolo comando riduce le dimensioni della nostra immagine fino al 60%.

Fase 3: Runtime

L’ultimo stadio deve essere il più sottile possibile e con un’impronta minima. Per questo motivo, copiamo l’applicazione già pronta dalla produzione e andiamo avanti. Lasciamo da parte tutte le cose di yarn e NPM_TOKEN ed eseguiamo solo l’applicazione.

Questa è la versione finale di 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"]

Notate che, per tutte le fasi, iniziamo a copiare prima i file package.json e yarn.lock, installiamo le dipendenze e poi copiamo il resto del codice base. Il motivo è che Docker costruisce ogni comando come un livello superiore al precedente. Ogni build può usare i livelli precedenti, se disponibili, e costruire solo i nuovi livelli per motivi di prestazioni.

Supponiamo di aver cambiato qualcosa in src/services/service1.ts senza toccare i pacchetti. Ciò significa che i primi quattro livelli dello stage del builder non sono stati toccati e possono essere riutilizzati. Questo rende il processo di build incredibilmente più veloce.

Inviare l’app al registro dei container di Google attraverso le pipeline di CircleCI

Esistono diversi modi per creare un’immagine Docker nelle pipeline di CircleCI. Nel nostro caso, abbiamo scelto di usare circleci/gcp-gcr orbs:

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

Grazie a Docker, la configurazione necessaria per creare e inviare la nostra applicazione è minima.

Eseguire il push dell’app nel registro di Google Container attraverso le azioni di GitHub

In alternativa a CircleCI, possiamo usare GitHub Actions per distribuire l’applicazione in modo continuo. Configuriamo gcloud, eseguiamo la build e poi il push dell’immagine Docker su 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"

A ogni piccola modifica apportata al branch principale, effettuiamo build e push di una nuova immagine Docker nel registro.

Distribuire le modifiche a Google Kubernetes Engine utilizzando le Delivery Pipeline di Google

Avere immagini Docker pronte all’uso per ogni singola modifica rende più facile il deploy in produzione o il rollback nel caso in cui qualcosa vada storto. Usiamo Google Kubernetes Engine per gestire e servire le nostre applicazioni e usiamo Google Cloud Deploy e Delivery Pipelines per il nostro processo di distribuzione continua.

Quando l’immagine Docker viene costruita dopo ogni piccola modifica (con la pipeline CI mostrata sopra) facciamo un ulteriore passo avanti e distribuiamo la modifica al nostro cluster di sviluppo utilizzando gcloud. Diamo un’occhiata a questa fase della pipeline CircleCI:

- 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}

In questo modo si attiva un processo di rilascio per distribuire le modifiche nel nostro cluster Kubernetes di sviluppo. Dopo aver testato e ottenuto le approvazioni, promuoviamo la modifica a staging e poi a produzione. Tutto questo è possibile perché per ogni modifica abbiamo un’immagine Docker isolata e sottile che contiene quasi tutto ciò di cui ha bisogno. Dobbiamo solo indicare al deployment quale tag usare.

Come il team di Quality Assurance (QA) trae vantaggio da questo processo

Il team QA ha bisogno soprattutto di una versione cloud di pre-produzione delle applicazioni da testare. Tuttavia, a volte ha bisogno di eseguire un’app precostruita in locale (con tutte le dipendenze) per testare una determinata funzionalità. In questi casi, non vogliono o non hanno bisogno di affrontare la clonazione dell’intero progetto, l’installazione dei pacchetti npm, la creazione dell’app, gli errori dello sviluppatore e l’intero processo di sviluppo per rendere l’app operativa. Ora che tutto è già disponibile come immagine Docker su Google Container Registry, tutto ciò che serve è un servizio nel file Docker compose:

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

Con questo servizio, possono avviare l’applicazione sulle loro macchine locali usando i container Docker:

docker compose up

Questo è un enorme passo avanti verso la semplificazione dei processi di testing. Anche se il QA decide di testare un tag specifico dell’applicazione, può facilmente cambiare il tag dell’immagine alla riga 6 ed eseguire nuovamente il comando Docker compose. Anche se decidono di confrontare simultaneamente diverse versioni dell’app, possono farlo facilmente con poche modifiche. Il vantaggio più grande è quello di tenere il nostro team di QA lontano dalle sfide degli sviluppatori.

Vantaggi dell’uso di Docker

  • Impronte quasi nulle per le dipendenze: se si decide di aggiornare la versione di Redis o Postgres, è possibile cambiare solo una riga e rieseguire l’applicazione. Non c’è bisogno di cambiare nulla nel sistema. Inoltre, se si hanno due applicazioni che necessitano entrambe di Redis (magari anche con versioni diverse), è possibile farle girare entrambe nel proprio ambiente isolato senza alcun conflitto tra loro.
  • Istanze multiple dell’applicazione: Ci sono molti casi in cui abbiamo bisogno di eseguire la stessa applicazione con un comando diverso. Per esempio, l’inizializzazione del DB, l’esecuzione di test, l’osservazione delle modifiche del DB o l’ascolto di messaggi. In ognuno di questi casi, dato che abbiamo già l’immagine costruita pronta, basta aggiungere un altro servizio al file Docker compose con un comando diverso e il gioco è fatto.
  • Ambiente di test più semplice: Il più delle volte, si ha solo bisogno di eseguire l’applicazione. Non servono codice, pacchetti o connessioni al database locale. Ci si vuole solo assicurare che l’applicazione funzioni correttamente o si ha bisogno di un’istanza funzionante come servizio di backend mentre si lavora a un progetto. Questo potrebbe essere anche il caso di QA, revisori di Pull Request o anche di persone UX che vogliono assicurarsi che il loro progetto sia stato implementato correttamente. La nostra configurazione docker rende molto semplice per tutti loro l’utilizzo senza dover affrontare troppi problemi tecnici.

Questo articolo è stato pubblicato originariamente su Docker.

Amin Choroomi

Software developer in Kinsta. Appassionato di Docker e Kubernetes, è specializzato nello sviluppo di applicazioni e nelle pratiche DevOps.