Scrittura di un Dockerfile per Code Engine

Prima di compilare il codice in un'immagine contenitore, è necessario apprendere alcune nozioni di base su come funziona una compilazione Docker all'interno di IBM Cloud® Code Engine. Quindi, guarda alcune best practice per il tuo Dockerfile per raggiungere questi obiettivi. Questi esempi si concentrano sulla riduzione delle dimensioni dell'immagine, sul miglioramento dell'interoperabilità con le applicazioni Code Engine e sull'esecuzione dei container come utente non root.

Quando si crea la configurazione di build, si decide quale delle due strategie disponibili utilizzare.

  1. I pacchetti di build nativi cloud ispezionano il tuo codice sorgente e rilevano quale ambiente di runtime è basato sul tuo codice e come viene creata un'immagine del contenitore dalle tue origini. È supportato per diversi ambienti di programmazione. È possibile trovare l'elenco supportato in Scegliere una strategia di creazione.

  2. Una build Docker crea un contenitore in base a come lo descrivi in un Dockerfile. Il Dockerfile viene quindi sottoposto a commit insieme al tuo codice sorgente per creare il contenitore.

Mentre puoi utilizzare una delle due strategie per la tua build, puoi scegliere Dockerfile, se, ad esempio,

  • L'ambiente di programmazione non è supportato dai pacchetti di build.
  • La build del progetto deve installare ulteriori package nel contenitore.

Elementi di base Dockerfile

Un Dockerfile descrive come viene creato un contenitore. Nel tuo Dockerfile, scegli un'immagine di base che include gli strumenti necessari di cui hai bisogno durante la tua build e il tuo runtime. È possibile copiare i file da un contesto di build nell'immagine, eseguire i comandi, definire il comportamento di runtime come le variabili di ambiente, le porte esposte e impostare ENTRYPOINT. Il ENTRYPOINT viene impostato dal comando richiamato quando il contenitore viene avviato. Per ulteriori informazioni su come possono essere specificate le istruzioni Dockerfile, vedi Riferimento Dockerfile.

In una build Code Engine, definisci un'origine che punta a un repository Git. Il contesto disponibile per la build Docker è, per impostazione predefinita, la directory root del tuo repository Git. Ad esempio, se hai una directory denominata src nel tuo repository, puoi usare l'istruzione COPY nel Dockerfile per copiare questa directory nella tua immagine. Ad esempio,

COPY src /app/src

Per copiare l'intero repository Git, specificare la seguente istruzione COPY,

COPY . /app/src

Se si copia l'intero repository Git, ma si vogliono escludere alcuni file, per esempio il file README.md del repository, si può aggiungere un file .dockerignore. Utilizzare lo stesso file per ignorare anche i file e le directory specificati nel file .gitignore. Utilizzando lo stesso file, ci si assicura che una compilazione eseguita localmente abbia a disposizione lo stesso insieme di file della compilazione eseguita in Code Engine.

Copiare sempre i propri file dell'applicazione in una sottodirectory della root (/) piuttosto che direttamente nella root per evitare conflitti con i file del sistema operativo. Quando si assegna un nome alla directory dell'applicazione, non usarne uno riservato ai sistemi operativi basati su UNIX, come Kubernetes o Code Engine, come /bin, /dev, /etc, /lib, /proc, /run, /sys, /usr, /var, o /workspace. La denominazione della directory dell'applicazione /app è una procedura ottimale.

Se il tuo repository del codice sorgente contiene le origini per le diverse applicazioni organizzate in directory, simili al repository degli esempiCode Engine, puoi utilizzare una sottodirectory come tuo contesto. Nel comando ibmcloud ce build create, specificare le directory secondarie utilizzando l'opzione --context-dir.

Se il tuo Dockerfile non si trova nella directory di contesto, puoi indicarlo utilizzando l'argomento --dockerfile.

Per provare una build Docker sul sistema locale prima di crearlo in Code Engine, puoi utilizzare Docker Desktop.

Riduzione della dimensione di un'immagine contenitore

La riduzione della dimensione di una immagine contenitore porta un valore in più aspetti.

  • È necessario meno spazio per memorizzare l'immagine nel registro del contenitore. Si salva la quota per altre immagini e si risparmia denaro.
  • A volte, l'esecuzione della build richiede meno tempo per essere completata perché un'immagine più piccola può essere trasferita più velocemente al registro del contenitore rispetto a una molto più grande. Si risparmia di nuovo.
  • L'applicazione o il lavoro che utilizza l'immagine viene avviato più velocemente perché il tempo necessario per eseguire il pull dell'immagine è più breve. Poiché le risorse necessarie per eseguire la tua applicazione o il tuo lavoro sono riservate mentre l'immagine viene estratta, risparmi di nuovo. Un tempo di avvio rapido è particolarmente rilevante per le applicazioni in Code Engine perché garantisce un tempo di risposta accettabile per le richieste dei tuoi utenti, anche se l'applicazione viene ridotta a zero.

