Contenedores de Alto Rendimiento: Go, Astro y React en Producción

Contenedores de Alto Rendimiento: Go, Astro y React en Producción

Optimiza contenedores Docker para Go, Astro y React: imágenes mínimas, tuning de recursos, perfiles de rendimiento y deploy con zero-downtime real.

Por Omar Flores

Un auto de carreras y un camión de reparto pueden llevar el mismo motor. Lo que determina el rendimiento en pista no es solo la potencia — es el peso del chasis, el perfil aerodinámico, la calibración del motor para las condiciones del circuito. Metes el mismo motor en un camión de ocho toneladas y no llegas a ningún lado rápido.

Los contenedores funcionan igual. Puedes empaquetar una API de Go o un frontend de Astro en un contenedor y que funcione. Pero si no optimizas la imagen, los límites de recursos, la configuración del runtime y el proceso de deploy, estás poniendo un motor de carreras en un camión.

Este post no es sobre cómo usar Docker. Es sobre cómo hacer que tus contenedores operen al máximo de lo que el software dentro puede dar.

El problema del contenedor gordo

La mayoría de los equipos construyen contenedores que funcionan, no contenedores que rinden. La diferencia se ve en tres números: tamaño de imagen, tiempo de arranque, y consumo de memoria en estado estable.

Una imagen de Go construida sin optimización puede pesar 800MB. La misma aplicación, optimizada, pesa 12MB. El tiempo de pull en un deploy de emergencia a las 2am es 4 segundos versus 3 minutos. El tiempo de arranque del contenedor es 200ms versus 800ms. Con 10 réplicas en un cluster, eso es 8 segundos de capacidad reducida versus 30.

Estos números no son teóricos. Son la diferencia entre un incidente que se resuelve solo y uno que despierta a medio equipo.

Go: el lenguaje que fue diseñado para esto

Go tiene una ventaja estructural sobre otros lenguajes en el contexto de contenedores: compila a un binario estático sin dependencias de runtime. No necesita un intérprete, no necesita una JVM, no necesita librerías compartidas del sistema. El binario es la aplicación completa.

Esto significa que la imagen de producción de una API en Go puede ser scratch — la imagen vacía de Docker. Literalmente nada, solo el binario.

El Dockerfile mínimo para Go

# ── Etapa 1: compilación ─────────────────────────────────────────────────────
FROM golang:1.23-alpine AS builder

WORKDIR /app

# Descargar dependencias primero — capa cacheada hasta que go.mod cambie
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO_ENABLED=0: binario completamente estático, sin libc
# GOOS=linux: compilar para Linux aunque el builder corra en Mac/Windows
# -ldflags="-s -w": eliminar tabla de símbolos y debug info — reduce ~30% el tamaño
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o /app/server \
    ./cmd/server


# ── Etapa 2: producción ──────────────────────────────────────────────────────
FROM scratch AS production

# scratch no tiene ni /etc/passwd — copiar los certificados TLS y el usuario
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd

# Solo el binario compilado
COPY --from=builder /app/server /server

# Usuario no-root definido en /etc/passwd del builder
USER nobody

EXPOSE 8080
ENTRYPOINT ["/server"]

El resultado es una imagen de 8-15MB dependiendo del tamaño de la aplicación. Sin shell, sin herramientas de sistema, sin superficie de ataque. Si alguien compromete la aplicación, no hay nada más que ejecutar dentro del contenedor.

-ldflags="-s -w" elimina la tabla de símbolos de debug (-s) y la información de DWARF para debuggers (-w). No afecta el funcionamiento en producción — solo el tamaño del binario. En aplicaciones medianas, la reducción es de 30-40%.

Cuando scratch no es suficiente

scratch no tiene shell ni utilidades básicas. Si tu aplicación necesita leer zonas horarias, ejecutar comandos del sistema, o usar CGO para librerías C, necesitas una base diferente. distroless de Google es el siguiente escalón:

FROM gcr.io/distroless/static-debian12 AS production

COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

distroless/static pesa ~2MB, incluye certificados TLS y zonas horarias, tiene usuario nonroot predefinido, pero no tiene shell ni gestores de paquetes. Si necesitas CGO, usa distroless/base (~20MB) que incluye glibc.

Tuning del runtime de Go para contenedores

Go tiene su propio garbage collector y su propio scheduler de goroutines. Ambos toman decisiones basadas en el hardware disponible. En un contenedor con límites de CPU, el comportamiento por defecto puede no ser óptimo.

ENV GOMAXPROCS=""        # Vacío = auto-detect de cores disponibles (Go 1.21+)
ENV GOGC=100             # GC cuando el heap crece 100% — default razonable
ENV GOMEMLIMIT=450MiB    # Límite suave de memoria para el GC (Go 1.19+)

GOMEMLIMIT es la variable más importante para contenedores. Sin ella, el GC de Go no sabe que el contenedor tiene un límite de memoria configurado en Docker. El proceso puede crecer hasta que el OOM killer del kernel lo termina abruptamente. Con GOMEMLIMIT configurado a ~90% del límite del contenedor, el GC se vuelve más agresivo antes de acercarse al límite, evitando los OOM kills.

# docker-compose.yml — límites de recursos alineados con GOMEMLIMIT
services:
  api:
    image: mi-api-go:latest
    environment:
      - GOMEMLIMIT=450MiB      # 90% del límite del contenedor
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

reservations garantiza recursos mínimos cuando el host está bajo presión. limits pone el techo absoluto. La API de Go arranca con 128MB garantizados y puede crecer hasta 512MB — el GC se activará agresivamente antes de llegar ahí.

Profiling en producción sin overhead

Go tiene profiling continuo integrado. Activarlo en producción con el endpoint pprof permite capturar perfiles reales de tráfico real — sin reproducir el problema en local.

import (
    "net/http"
    _ "net/http/pprof"   // El import side-effect registra los handlers
)

// Servidor de pprof en puerto separado — NUNCA exponer al público
go func() {
    http.ListenAndServe("127.0.0.1:6060", nil)
}()

Desde fuera del contenedor, un túnel temporal da acceso al perfil:

# Tunnel al puerto de pprof del contenedor
docker exec -it mi-api sh -c "wget -O- http://localhost:6060/debug/pprof/heap" > heap.prof

# Analizar con pprof
go tool pprof heap.prof
(pprof) top10
(pprof) web    # Genera un gráfico SVG del flamegraph

El flamegraph muestra exactamente qué funciones consumen más memoria o CPU en tráfico real. Sin esto, optimizar Go es adivinar.

Astro: el frontend que no necesita servidor

Astro genera HTML estático por defecto. Una build de Astro produce un directorio dist/ con archivos que cualquier servidor puede servir sin ningún proceso corriendo. En un contenedor, eso significa Nginx con una imagen de 25MB sirviendo miles de usuarios simultáneos.

Dockerfile para Astro

# ── Etapa 1: build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .

# Variables de build que se incrustan en el HTML/JS estático
ARG PUBLIC_API_URL=/api
ENV PUBLIC_API_URL=$PUBLIC_API_URL

RUN npm run build
# /app/dist contiene el sitio estático completo


# ── Etapa 2: producción ──────────────────────────────────────────────────────
FROM nginx:alpine AS production

# Configuración optimizada para Astro
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Assets con sus rutas de hash — Astro genera nombres como _astro/index.DkxR3y8Q.js
COPY --from=builder /app/dist /usr/share/nginx/html

HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget -qO- http://localhost/health || exit 1

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf optimizado para sitio Astro
server {
    listen 80;
    root /usr/share/nginx/html;

    # Astro coloca todos los assets en /_astro/ con hashes en el nombre
    # Cacheable indefinidamente — si el contenido cambia, el hash cambia
    location /_astro/ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        gzip_static on;
        access_log off;
    }

    location ~* \.(jpg|jpeg|png|webp|avif|svg|ico|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
        access_log off;
    }

    # Páginas HTML — nunca cachear, siempre validar
    location / {
        try_files $uri $uri/ $uri.html =404;
        add_header Cache-Control "no-cache, must-revalidate";
    }

    location /health {
        access_log off;
        return 200 "ok\n";
        add_header Content-Type text/plain;
    }

    gzip on;
    gzip_comp_level 6;
    gzip_types text/html text/css application/javascript application/json image/svg+xml;
}

try_files $uri $uri/ $uri.html =404 es la configuración correcta para Astro con rutas estáticas. Astro genera about/index.html para la ruta /about, pero también puede generar about.html. Esta directiva maneja ambos patrones. Para Astro con SSR o adaptadores, la configuración cambia — el contenedor ya no puede ser solo Nginx.

Astro con SSR: Node.js en el contenedor

Si usas el adaptador @astrojs/node para SSR, necesitas un proceso Node corriendo:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY . .
RUN npm run build
# Con el adaptador node, dist/server/ contiene el servidor


FROM node:20-alpine AS production
RUN addgroup -S astro && adduser -S astrouser -G astro
WORKDIR /app

# Solo las dependencias de producción
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile --omit=dev

COPY --from=builder --chown=astrouser:astro /app/dist ./dist
USER astrouser

HEALTHCHECK --interval=30s --timeout=5s \
    CMD wget -qO- http://localhost:4321/health || exit 1

EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

La imagen SSR pesa más (~120MB con Node.js) pero sigue siendo considerablemente más pequeña que una imagen sin optimizar (~600MB). La diferencia clave versus el sitio estático es que ahora hay un proceso Node corriendo — y sus límites de memoria necesitan configurarse.

React: el SPA que necesita servidor

Una aplicación React es un sitio estático desde la perspectiva del servidor — Vite o Create React App genera HTML + JS + CSS en dist/. El mismo patrón que Astro estático aplica: Nginx sirve los archivos, no hay proceso de aplicación en producción.

La diferencia técnica que más confunde a los equipos: React necesita que todas las rutas sirvan index.html. React Router maneja las rutas en el cliente. Si el usuario navega directamente a /dashboard, el servidor debe entregar index.html para que React pueda manejar esa ruta.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY . .

# Variables de entorno de build — quedan incrustadas en el bundle
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build


FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget -qO- http://localhost/health || exit 1

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
server {
    listen 80;
    root /usr/share/nginx/html;

    # Assets con hash de Vite — inmutables
    location ~* \.(js|css)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        gzip_static on;
        access_log off;
    }

    # SPA fallback — React Router maneja las rutas
    location / {
        try_files $uri /index.html;
        add_header Cache-Control "no-cache, must-revalidate";
    }

    location /health {
        access_log off;
        return 200 "ok\n";
    }

    gzip on;
    gzip_comp_level 6;
    gzip_types text/html text/css application/javascript application/json;
}

La diferencia con Astro: try_files $uri /index.html en lugar de try_files $uri $uri/ $uri.html =404. React necesita que cualquier ruta desconocida sirva index.html. Astro estático sirve el 404 real si la página no existe.

El problema de las variables de entorno en React/Vite

Las variables VITE_* se incrustan en el bundle en tiempo de build. Esto crea una restricción importante: la misma imagen no puede servir a staging y producción si usan URLs de API distintas.

La solución sin recompilar: generar config.js en tiempo de ejecución antes de que Nginx arranque.

#!/bin/sh
# docker-entrypoint.d/10-inject-config.sh
# Se ejecuta automáticamente antes de que nginx inicie

cat > /usr/share/nginx/html/config.js << EOF
window.__APP_CONFIG__ = {
  apiUrl: "${API_URL:-/api}",
  environment: "${APP_ENV:-production}",
  version: "${APP_VERSION:-unknown}"
};
EOF

echo "Config injected: API_URL=${API_URL}"
<!-- index.html — antes del bundle principal -->
<script src="/config.js"></script>
// src/config.ts
declare global {
  interface Window {
    __APP_CONFIG__?: { apiUrl: string; environment: string; version: string };
  }
}

export const config = {
  apiUrl: window.__APP_CONFIG__?.apiUrl ?? import.meta.env.VITE_API_URL ?? '/api',
  environment: window.__APP_CONFIG__?.environment ?? 'development',
};

Ahora la misma imagen Docker sirve a cualquier ambiente. Solo cambian las variables de entorno que le pasas al contenedor:

services:
  frontend:
    image: mi-react-app:${IMAGE_TAG}
    environment:
      - API_URL=https://api.produccion.com
      - APP_ENV=production
      - APP_VERSION=${IMAGE_TAG}

El stack completo: Go API + Astro frontend

Con los tres servicios optimizados, el docker-compose.yml de producción combina todo:

# docker-compose.yml
services:

  frontend:
    image: ghcr.io/usuario/mi-frontend:${IMAGE_TAG:-latest}
    restart: unless-stopped
    networks:
      - public_net
    deploy:
      resources:
        limits:
          cpus: '0.25'
          memory: 64M      # Nginx es muy eficiente en memoria
        reservations:
          memory: 32M
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 30s
      timeout: 3s
      retries: 3

  api:
    image: ghcr.io/usuario/mi-api-go:${IMAGE_TAG:-latest}
    restart: unless-stopped
    environment:
      - PORT=8080
      - DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/appdb?sslmode=disable
      - GOMEMLIMIT=450MiB
    networks:
      - public_net
      - db_net
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 15s
      timeout: 5s
      start_period: 10s
      retries: 3

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_DB=appdb
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - db_net
    deploy:
      resources:
        limits:
          memory: 512M
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    command: >
      postgres
      -c shared_buffers=128MB
      -c max_connections=50
      -c work_mem=4MB

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
    networks:
      - public_net
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 128M
    depends_on:
      frontend:
        condition: service_healthy
      api:
        condition: service_healthy

networks:
  public_net:   # Nginx, frontend, API
  db_net:       # API, DB

volumes:
  postgres_data:

El Nginx del gateway enruta el tráfico por path: /api/* va a la API de Go, todo lo demás va al frontend:

# nginx/nginx.conf — gateway de producción

upstream api {
    server api:8080;
    keepalive 32;
}

upstream frontend {
    server frontend:80;
    keepalive 16;
}

server {
    listen 443 ssl http2;
    server_name mi-app.com;

    ssl_certificate     /etc/letsencrypt/live/mi-app.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mi-app.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;

    # Timeouts para la API de Go — operaciones largas
    location /api/ {
        proxy_pass         http://api;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 30s;

        # No cachear respuestas de la API por defecto
        add_header Cache-Control "no-store";
    }

    # Frontend — Nginx interno ya maneja su propio caché
    location / {
        proxy_pass http://frontend;
        proxy_set_header Host $host;
    }
}

Deploy sin tiempo de inactividad

Un deploy bien hecho actualiza los contenedores sin que ningún usuario vea un error. La estrategia depende de cuántas réplicas tienes.

Con una sola instancia, el mínimo viable es un rolling restart con health check:

#!/bin/bash
# deploy.sh

set -e

IMAGE_TAG=$1
COMPOSE_FILE="docker-compose.yml"

echo "Pulling new images..."
IMAGE_TAG=$IMAGE_TAG docker-compose pull api frontend

echo "Updating API..."
IMAGE_TAG=$IMAGE_TAG docker-compose up -d --no-deps api

echo "Waiting for API health..."
timeout 60 bash -c 'until docker-compose exec -T api wget -qO- http://localhost:8080/health; do sleep 2; done'

echo "Updating frontend..."
IMAGE_TAG=$IMAGE_TAG docker-compose up -d --no-deps frontend

echo "Updating gateway..."
docker-compose exec nginx nginx -s reload

echo "Cleaning old images..."
docker image prune -f

echo "Deploy complete: $IMAGE_TAG"

--no-deps actualiza solo el servicio especificado sin reiniciar sus dependencias. La API se actualiza primero porque el frontend puede seguir sirviendo assets aunque la API esté en transición — los usuarios verán errores temporales de API pero no una página rota. Luego el frontend. Luego un nginx -s reload que recarga la configuración del gateway sin interrumpir conexiones activas.

GitHub Actions: el pipeline completo

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.version }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_PREFIX }}/mi-api,${{ env.IMAGE_PREFIX }}/mi-frontend
          tags: type=sha,prefix=,format=short

      # Build en paralelo — API y frontend son independientes
      - name: Build and push API (Go)
        uses: docker/build-push-action@v5
        with:
          context: ./api
          target: production
          push: true
          tags: ${{ env.IMAGE_PREFIX }}/mi-api:${{ steps.meta.outputs.version }},${{ env.IMAGE_PREFIX }}/mi-api:latest
          cache-from: type=gha,scope=api
          cache-to: type=gha,scope=api,mode=max

      - name: Build and push Frontend (Astro/React)
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          target: production
          push: true
          tags: ${{ env.IMAGE_PREFIX }}/mi-frontend:${{ steps.meta.outputs.version }},${{ env.IMAGE_PREFIX }}/mi-frontend:latest
          cache-from: type=gha,scope=frontend
          cache-to: type=gha,scope=frontend,mode=max
          build-args: |
            PUBLIC_API_URL=/api

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/mi-app
            ./deploy.sh ${{ needs.build.outputs.image-tag }}

Los dos build-push-action corren en paralelo dentro del mismo job — GitHub Actions ejecuta steps secuencialmente, pero con cache-from separado por scope (scope=api, scope=frontend), cada build usa su propio caché sin interferir. Para paralelismo real entre builds independientes, usa jobs separados con needs: [].

Medir para optimizar

No hay optimización sin medición. Las tres métricas que importan en producción:

# Tamaño de imagen — ejecutar después de cada build
docker images mi-api:latest --format "{{.Size}}"

# Tiempo de arranque — desde docker start hasta primer health check OK
time docker run --rm --name test-api -p 8080:8080 mi-api:latest &
while ! curl -s http://localhost:8080/health; do sleep 0.1; done
docker stop test-api

# Uso de memoria en estado estable — después de carga inicial
docker stats mi-api --no-stream --format "{{.MemUsage}}"

Para la API de Go, un benchmark bajo carga real con hey o wrk revela si los límites de recursos están bien calibrados:

# 100 usuarios concurrentes, 10,000 peticiones totales
hey -n 10000 -c 100 http://localhost:8080/api/users

# Si el percentil p99 de latencia es mucho mayor que p50,
# el GC está pausando el mundo — ajustar GOGC o GOMEMLIMIT

Si el p99 de latencia supera 5x el p50 bajo carga sostenida, el GC de Go está haciendo pausas largas. La causa habitual: GOMEMLIMIT no configurado y el heap creciendo sin límite hasta que un GC masivo lo compacta. Configura GOMEMLIMIT al 85-90% del límite del contenedor y el comportamiento se vuelve predecible.

Lo que esto significa en producción real

He visto el mismo patrón repetirse en equipos que llegan a escala: la aplicación es buena, el código es correcto, pero los costos de infraestructura crecen de forma desproporcionada al tráfico. La causa casi siempre es lo mismo — contenedores sobredimensionados, imágenes gordas que tardan en desplegarse, memoria mal configurada que provoca OOM kills silenciosos, deploys que interrumpen usuarios porque nadie configuró el health check.

Una imagen de Go de 12MB versus 800MB no es un logro estético. Es que el equipo puede hacer 40 deploys al día en lugar de 4. Es que una falla en producción se recupera en 8 segundos en lugar de 3 minutos. Es que el servidor que costaba $200 al mes ahora corre 8 réplicas en lugar de 2.

El frontend en Nginx desde un contenedor de 25MB sirviendo miles de usuarios simultáneos es lo mismo. La lógica de negocio no cambia. El hardware no cambia. Solo la forma en que lo empaquetas.

La optimización no es un sprint que haces una vez antes del lanzamiento. Es la decisión de no cargar peso innecesario desde el primer día.