IBM Cloud Docs
Cómo escribir un Dockerfile para Code Engine

Cómo escribir un Dockerfile para Code Engine

Antes de crear el código en una imagen de contenedor, obtenga información básica sobre cómo funciona una compilación de Docker en IBM Cloud® Code Engine. A continuación, examine algunas prácticas recomendadas para que el Dockerfile alcance estos objetivos. Estos ejemplos se centran en reducir el tamaño de la imagen, mejorar la interoperatividad con aplicaciones de Code Engine y ejecutar contenedores como un usuario no root.

Cuando cree la configuración de la compilación, debe decidir cuál de las dos estrategias disponibles va a utilizar.

  1. Los paquetes de compilación nativos de la nube (Cloud Native Buildpacks) inspeccionan el código fuente y detectan el entorno de ejecución en el que se basa el código y cómo se crea una imagen de contenedor a partir de sus orígenes. Recibe soporte en diversos entornos de programación. Encontrará la lista de entornos admitidos en el tema sobre Elección de una estrategia de compilación.

  2. Una compilación de Docker crea un contenedor en función de cómo lo describe en un Dockerfile. A continuación, el archivo Dockerfile se confirma junto con el código fuente para crear el contenedor.

Aunque puede utilizar cualquiera de las estrategias para la compilación, puede elegir Dockerfile, si, por ejemplo,

  • El entorno de programación no recibe soporte de los paquetes de compilación.
  • La compilación del proyecto debe instalar paquetes adicionales en el contenedor.

Conceptos básicos sobre Dockerfile

Un Dockerfile describe cómo se crea un contenedor. En el archivo Dockerfile, elija una imagen base que incluya las herramientas necesarias que necesite durante la compilación y el tiempo de ejecución. Puede copiar archivos de un contexto de compilación en la imagen; ejecutar mandatos; definir el comportamiento de tiempo de ejecución como, por ejemplo, las variables de entorno y los puertos expuestos; y establecer ENTRYPOINT. El mandato que se invoca cuando se inicia el contenedor establece ENTRYPOINT. Para obtener más información sobre cómo se pueden especificar las instrucciones de Dockerfile, consulte Referencia de Dockerfile.

En una compilación de Code Engine, se define un origen que apunta a un repositorio Git. El contexto que está disponible para la compilación de Docker es, de forma predeterminada, el directorio raíz del repositorio Git. Por ejemplo, si tiene un directorio denominado src en el repositorio, puede utilizar la sentencia COPY en el archivo Dockerfile para copiar este directorio en la imagen. Por ejemplo:

COPY src /app/src

Para copiar todo el repositorio Git, especifique la siguiente sentencia COPY:

COPY . /app/src

Si copia todo el repositorio de Git, pero desea excluir algunos archivos, por ejemplo el README.md del repositorio, puede añadir un Archivo .dockerignore. Utilice el mismo archivo para ignorar también los archivos y directorios que especifique en el archivo .gitignore. Al utilizar el mismo archivo, se garantiza que una compilación ejecutada localmente va a tener el mismo conjunto de archivos disponibles que la compilación ejecutada en Code Engine.

Copie siempre los archivos de aplicación en un subdirectorio de la raíz (/) en lugar de en la raíz directamente, para evitar conflictos con los archivos del sistema operativo. Cuando asigne un nombre al directorio de la aplicación, no utilice uno reservado por sistemas operativos basados en Unix, Kubernetes o compilaciones de Code Engine como, por ejemplo, /bin, /dev, /etc, /lib, /proc, /run, /sys, /usr, /var o /workspace. Se recomienda denominar /app al directorio de la aplicación.

Si el repositorio de código fuente contiene los orígenes para distintas aplicaciones que están organizadas en directorios, de forma similar al repositorio de ejemplos deCode Engine, puede utilizar un subdirectorio como contexto. En el mandato ibmcloud ce build create, especifique subdirectorios mediante la opción --context-dir.

Si el archivo Dockerfile no está en el directorio de contexto, puede apuntar al mismo con el argumento --dockerfile.

Para probar una compilación de Docker en el sistema local antes de compilarla en Code Engine, puede utilizar Docker Desktop.

Reducción del tamaño de una imagen de contenedor