Esaminare alcune delle seguenti procedure ottimali per ridurre la dimensione della build.

Combina diversi comandi in una singola istruzione RUN per ridurre la dimensione dell'immagine

In quest' esempio devi installare il software nell'immagine del contenitore, ad esempio Node.js. Utilizza le immagini base per Node.js per creare un'applicazione Node.js.

Per installare manualmente Node.js, puoi utilizzare il seguente esempio di Dockerfile:

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y nodejs
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

In questo esempio, diverse istruzioni RUN sono utilizzate per aggiornare, installare Node.js, pulire e rimuovere la cache dei file. Sebbene questa sequenza di istruzioni sia corretta, l'utilizzo di più istruzioni RUN non è il metodo migliore per implementarla. Quando viene elaborato un Dockerfile, ogni istruzione nel Dockerfile crea un livello. Ogni livello contiene i file che sono stati creati o aggiornati e anche le informazioni su ciò che è stato eliminato. Un'immagine contenitore è la raccolta di tutti i livelli. Se un file viene aggiunto in una istruzione ed eliminato in una seconda, l'immagine risultante contiene ancora il file nel livello per la prima istruzione e lo contrassegna come eliminato nel secondo livello.

Per risparmiare spazio su disco, specificare una singola istruzione RUN per creare un singolo livello per questa serie di comandi,

FROM ubuntu

RUN apt update && apt upgrade -y && apt install -y nodejs && apt clean && rm -rf /var/lib/apt/lists/\*

Per mantenere un Dockerfile leggibile con una riga per comando, aggiungere le interruzioni di riga al codice,

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

Utilizzando questo tipo di istruzione, è possibile ridurre la dimensione dell'immagine contenitore da circa 174 MB a circa 147 MB. Nota che le tue dimensioni di esempio potrebbero differire in quanto l'immagine di base Ubuntu e il pacchetto Node.js possono cambiare.

Questo tipo di prassi ottimale si applica non solo alle installazioni di pacchetti, ma anche a compiti simili. Nell'esempio successivo, il download e l'estrazione di un pacchetto software, nuovamente Node.js, può essere simile al seguente esempio,

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y curl
RUN curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz
RUN mkdir /opt/node
RUN tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1
RUN rm /tmp/nodejs.tar.gz
RUN apt remove -y curl
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

Il comando rm rimuove il pacchetto nodejs scaricato, ma le istruzioni RUN multiple creano nuovamente livelli separati. Inoltre, per scaricare il pacchetto nodejs, il pacchetto curl è temporaneamente installato. Mentre il pacchetto curl stesso viene successivamente rimosso, le sue dipendenze che erano implicitamente installate sono ancora lì. Un file Docker migliore è quello dell'esempio seguente,

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y curl && \
    curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz && \
    mkdir /opt/node && \
    tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1 && \
    rm /tmp/nodejs.tar.gz && \
    apt remove -y curl && \
    apt auto-remove -y && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

I comandi correlati vengono combinati in una singola istruzione RUN e il comando apt auto-remove viene aggiunto per rimuovere le dipendenze. Questo esempio riduce la dimensione dell'immagine da circa 222 MB a circa 162 MB.

Utilizza una piccola immagine di base

Gli esempi precedenti utilizzano Ubuntu come immagine base. Mentre questa immagine contiene molti programmi di utilità utili, più sono i programmi di utilità che si trovano in un'immagine di base, maggiore è la sua dimensione. Inoltre, includendo più programmi di utilità, aumenti la possibilità di incontrare una vulnerabilità della sicurezza, richiedendo di ricreare la tua immagine. Per evitare entrambi questi problemi, utilizzare un'immagine di base più piccola. Ad esempio,

  • Alpine è un'immagine Docker ufficiale con dimensioni ridotte. Per gli ambienti di programmazione come Java o Node.js, spesso trovi tag che si basano su Alpine.

  • Le immagini distroless di Google Container Tools non contengono alcun strumento del sistema operativo ma l'ambiente di runtime necessario per linguaggi differenti come Java e Node.js.

Confronta queste immagini di base creando un'immagine contenitore che esegue un programma Node.js in un file program.js. Per Ubuntu, il Dockerfile è simile al seguente esempio:

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Per Alpine, l'immagine viene presa direttamente da Node.js,

FROM node:16-alpine

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Per distroless, utilizzare il seguente esempio:

FROM gcr.io/distroless/nodejs:16

COPY program.js /app/program.js

WORKDIR /app

CMD ["program.js"]

Mentre l'immagine basata su Ubuntuè di 147 MB, l'immagine basata su Alpine è di 90 MB e l'immagine basata su distroless è di 94 MB.

Non includere origini e strumenti di build per ridurre la dimensione dell'immagine

Nei precedenti esempi basati su Node.js, viene aggiunto un singolo file di origine all'immagine contenitore. Questo esempio può utilizzare un singolo file di origine poiché non è stata necessaria alcuna compilazione. Tuttavia, se è necessaria una compilazione, utilizzare gli strumenti necessari solo per la build, ma non includerli nell'immagine risultante. Ad esempio, specifica un'applicazione Java che utilizza Maven come esempio. Un Dockerfile non codificato è simile al seguente esempio:

FROM maven:3-jdk-11-openj9

WORKDIR /app
COPY . /app

RUN mvn package

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/target/java-application-1.0-SNAPSHOT-fat.jar"]

L'immagine del contenitore risultante contiene tutto il codice sorgente e tutti i file intermedi da Maven (come la sua cache di risorse, i file di classe che vengono successivamente impacchettati in un file JAR) e lo strumento di build Maven. Inoltre, il JDK ( Java Development Kit) è incluso anche quando è richiesto un JRE ( Java Runtime Environment) molto più piccolo al runtime. Di conseguenza, la dimensione dell'immagine è di 466 MB.

Per rendere l'immagine più piccola, utilizzare una funzione denominata build a più fasi. Ogni fase in una build Docker ha la propria immagine di base e può eseguire i comandi nella fase. Alla fine, viene creata un'immagine finale che copia nelle risorse utente delle fasi precedenti. Per creare un'immagine contenitore dal codice sorgente, un pattern comune deve avere due fasi:

  1. Lo stage del builder che utilizza un'immagine di base che contiene tutti gli strumenti necessari per compilare il codice sorgente nel binario per il runtime.
  2. Lo stage di runtime che utilizza un'immagine di base con l'ambiente di runtime necessario per eseguire il binario. Il binario dello stage builder viene copiato in questo stage.

Per il progetto Maven, il risultato è simile al seguente esempio:

FROM maven:3-jdk-11-openj9 AS builder

WORKDIR /app
COPY . /app

RUN mvn package

FROM adoptopenjdk:11-jre-openj9

WORKDIR /app
COPY --from=builder /app/target/java-application-1.0-SNAPSHOT-fat.jar /app/java-application.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/java-application.jar"]

In questo esempio, lo stage del builder utilizza l'immagine di base Maven per creare il file JAR dell'applicazione. Lo stage runtime utilizza l'immagine di base JRE più piccola. Utilizzando il comando COPY --from=STAGE_NAME, il file JAR viene copiato dallo stage del builder nello stage di runtime. L'immagine risultante ha una dimensione di 242 MB, risparmiando circa il 48%.

Questo modello può essere utilizzato anche per altri linguaggi di programmazione in cui è necessario eseguire una compilazione.

  • Applicazioni Node che richiedono una build, ad esempio una build Angular o React. In questo caso, il builder e l'immagine di base di runtime potrebbero essere uguali (entrambi i nodi), ma non tutte le risorse utente e le origini del tempo di build devono essere copiate nell'immagine di tempo di runtime.
  • Qualsiasi linguaggio di programmazione che compila il codice sorgente in un eseguibile nativo che viene eseguito senza un ambiente di runtime, ad esempio Go o Rust.

Per quei linguaggi che producono un eseguibile nativo e non necessitano affatto di un ambiente di runtime, utilizza un'altra funzionalità Docker per la fase di runtime: scratch. Scratch può essere utilizzato come base nel comando FROM, ma non è un'immagine contenitore finale. Invece, indica alla build Docker di non utilizzare affatto un'immagine base. Senza i file del sistema operativo da un'immagine di base, l'immagine risultante può contenere solo un singolo file: il binario che viene copiato dallo stage del builder. Tenere presente che, a seconda del linguaggio di programmazione e del codice, potrebbe essere necessario apportare ulteriori modifiche alle opzioni del compilatore poiché i file binari potrebbero basarsi su alcuni file del sistema operativo per esistere.

Tieni pulita la tua immagine

Quando stai per la prima volta sviluppando il tuo Dockerfile e eseguendo il debug di come funziona, potresti installare alcuni strumenti temporanei o applicare altri carichi di lavoro che non devono trovarsi nell'immagine del contenitore finale. La rimozione di tali file temporanei e carichi di lavoro mantiene la tua immagine pulita, piccola e più sicura.

Migliorare l'ora di inizio della tua immagine

Per la massima efficienza, l'immagine del contenitore deve iniziare il più velocemente possibile. Per le applicazioni, la metrica rilevante è il tempo che impiega l'endpoint HTTP per essere disponibile e pronto ad accettare ed elaborare richieste HTTP in entrata. La velocità di avvio è particolarmente importante per le applicazioni Code Engine basate su Knative. Tali applicazioni possono essere configurate per ridurre a zero le istanze in esecuzione quando non c'è traffico, quindi non consumando risorse e non costando denaro. Quando arriva una richiesta, viene avviata un'istanza per gestire la richiesta. Il tuo contenitore deve iniziare il più velocemente possibile per rispondere alla nuova richiesta.

Per migliorare l'avvio dell'applicazione, esaminare l'implementazione dell'applicazione e verificare se è possibile applicare modelli quali:

  • Parallelizzazione del lavoro di inizializzazione indipendente, ad esempio, per stabilire una connessione a un database e per leggere il file di configurazione per comunicare con un server di posta dalle variabili di ambiente.
  • Ritardo del lavoro di inizializzazione che non è necessario per l'avvio dell'applicazione ed eseguirli invece in base alla prima necessità.

Inoltre, è anche possibile evitare una trappola comune quando si implementa un'applicazione Web che utilizza un framework come Angular, React o Vue. Ciascuno di questi framework è basato su Node.js con NPM e include un'interfaccia della riga di comando che può semplificare la configurazione di un progetto. Ad esempio, un'applicazione React creata con il comando create-react-app imposta un file package.json che include alcuni script predefiniti. Uno di tali script è start, che richiama un server Web con l'applicazione Web. Il tuo Dockerfile può essere simile al seguente esempio,

FROM nodejs:16-alpine

COPY . /app
WORKDIR /app

RUN npm install

EXPOSE 3000
ENTRYPOINT ["npm", "run", "start"]

Mentre questo tipo di Dockerfile funziona, non è veloce come ogni volta che viene richiamato il comando npm run start, l'applicazione viene compilata e quindi avviata. Questo ritardo è particolarmente evidente con applicazioni che vanno oltre una piccola dimensione del campione. L'approccio corretto è compilare l'applicazione al momento della creazione e servirla solo all'avvio,

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app

RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Viene visualizzato nuovamente il builder e il modello a due fasi di runtime. Notare inoltre che l'esempio aggiornato utilizza una porta diversa, 8080. Mentre questo esempio funziona con qualsiasi altra porta, 8080 è la porta predefinita per applicazioni Code Engine. Inoltre, utilizzando la build compilata, tutte le origini e tutti gli strumenti installati in node_modules non sono inclusi nell'immagine del contenitore finale, che riduce la sua dimensione da 281 a 97 MB.

Esecuzione di un contenitore come non root

I sistemi ben progettati seguono il principio del minimo privilegio - un'applicazione o un utente ottiene solo quei privilegi che richiede per eseguire un'azione specifica. In Code Engine, si esegue un server delle applicazioni o una logica batch, che di solito non richiede l'accesso di gestione al sistema. Pertanto, non deve essere eseguito come root nel contenitore. Una buona pratica è quella di configurare l'immagine del contenitore con un utente definito e di eseguirla come non root. Ad esempio, in base allo scenario precedente,

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Il Dockerfile utilizza il comando USER per specificare che desidera essere eseguito come utente e gruppo 1100. Notare che questo comando non crea implicitamente un utente e un gruppo denominato nell'immagine contenitore. Di solito, questa struttura è accettabile, ma se la logica dell'applicazione richiede l'esistenza dell'utente e anche della sua directory home, è necessario creare esplicitamente l'utente e il gruppo:

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve && \
    addgroup nonroot --gid 1100 && \
    adduser nonroot --ingroup nonroot --uid 1100 --home /home/nonroot --disabled-password

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Il comando RUN nello stage di runtime è stato esteso per richiamare i comandi addgroup e adduser per creare un gruppo e un utente con una directory home.