Práctica: Configurar un entorno multi-servicio con Docker Compose

Lectura
30 min~9 min lectura

Introducción al Entorno Multi-Servicio con Docker Compose

En el desarrollo moderno de microservicios, la capacidad de orquestar y gestionar múltiples servicios de forma cohesiva es tan crucial como el código de los propios servicios. Hasta ahora, has podido ejecutar un único servicio Go de forma aislada, pero la realidad de una arquitectura de microservicios implica la convivencia y comunicación de varios componentes: tu API, una base de datos, un sistema de caché, un servicio de colas, etc. Gestionar manualmente el inicio, la configuración de red y las dependencias de cada uno se vuelve rápidamente insostenible.

Aquí es donde Docker Compose se convierte en una herramienta fundamental. Permite definir y ejecutar aplicaciones multi-contenedor utilizando un simple archivo YAML. Para nosotros, desarrolladores de APIs en Go, esto significa que podemos recrear todo nuestro entorno de producción (o una aproximación muy fiel) en nuestra máquina local con un solo comando. Esta lección práctica te guiará en la configuración de un entorno que incluye un microservicio Go con gorilla/mux y una base de datos PostgreSQL, definiendo sus dependencias, redes y volúmenes de manera declarativa.

El objetivo final es que tengas un docker-compose.yml que, al ejecutar docker-compose up, levante automáticamente la base de datos, ejecute las migraciones necesarias y lance tu servicio Go, el cual ya estará configurado para conectarse a la base de datos a través de la red interna creada por Compose. Esto no solo agiliza el desarrollo, sino que también garantiza consistencia entre los entornos de desarrollo, testing y producción, eliminando el clásico problema de "pero en mi máquina sí funciona".

Concepto Clave: Orquestación de Contenedores para Desarrollo

Imagina que estás dirigiendo una orquesta sinfónica. Cada músico (contenedor) es un experto en su instrumento (servicio: Go, PostgreSQL, Redis). Tú, como director (Docker Compose), no necesitas enseñarle al violinista cómo tocar, pero sí necesitas dar la señal para que empiece, asegurarte de que esté en el mismo tempo que el cellista y que todos sigan la misma partitura (configuración). Docker Compose es esa batuta y partitura. Te permite coordinar el inicio, la comunicación y la configuración de múltiples servicios independientes para que trabajen en armonía como una sola aplicación.

El archivo docker-compose.yml es la partitura declarativa. En él defines cada servicio (cada contenedor), su imagen base, los volúmenes para persistir datos (como los archivos de la base de datos), los puertos que se exponen al anfitrión, las variables de entorno para la configuración y, críticamente, las dependencias entre servicios. Por ejemplo, puedes indicar que el servicio "api" depende del servicio "db", por lo que Compose se asegurará de que PostgreSQL esté listo y saludable antes de intentar lanzar tu aplicación Go.

Tip: Docker Compose para desarrollo no debe confundirse con orquestadores de producción como Kubernetes. Compose es ideal para desarrollo local, CI/CD y entornos de staging simples. Su simplicidad es su mayor virtud para el flujo de trabajo diario del desarrollador.

Cómo Funciona en la Práctica: Paso a Paso

El proceso comienza con la estructura de tu proyecto. Debes organizar tu código de Go y los archivos de Docker de manera coherente. Una estructura típica podría ser: un directorio para el código de la API, tu Dockerfile para construir la imagen de Go, y el archivo docker-compose.yml en la raíz del proyecto. El primer paso es asegurarte de que tu Dockerfile para Go sea eficiente, utilizando multi-stage builds para crear un binario ligero.

A continuación, defines los servicios en el docker-compose.yml. Crearás un servicio para la base de datos, utilizando la imagen oficial de PostgreSQL, definiendo variables de entorno para la contraseña, usuario y nombre de la base de datos. Configurarás un volumen para que los datos persistan incluso si el contenedor se elimina. Luego, definirás el servicio para tu API de Go. Este servicio se construirá a partir del Dockerfile, tendrá una dependencia declarada hacia el servicio de base de datos, y compartirá una red interna para que puedan comunicarse usando el nombre del servicio como hostname (ej: db).

Finalmente, ejecutas docker-compose up --build. Compose leerá el archivo YAML, creará una red virtual aislada, iniciará el contenedor de PostgreSQL, esperará a que esté listo (según los checks de salud que definas), y luego construirá la imagen de tu API Go y lanzará el contenedor. Tu aplicación Go, al iniciar, leerá las variables de entorno (como DB_HOST=db) y se conectará sin problemas. Puedes ver los logs de todos los servicios intercalados en tu terminal, lo que facilita la depuración.

Código en Acción: Configuración Completa

A continuación, se presenta un ejemplo completo y funcional de un entorno multi-servicio para una API Go con PostgreSQL. Este es un ejemplo real que puedes adaptar.

1. Dockerfile para el Microservicio Go

# Etapa de construcción
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api

# Etapa final mínima
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
COPY --from=builder /app/.env .  # Si usas un archivo .env para config local (no para secrets en prod)
EXPOSE 8080
CMD ["./main"]

2. Archivo docker-compose.yml Definitivo

version: '3.8'