La reducción del tamaño de una imagen de contenedor aporta ventajas en varios aspectos.

  • Se necesita menos espacio para almacenar la imagen en el registro de contenedores. Así ahorra cuota para otras imágenes y también ahorra dinero.
  • A veces, la ejecución de compilación necesita menos tiempo para completarse porque una imagen más pequeña se puede transferir más rápido al registro de contenedor que una imagen mucho más grande. De nuevo, así ahorra dinero.
  • La aplicación o el trabajo que utiliza la imagen se inicia más rápido porque el tiempo necesario para extraer la imagen es menor. Puesto que los recursos necesarios para ejecutar la aplicación o el trabajo están reservados mientras se extrae la imagen, vuelve a ahorrar dinero. Un tiempo de inicio rápido es especialmente relevante para las aplicaciones de Code Engine porque garantiza un tiempo de respuesta aceptable para las solicitudes de los usuarios, incluso si la aplicación se reduce a cero.

Consulte algunas de las siguientes prácticas recomendadas para reducir el tamaño de la compilación.

Combinación de varios mandatos en una sola sentencia RUN para reducir el tamaño de la imagen

En este ejemplo, debe instalar software en la imagen de contenedor, por ejemplo, Node.js. Utilice las imágenes base para Node.js para crear una aplicación Node.js.

Para instalar manualmente Node.js, puede utilizar el siguiente ejemplo de 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/\*

En este ejemplo, se utilizan varias sentencias RUN para actualizar, instalar Node.js, limpiar y eliminar la memoria caché de archivos. Aunque esta secuencia de sentencias es correcta, el uso de varias sentencias RUN no es la mejor implementación. Cuando se procesa un Dockerfile, cada sentencia del Dockerfile crea una capa. Cada capa contiene los archivos que se han creado o actualizado y también información sobre lo que se ha suprimido. Una imagen de contenedor es la colección de todas las capas. Si se añade un archivo a una sentencia y se suprime en una segunda, la imagen resultante todavía contiene el archivo en la capa correspondiente a la primera sentencia y la marca como suprimida en la segunda capa.

Para ahorrar espacio de disco, especifique una sola sentencia RUN para crear una sola capa para este conjunto de mandatos.

FROM ubuntu

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

Para facilitar la lectura de un Dockerfile con una línea por mandato, añada saltos de línea a su código,

FROM ubuntu

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

Si utiliza este tipo de sentencia, puede reducir el tamaño de la imagen de contenedor de unos 174 MB a unos 147 MB. Tenga en cuenta que los tamaños de ejemplo pueden diferir ya que la imagen base de Ubuntu y el paquete Node.js pueden cambiar.

Este tipo de práctica recomendada no solo se aplica a las instalaciones de paquetes, sino también a tareas similares. En el siguiente ejemplo, la descarga y la extracción de un paquete de software, de nuevo Node.js, se pueden parecer similar a las del siguiente ejemplo:

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/\*

El mandato rm elimina el paquete nodejs descargado, pero las múltiples sentencias RUN vuelven a crear capas separadas. Además, para descargar el paquete nodejs, se instala temporalmente el paquete curl. Aunque el paquete de curl propiamente dicho se elimina más adelante, sus dependencias que se instalaron implícitamente siguen ahí. En el siguiente ejemplo se muestra un Dockerfile más adecuado,

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/\*

Los mandatos relacionados se combinan en una sola sentencia RUN y se añade el mandato apt auto-remove para eliminar las dependencias. Este ejemplo reduce el tamaño de la imagen de unos 222 MB a unos 162 MB.

Utilización de una imagen base muy pequeña

En los ejemplos anteriores se utiliza Ubuntu como imagen base. Aunque esta imagen contiene muchos programas de utilidad útiles, cuantos más programas de utilidad haya en una imagen base, mayor es su tamaño. Además, al incluir más programas de utilidad, aumenta la probabilidad de que encuentre una vulnerabilidad de seguridad, lo que le requiere volver a compilar la imagen. Para evitar estos problemas, utilice una imagen base de menor tamaño. Por ejemplo:

Compare estas imágenes base creando una imagen de contenedor que ejecute un programa Node.js en un archivo program.js. Para Ubuntu, el archivo Dockerfile tiene un aspecto similar al del siguiente ejemplo:

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"]

Para Alpine, la imagen se toma directamente desde Node.js,

FROM node:16-alpine

COPY program.js /app/program.js

WORKDIR /app

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

Para distroless, utilice el ejemplo siguiente:

FROM gcr.io/distroless/nodejs:16

COPY program.js /app/program.js

WORKDIR /app

CMD ["program.js"]

Mientras que la imagen basada en Ubuntu es de 147 MB, la imagen basada en Alpine es de 90 MB y la imagen basada en distroless es de 94 MB.

No incluya fuentes ni herramientas de compilación para reducir el tamaño de la imagen

En los ejemplos basados en Node.js anteriores, se añade un solo archivo de origen a la imagen de contenedor. En este ejemplo se ha podido utilizar un único archivo de origen porque no era necesaria ninguna compilación. Sin embargo, si se necesita una compilación, utilice solo las herramientas necesarias para la compilación, pero no las incluya en la imagen resultante. Por ejemplo, especifique una aplicación Java que utilice Maven como ejemplo. Un Dockerfile mal codificado tiene un aspecto similar al del siguiente ejemplo:

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"]

La imagen de contenedor resultante contiene todo el código fuente y todos los archivos intermedios de Maven (como su memoria caché de artefactos, archivos de clase que se empaquetan posteriormente en un archivo JAR) así como la herramienta de compilación Maven. Además, también se incluye el JDK (Java Development Kit) cuando se necesita un Java Runtime Environment (JRE) mucho menor en el momento de la ejecución. Como resultado, el tamaño de la imagen es de 466 MB.

Para reducir el tamaño de la imagen, utilice una característica llamada compilaciones de varias etapas. Cada etapa de una compilación de Docker tiene su propia imagen base y puede ejecutar mandatos en su etapa. Al final, se crea una imagen final que copia artefactos de etapas anteriores. Para compilar una imagen de contenedor a partir del código fuente, un patrón común consiste en tener dos etapas:

  1. La etapa de compilación que utiliza una imagen base que contiene todas las herramientas necesarias para compilar el código fuente en el binario para el tiempo de ejecución.
  2. La etapa de tiempo de ejecución que utiliza una imagen base con el entorno de ejecución necesario para ejecutar el binario. El binario de la etapa de compilación se copia en esta etapa.

Para el proyecto Maven, el resultado se parece al del siguiente ejemplo:

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"]

En este ejemplo, la etapa de compilación utiliza la imagen base de Maven para compilar el archivo JAR de la aplicación. La etapa de tiempo de ejecución utiliza la imagen base de JRE de menor tamaño. Mediante el mandato COPY --from=STAGE_NAME, el archivo JAR se copia de la etapa de compilación en la etapa de ejecución. La imagen resultante tiene un tamaño de 242 MB, ahorrando alrededor del 48%.

Este patrón también se puede utilizar para otros lenguajes de programación en los que es necesario realizar una compilación.

  • Aplicaciones de nodo que requieren una compilación, por ejemplo una compilación Angular o React. Aquí, la imagen base del compilador y del tiempo de ejecución pueden terminar siendo la misma (ambos nodos), pero no es necesario copiar todos los artefactos y orígenes de tiempo de compilación en la imagen de tiempo de ejecución.
  • Cualquier lenguaje de programación que compile código fuente en un ejecutable nativo que se ejecute sin un entorno de ejecución, por ejemplo Go o Rust.

Para los lenguajes que producen un ejecutable nativo y no necesitan un entorno de ejecución, utilice otra prestación de Docker para la etapa de ejecución: scratch. Scratch se puede utilizar como base en el mandato FROM, pero no es una imagen de contenedor final. Solo indica a la compilación de Docker que no utilice una imagen base. Sin archivos de sistema operativo procedentes de una imagen base, la imagen resultante puede contener un solo archivo: el binario que se copia de la etapa de compilación. Tenga en cuenta que, en función del lenguaje de programación y del código, es posible que tenga que realizar más ajustes en las opciones del compilador, ya que la existencia de algunos archivos binarios se basa en algunos archivos del sistema operativo.

Mantenimiento de la imagen limpia

Cuando desarrolle por primera vez su Dockerfile y depure su funcionamiento, es posible que instale algunas herramientas temporales o que aplique otras cargas de trabajo que no sean necesarias en la imagen de contenedor final. La eliminación de estos archivos temporales y cargas de trabajo mantiene la imagen limpia, pequeña y segura.

Mejora del tiempo de arranque de la imagen

Para optimizar la eficiencia, la imagen de contenedor se debe iniciar tan rápido como sea posible. Para las aplicaciones, la métrica relevante es el tiempo que tarda el punto final HTTP en estar disponible y listo para aceptar y procesar solicitudes HTTP entrantes. La velocidad de inicio resulta especialmente importante para las aplicaciones de Code Engine que se basan en Knative. Estas aplicaciones se pueden configurar de modo que se reduzcan a cero instancias en ejecución cuando no hay tráfico, por lo que no consumen recursos y no cuestan dinero. Cuando entra una solicitud, se inicia una instancia para que gestione la solicitud. El contenedor se debe iniciar lo más rápido posible para responder a la nueva solicitud.

Para mejorar el arranque de la aplicación, investigue la implementación de la aplicación y consulte si puede aplicar patrones como:

  • La paralelización del trabajo de inicialización independiente, por ejemplo el establecimiento de una conexión con una base de datos y la lectura del archivo de configuración para comunicarse con un servidor de correo desde variables de entorno.
  • El retraso del trabajo de inicialización que no sea necesario para arrancar la aplicación y su ejecución solo cuando se necesite.

Además, también puede evitar un inconveniente común cuando se implementa una aplicación web que utilice una infraestructura como Angular, React o Vue. Cada una de estas infraestructuras se basa en Node.js con NPM e incluye una interfaz de línea de mandatos que puede facilitar la configuración de un proyecto. Por ejemplo, una aplicación React que se crea con el mandato create-react-app configura un archivo package.json que incluye algunos scripts predefinidos. Uno de estos scripts es start, que incorpora un servidor web con la aplicación web. El archivo Dockerfile puede tener un aspecto similar al del ejemplo siguiente:

FROM nodejs:16-alpine

COPY . /app
WORKDIR /app

RUN npm install

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

Aunque este tipo de Dockerfile funciona, no es tan rápido como cuando se invoca el mandato npm run start, la aplicación se compila y se inicia. Este retraso se nota especialmente con aplicaciones que van más allá de un pequeño tamaño de muestra. El enfoque correcto consiste en compilar la aplicación en tiempo de compilación y servirla solo al arrancar.

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" ]

Aquí se ve de nuevo el patrón de dos etapas de compilación y de tiempo de ejecución. Observe también que el ejemplo actualizado utiliza otro puerto, 8080. Aunque este ejemplo funciona con cualquier otro puerto, 8080 es el puerto predeterminado para aplicaciones de Code Engine. Además, al utilizar la compilación compilada, todas las fuentes y herramientas que están instaladas en node_modules no se incluyen en la imagen final del contenedor, lo que reduce su tamaño 281 - 97 MB.

Ejecución de un contenedor como no root

Los sistemas bien diseñados siguen el principio de menor privilegio: una aplicación o un usuario solo obtiene los privilegios necesarios para realizar una acción específica. En Code Engine, ejecuta un servidor de aplicaciones o una lógica de proceso por lotes, que normalmente no requiere acceso administrativo al sistema. Por lo tanto, no se debe ejecutar como root en su contenedor. Una práctica recomendada consiste en configurar la imagen de contenedor con un usuario definido y ejecutarla como no root. Por ejemplo, basándonos en el escenario anterior,

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" ]

El archivo Dockerfile utiliza el mandato USER para especificar que desea ejecutarse como usuario y grupo 1100. Tenga en cuenta que este mandato no crea implícitamente un usuario y un grupo con nombre en la imagen de contenedor. Normalmente, esta estructura es aceptable, pero si la lógica de la aplicación requiere que exista el usuario y también su directorio inicial, debe crear el usuario y el grupo explícitamente:

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" ]

El mandato RUN en la etapa de ejecución se ha ampliado para llamar a los mandatos addgroup y adduser para crear un grupo y un usuario con un directorio de inicio.