Skip to main content

Mejores prácticas para crear contenedores de aplicaciones Go con Docker

Escrito por
Headshot of Jekayin-Oluwa Olabemiwo

Jekayin-Oluwa Olabemiwo

blog feature docker labels

23 de marzo de 2022

0 minutos de lectura

Las aplicaciones Go y los contenedores están hechos el uno para el otro. El pequeño tamaño de los binarios de las aplicaciones Go es ideal para las estrategias de implementación de microservicios que Docker y Kubernetes ofrecen con gran eficacia. Sin embargo, esta sinergia también presenta desafíos. Por eso, es importante comprender las mejores prácticas y los conceptos clave de los contenedores para evitar problemas de seguridad que pueden introducirse fácilmente en las imágenes de contenedor.

En este artículo, programarás una aplicación Go de ejemplo y aprenderás a crear su contenedor y ejecutarla de forma segura.

Requisitos previos

Para seguir este tutorial, necesitas tener Docker y Go instalados en tu equipo y conocer sus conceptos básicos. Encontrarás instrucciones introductorias y descargas de Docker en la página de orientación y configuración de la documentación de Docker. Puedes encontrar buenos tutoriales de Go en la documentación oficial de Go o ir directamente a la página de instalación de Go para Linux.

Crear una aplicación Go de ejemplo

Para empezar, creemos nuestra API de Go. Primero, ve al directorio de tu equipo donde quieres guardar la aplicación Go. Luego, crea un directorio llamado “godocker”. En este directorio godocker/, ejecuta el siguiente comando para definir tu módulo Go:

go mod init godocker

Luego, crea un archivo llamado “main.go” dentro del directorio “godocker”. Este archivo contendrá el código de tu API. Ahora puedes agregar la lógica para proporcionar y gestionar la hora actual mediante la API.

Ingresa el siguiente código en godocker/main.go:

package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
)

type Time struct {
CurrentTime string `json:"current_time"`
}

func main() {
// defining router
mux := http.NewServeMux()
mux.HandleFunc("/time", getTime)

// starting server
fmt.Println("Server is running at 127.0.0.1:8080")
log.Fatal(http.ListenAndServe( "localhost:8080", mux))
}

func getTime(w http.ResponseWriter, r *http.Request) {
currentTime := []Time{
    { CurrentTime: http.TimeFormat },
}

json.NewEncoder(w).Encode(currentTime)
}

Este código proporciona la hora actual mediante la API usando la variable TimeFormat del paquete http. Luego, definimos la biblioteca NewServeMux como mux para configurar el servidor HTTP. Por último, registramos el endpoint /time para devolver la hora actual en la API y codificar la respuesta en el endpoint /time como JSON.

go mod init godocker

Hagamos una prueba rápida. Ejecuta la aplicación con el siguiente comando en la terminal:

go run main.go

Deberías obtener un resultado similar a este:

Server is running at 127.0.0.1:8080

Ahora, probemos la API en otra ventana de terminal con cURL. Ingresa el siguiente comando en la terminal:

curl http://127.0.0.1:8080/time

El resultado debería verse así:

[{"current_time":"Mon, 02 Jan 2006 15:04:05 GMT"}]

Preparar el Dockerfile

Un Dockerfile contiene una serie de instrucciones para empaquetar e implementar una aplicación como contenedor. En esta sección, crearemos un Dockerfile y revisaremos algunas de las instrucciones que podemos usar para empaquetar nuestra aplicación de ejemplo como contenedor.

Especificar la versión de la sintaxis de Docker

Primero, agregamos la directiva de sintaxis. En el directorio godocker/, crea un archivo llamado "Dockerfile" e ingresa el siguiente código en la primera línea:

# syntax=docker/dockerfile:1

La directiva de sintaxis especifica la ubicación de la sintaxis de Dockerfile que usaremos para crear nuestro Dockerfile. Esta línea de código define la ubicación de la sintaxis de Dockerfile como docker/dockerfile:1, que es la versión más reciente. Docker comprobará la versión de la sintaxis antes de usar el backend Buildkit para crear el Dockerfile.

Asegúrate de que esta línea tenga un comentario al inicio del archivo. Después de la directiva de sintaxis, la convención es dejar una línea en blanco.

Heredar una imagen base con poca memoria

Luego, especificamos la imagen base de Docker que queremos heredar con la instrucción FROM.

Agrega el siguiente código a godocker/Dockerfil`:

FROM golang:1.17-alpine

Esta instrucción garantiza que no tengamos que crear nuestra propia imagen base de Docker. En su lugar, heredamos la imagen oficial de Docker para aplicaciones Go en la variante Alpine Linux. La versión de Go de la imagen base es 1.17. La imagen alpine es muy pequeña en comparación con una variante como la imagen ubuntu.

Ahora completamos el resto del Dockerfile, incluidos los comentarios en el código, para explicar brevemente el propósito de cada línea.

Actualiza godocker/Dockerfile para que contenga el siguiente código:

# syntax=docker/dockerfile:1

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

# tells Docker that the container listens on specified network ports at runtime
EXPOSE 8080

# command to be used to execute when the image is used to start a container
CMD [ "/godocker" ]

Crear la imagen

Con nuestro Dockerfile listo, creemos nuestra imagen de Docker a partir de él con el comando docker build. Docker usa el daemon de Docker para crear imágenes. Usamos la opción --tag —que podemos abreviar como -t— con el comando docker build para asignar un nombre personalizado a nuestra imagen de Docker.

Ingresa el siguiente comando en la terminal:

docker build --tag godocker .

Agregamos . al comando docker build para que la imagen se cree en el directorio actual, que sirve como contexto de compilación. Evita usar la ruta / como contexto de compilación, ya que esto puede transferir todo el código fuente al daemon de Docker.

El resultado de la compilación debería incluir la línea FINISHED y verse similar a esto:

[+] Building 6.8s (17/17) FINISHED                                                            
...
 => => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763  0.0s
 => => naming to docker.io/library/godocker

Este resultado significa que la imagen de Docker se creó por completo con la etiqueta godocker.

Para ver la lista de imágenes locales, ingresa el siguiente comando en la terminal:

docker image ls

El resultado debería verse similar a este:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                latest     539bdb3e661f    2 minutes ago   319MB
docker/getting-started  latest     720f449e5af2    1 hour ago      27.2MB

En nuestro resultado, el tamaño de la imagen godocker recién creada es de 319 MB, lo cual es mucho para una aplicación de API sencilla. Por eso, debemos optimizar la compilación para crear una imagen más ligera. En la siguiente sección, implementaremos el concepto de compilaciones multietapa para lograrlo.

Usar compilaciones multietapa

El enfoque de compilaciones multietapa ayuda a crear imágenes mucho más pequeñas que las que se obtienen con el enfoque de una sola etapa que mostramos en la sección anterior. Una compilación multietapa usa una imagen para crear fragmentos que se empaquetan en una imagen más pequeña, compuesta únicamente por las partes esenciales para que esos fragmentos se ejecuten. Al reducir nuestras imágenes al mínimo necesario para ejecutar la aplicación, podemos reducir el riesgo de vulnerabilidades de seguridad. Para lograrlo, debemos usar varias instrucciones FROM en nuestro Dockerfile.

Usar la imagen oficial scratch

Podemos iniciar la compilación con una imagen vacía heredando la imagen oficial de Docker scratch. En esta sección, mostraremos cómo usar la imagen scratch en compilaciones multietapa.

Primero, ve al directorio raíz de tu aplicación. Luego, crea un archivo llamado “Dockerfile.multistage” e ingresa el siguiente código:

# syntax=docker/dockerfile:1

##
## STEP 1 - BUILD
##

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine AS build

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

EXPOSE 8080

ENTRYPOINT ["/godocker"]

Este código especifica que se heredará la imagen base oficial golang:1.17-alpine, con el nombre de etapa build. Luego, usamos otra instrucción FROM para implementar el concepto multietapa y copiar el binario compilado de la primera etapa a la imagen vacía de la segunda.

Luego, debemos crear una nueva imagen con el nuevo archivo Dockerfile.multistage. También debemos asignarle la etiqueta “multistage”. Esto permite distinguirla de la imagen que creamos antes.

Ingresa el siguiente comando en la terminal:

docker build -t godocker:multistage -f Dockerfile.multistage .

Cuando la compilación termine correctamente, revisa la lista de imágenes ingresando el siguiente comando en la terminal:

docker image ls

El resultado debería verse similar a este:

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                multistage 192cc137f88b    9 seconds ago   6.18MB
godocker                latest     539bdb3e661f    1 hour ago      319MB

Este resultado muestra la gran diferencia de tamaño entre las imágenes godocker:multistage y godocker:latest. La mejora es evidente: pasamos de 319 MB en la imagen de una sola etapa a 6,1 MB en la imagen multietapa. Como dependemos de los contenedores para iniciarse rápidamente, optimizaciones como esta son fundamentales al crear contenedores para aplicaciones Go.

Implementar el contenedor

Además de optimizar el rendimiento y la eficiencia, también debemos pensar en la mejor manera de implementar nuestros contenedores para que se ejecuten de forma segura. En esta etapa del tutorial, pondremos en práctica algunas de las mejores prácticas.

Ejecutar como usuario no root

El principio de privilegio mínimo exige que limitemos el acceso a los recursos del sistema. Nuestros contenedores Docker de Go son contenedores de aplicaciones y no necesitan ejecutarse con privilegios de root. Por eso, debemos crear un nuevo usuario y grupo con acceso limitado en nuestro Dockerfile para mejorar la seguridad.

Para crear un usuario que no sea root, agrega las siguientes líneas inmediatamente después de la primera instrucción FROM en godocker/Dockerfile.multistage:

RUN useradd -u 1001 -m iamuser

Esta instrucción define los argumentos USERNAME y PASSWORD con la palabra clave ARG y luego crea un usuario con la instrucción RUN adduser.

Luego, implementemos las instrucciones para copiar los datos del usuario de la primera etapa y aplicarlos a la segunda.

Agrega el código en negrita del siguiente ejemplo a la segunda etapa de godocker/Dockerfile.multistage, que ahora debería terminar con el siguiente código:

...
##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

COPY --from=build /etc/passwd /etc/passwd

USER 1001

EXPOSE 8080

ENTRYPOINT ["/godocker"]

En Kubernetes, puedes especificar runAsuser: UID en el campo securityContext. Consulta la documentación de Kubernetes para aprender a establecer el contexto de seguridad de un Pod.

Ejecutar con un sistema de archivos raíz de solo lectura

Otra forma de mejorar la seguridad de nuestra aplicación es ejecutar el contenedor con un sistema de archivos de solo lectura. Para aplicar el acceso de solo lectura al sistema de archivos de nuestro contenedor, usaremos la opción de solo lectura con el comando docker run.

Ingresa el siguiente comando en la terminal:

docker run -read-only godocker

Eliminar o denegar capacidades de Linux

Las capacidades de Linux son el conjunto de privilegios que podemos habilitar o deshabilitar al usar Linux. Como nuestro contenedor se basa en una variante de Linux, también podemos usar estas capacidades para mejorar la seguridad de nuestra aplicación. Eliminar algunas capacidades reduce el riesgo para nuestro contenedor.

En este tutorial, eliminaremos todas las capacidades excepto setuid.

Ingresa el siguiente comando en la terminal:

docker run --cap-drop=all --cap-add=setuid

Limitar el uso de CPU y memoria

El comando docker run nos permite establecer límites de uso de recursos del equipo host para nuestro contenedor de Docker. Docker usa la opción --cpus.

Por ejemplo, para evitar que el contenedor use más del 50 % de una sola CPU, ingresa el siguiente comando en la CLI de Docker:

docker run -it --cpus=".5" alpine /bin/bash

Si necesitas usar 2 CPU, puedes limitar el uso con el siguiente comando en la CLI de Docker:

docker run -it --cpus=2 alpine /bin/bash

Si quieres limitar el uso de memoria de un contenedor Docker a 1024 MB, puedes usar el comando docker run así:

docker run -m 1024m --memory-reservation=256m alpine /bin/bash

Este comando también establece un límite de memoria de 256 MB que se aplica cuando Docker detecta que el host tiene poca memoria disponible.

Conclusión

En este artículo, recorrimos el proceso de configurar una aplicación Go y crear su contenedor con Docker. También implementamos el concepto de compilaciones multietapa para optimizar el rendimiento, creamos una imagen desde cero con una imagen oficial de Docker vacía y analizamos algunas mejores prácticas que pueden ser útiles al crear e implementar contenedores. Estas pautas son un punto de partida para lograr una eficiencia sólida, seguridad y administración de memoria al crear contenedores para aplicaciones web Go con Docker.

Para obtener más información sobre las mejores prácticas de seguridad, visita el centro de recursos Snyk Learn.