services:
  # Servicio de Base de Datos PostgreSQL
  postgres:
    image: postgres:15-alpine
    container_name: go-api-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: apiuser
      POSTGRES_PASSWORD: unacontraseñasegura123
      POSTGRES_DB: apidb
    ports:
      - "5432:5432"  # Expone el puerto de PostgreSQL al host para herramientas como pgAdmin
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # Script de inicialización opcional
    networks:
      - go-network
    healthcheck:  # Check de salud crucial para las dependencias
      test: ["CMD-SHELL", "pg_isready -U apiuser -d apidb"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Servicio de la API en Go
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: go-api-service
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy  # Espera a que la DB pase el healthcheck
    environment:
      DB_HOST: postgres  # ¡Nota! Usamos el nombre del servicio como hostname
      DB_PORT: 5432
      DB_USER: apiuser
      DB_PASSWORD: unacontraseñasegura123
      DB_NAME: apidb
      API_PORT: "8080"
    ports:
      - "8080:8080"
    volumes:
      - ./:/app  # Montar el código para desarrollo en caliente (opcional, ver errores comunes)
    networks:
      - go-network
    # command: ./main  # El CMD del Dockerfile ya lo hace

# Definición de volúmenes y redes
volumes:
  postgres_data:  # Volumen con nombre para persistencia de datos de PostgreSQL

networks:
  go-network:
    driver: bridge

3. Código Go de Ejemplo (main.go) con Conexión Configurada

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
)

var db *sql.DB

func main() {
    // Inicializar conexión a la base de datos desde variables de entorno
    connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
        os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
        os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))

    var err error
    db, err = sql.Open("postgres", connStr)
    if err != nil {
        log.Fatal("Error al abrir conexión a DB:", err)
    }
    defer db.Close()

    // Configurar pool de conexiones
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(5 * time.Minute)

    // Verificar la conexión
    if err := db.Ping(); err != nil {
        log.Fatal("Error al hacer ping a la DB:", err)
    }
    log.Println("✅ Conectado a PostgreSQL correctamente")

    // Configurar enrutador
    r := mux.NewRouter()
    r.HandleFunc("/health", healthHandler).Methods("GET")
    r.HandleFunc("/users", getUsersHandler).Methods("GET")
    // ... más rutas

    port := os.Getenv("API_PORT")
    if port == "" {
        port = "8080"
    }
    log.Printf("🚀 Servidor iniciado en el puerto %s", port)
    log.Fatal(http.ListenAndServe(":"+port, r))
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    // Verificar salud de la base de datos como parte del health check
    if err := db.Ping(); err != nil {
        http.Error(w, "Database connection failed", http.StatusServiceUnavailable)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status": "healthy", "service": "go-api"}`))
}

func getUsersHandler(w http.ResponseWriter, r *http.Request) {
    rows, err := db.Query("SELECT id, name, email FROM users LIMIT 10")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
    // ... procesar filas y devolver JSON
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`[{"id":1,"name":"Ejemplo"}]`))
}

Errores Comunes y Cómo Evitarlos

1. No Usar Health Checks en las Dependencias: Definir depends_on sin condition: service_healthy solo garantiza que el contenedor se haya iniciado, no que el servicio dentro esté listo. Esto puede causar que tu API falle al intentar conectarse a una base de datos que aún está inicializándose. Solución: Siempre implementa y utiliza healthchecks robustos en servicios como bases de datos.

2. Montar Volúmenes del Código en Producción (o para Builds): En el ejemplo, la línea volumes: - ./:/app bajo el servicio `api` es útil para desarrollo (hot-reload), pero NUNCA debe usarse en producción. Además, si montas el código local durante el build, el contexto de construcción puede verse afectado. Solución: Usa volúmenes de código solo en un archivo docker-compose.override.yml para desarrollo, y mantén el docker-compose.yml base limpio para staging/producción.

3. Hardcodear Credenciales en el Archivo Compose: Colocar contraseñas directamente en el YAML es un riesgo de seguridad, especialmente si el archivo se sube a un repositorio. Solución: Utiliza variables de entorno externas a través de un archivo .env (que está en .gitignore) y referéncialas en el compose con la sintaxis ${VARIABLE}.

4. Confusión de Redes y Nombres de Host: Intentar conectar desde Go a localhost:5432 en lugar de al nombre del servicio (postgres:5432). Desde dentro de la red de Docker, los servicios se comunican usando sus nombres de servicio como hostname. Solución: Asegúrate de que la variable de entorno DB_HOST en tu servicio `api` sea exactamente el nombre del servicio de base de datos (en nuestro caso, "postgres").

5. No Gestionar la Persistencia de Datos: Si no defines un volumen con nombre (como postgres_data) para tu base de datos, todos los datos se perderán al detener y eliminar los contenedores con docker-compose down. Solución: Siempre define volúmenes con nombre para cualquier dato que deba persistir más allá del ciclo de vida del contenedor.

Checklist de Dominio

Antes de considerar esta lección completa, verifica que puedes realizar y comprendes cada uno de los siguientes puntos:

  • Puedo explicar la diferencia entre ejecutar contenedores individuales con docker run y orquestarlos con docker-compose up.
  • He creado y entendido cada sección de un archivo docker-compose.yml funcional que define al menos dos servicios (Go y una base de datos).
  • Sé cómo configurar correctamente las variables de entorno en el compose y cómo mi aplicación Go las lee para configurar la conexión a la base de datos.
  • He implementado un healthcheck para el servicio de base de datos y he usado condition: service_healthy en la dependencia de mi API.
  • Comprendo el concepto de redes en Docker Compose y puedo explicar por qué mi servicio Go se conecta a "postgres" y no a "localhost".
  • He utilizado un volumen con nombre para persistir los datos de PostgreSQL y sé cómo limpiarlos con docker-compose down -v.
  • Puedo construir y reiniciar servicios específicos (ej: docker-compose up --build api) sin afectar a la base de datos en ejecución.
  • Sé cómo ver los logs de todos los servicios combinados (docker-compose logs -f) y los de un servicio en particular.
De lección a portfolio

Convertí esta lección en una habilidad visible para entrevistas.

Guardá el curso, completá los ejercicios y conectá esta habilidad con una ruta de empleo, data, IA, programación o marketing.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión