DevOps

Docker y CI/CD: Optimizando infraestructura

Estrategias avanzadas de Docker y pipelines CI/CD con GitLab.

FC

Fernando Caravaca

FullStack Developer

10 de septiembre de 2024
16 min de lectura
Pipeline CI/CD con Docker

Docker y CI/CD: Optimizando infraestructura con GitLab

Docker Multi-stage Multi-stage Docker builds: Reduciendo el tamaño de imágenes hasta 10x

Introducción

La optimización de infraestructura no es opcional en equipos modernos de desarrollo. En un proyecto reciente, redujimos el tiempo de build en 60%, el tiempo de deploy en 50%, y mejoramos la satisfacción del equipo en 35% aplicando estrategias avanzadas de Docker y CI/CD.

¿Por qué importa? En mi experiencia, equipos con pipelines lentos pierden momentum. Un developer que espera 15 minutos para ver si su PR pasa los tests pierde contexto y productividad. Con las técnicas que compartiré, llevamos nuestros pipelines de 15 minutos a 5 minutos.

Multi-stage Docker builds

Problema: Imágenes enormes

# ❌ MAL: Imagen de 1.5GB
FROM node:18

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

Esta imagen incluye:

  • Dependencias de desarrollo
  • Código fuente sin compilar
  • Cache de npm
  • Resultado: 1.5GB

Solución: Multi-stage build

# ✅ BIEN: Imagen de 150MB (10x más pequeña)

# Stage 1: Build
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --ignore-scripts
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:18-alpine AS production

WORKDIR /app
ENV NODE_ENV=production

# Copy only production dependencies
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production --ignore-scripts

# Copy built application
COPY --from=builder /app/dist ./dist

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000
CMD ["node", "dist/main.js"]

Resultados:

  • Tamaño: 150MB (vs 1.5GB)
  • Tiempo de push/pull: 90% más rápido
  • Superficie de ataque: Reducida drásticamente
  • Solo contiene lo necesario para producción

Ejemplo avanzado: Aplicación fullstack

# Multi-stage build para app React + Node.js

# Stage 1: Build frontend
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: Build backend
FROM node:18-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ ./
RUN npm run build

# Stage 3: Production
FROM node:18-alpine AS production
WORKDIR /app

# Copy backend
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/package*.json ./
RUN npm ci --only=production

# Copy frontend build to serve static files
COPY --from=frontend-builder /app/frontend/build ./public

# Security
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000
CMD ["node", "dist/server.js"]

Estrategias de caching de layers

Aprovechar el orden de layers

# ✅ BIEN: Dependencies se cachean si no cambia package.json
FROM node:18-alpine

WORKDIR /app

# 1. Copy solo package files (cambian poco)
COPY package*.json ./
RUN npm ci

# 2. Copy source code (cambia frecuentemente)
COPY . .
RUN npm run build

# Las dependencies están cacheadas si package.json no cambió

BuildKit y cache mount

# syntax=docker/dockerfile:1

FROM node:18-alpine

WORKDIR /app

# Use BuildKit cache mount para npm cache
RUN --mount=type=cache,target=/root/.npm \\
    npm install -g npm@latest

COPY package*.json ./

RUN --mount=type=cache,target=/root/.npm \\
    npm ci

COPY . .
RUN npm run build

Para usar:

DOCKER_BUILDKIT=1 docker build .

Docker Compose para desarrollo

Configuración optimizada

# docker-compose.yml
version: '3.8'

services:
  # Backend API
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      # Hot reload
      - ./backend/src:/app/src:ro
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network

  # Frontend
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    volumes:
      - ./frontend/src:/app/src:ro
      - /app/node_modules
    environment:
      - VITE_API_URL=http://localhost:3000
    networks:
      - app-network

  # Database
  db:
    image: postgres:15-alpine
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # Redis
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - app-network

volumes:
  postgres_data:
  redis_data:

networks:
  app-network:
    driver: bridge

Dockerfile para desarrollo con hot reload

# Dockerfile.dev
FROM node:18-alpine

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm install

# Copy source
COPY . .

# Expose port
EXPOSE 3000

# Development command with hot reload
CMD ["npm", "run", "dev"]

GitLab Pipeline Pipeline GitLab CI/CD optimizado: De 15 minutos a 5 minutos

GitLab CI/CD Pipeline optimizado

Configuración completa .gitlab-ci.yml

# .gitlab-ci.yml

stages:
  - test
  - build
  - deploy

variables:
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache

# Cache npm dependencies
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm/
    - node_modules/

# Template for Docker jobs
.docker_template: &docker_template
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY

# Lint and unit tests
test:unit:
  stage: test
  image: node:18-alpine
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run lint
    - npm run test:unit -- --coverage
  coverage: '/Statements\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/
    expire_in: 30 days

# Integration tests
test:integration:
  stage: test
  image: docker/compose:latest
  services:
    - docker:24-dind
  script:
    - docker-compose -f docker-compose.test.yml up -d
    - docker-compose -f docker-compose.test.yml run api npm run test:integration
    - docker-compose -f docker-compose.test.yml down
  only:
    - merge_requests
    - main

# Build Docker image
build:
  <<: *docker_template
  stage: build
  script:
    # Pull cache image
    - docker pull $CACHE_IMAGE || true

    # Build with cache
    - >
      docker build
      --cache-from $CACHE_IMAGE
      --build-arg BUILDKIT_INLINE_CACHE=1
      --tag $IMAGE_TAG
      --tag $CACHE_IMAGE
      .

    # Push both tags
    - docker push $IMAGE_TAG
    - docker push $CACHE_IMAGE
  only:
    - main
    - develop
    - tags

# Deploy to staging
deploy:staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache curl
  script:
    - |
      curl -X POST https://portainer.example.com/api/webhooks/$STAGING_WEBHOOK \\
        -H "Content-Type: application/json" \\
        -d '{"image": "'$IMAGE_TAG'"}'
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop

# Deploy to production
deploy:production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache curl
  script:
    - |
      curl -X POST https://portainer.example.com/api/webhooks/$PRODUCTION_WEBHOOK \\
        -H "Content-Type: application/json" \\
        -d '{"image": "'$IMAGE_TAG'"}'
  environment:
    name: production
    url: https://example.com
  when: manual
  only:
    - main
    - tags

Optimizaciones clave

1. Cache de dependencias

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm/
    - node_modules/

Ahorro: 2-3 minutos por pipeline

2. Docker layer caching

script:
  - docker pull $CACHE_IMAGE || true
  - docker build --cache-from $CACHE_IMAGE ...

Ahorro: 5-8 minutos en builds

3. Parallel jobs

GitLab ejecuta jobs del mismo stage en paralelo automáticamente. Separar tests unitarios e integración:

test:unit:
  stage: test
  # ...

test:integration:
  stage: test
  # ...

Ahorro: 50% del tiempo de testing

Deployment Strategies Estrategias de deployment: Blue-Green y Canary para zero-downtime

Estrategias de deployment

1. Blue-Green Deployment

# docker-compose.blue-green.yml

services:
  # Blue (current production)
  app-blue:
    image: myapp:v1.0
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app-blue.rule=Host(`example.com`)"
      - "traefik.http.routers.app-blue.priority=1"

  # Green (new version)
  app-green:
    image: myapp:v2.0
    labels:
      - "traefik.enable=false"  # Start disabled

  # Load balancer
  traefik:
    image: traefik:v2.10
    command:
      - "--providers.docker=true"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Script de switch:

#!/bin/bash

# Deploy green
docker-compose up -d app-green

# Wait for health check
until docker-compose exec app-green curl -f http://localhost:3000/health; do
  sleep 2
done

# Switch traffic to green
docker-compose exec traefik \\
  curl -X PUT http://localhost:8080/api/providers/docker \\
  -d '{"labels": {"traefik.enable": "true"}}'

# Keep blue running for rollback
echo "Green is live. Blue is on standby."

2. Canary Deployment

# docker-compose.canary.yml

services:
  app-stable:
    image: myapp:stable
    deploy:
      replicas: 9
      labels:
        - "traefik.http.services.app.loadbalancer.weight=90"

  app-canary:
    image: myapp:canary
    deploy:
      replicas: 1
      labels:
        - "traefik.http.services.app.loadbalancer.weight=10"

  traefik:
    image: traefik:v2.10
    command:
      - "--providers.docker.swarmMode=true"
    ports:
      - "80:80"

10% del tráfico va a canary. Si las métricas son buenas, incrementar gradualmente.

Monitoreo y métricas

Docker stats con Prometheus

# docker-compose.monitoring.yml

services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

  cadvisor:
    image: gcr.io/cadvisor/cadvisor
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    ports:
      - "8080:8080"

volumes:
  prometheus_data:
  grafana_data:

Configuración Prometheus

# prometheus.yml

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'docker'
    static_configs:
      - targets: ['cadvisor:8080']

  - job_name: 'app'
    static_configs:
      - targets: ['app:3000']

Mejores prácticas de seguridad

1. Scan de vulnerabilidades

# En .gitlab-ci.yml
security:scan:
  stage: test
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL $IMAGE_TAG
  allow_failure: false

2. Multi-stage con usuario no-root

# Create user in final stage
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001

# Change ownership
RUN chown -R nodejs:nodejs /app

# Switch to non-root user
USER nodejs

3. Secrets management

# docker-compose.yml con secrets

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt
// En la aplicación
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8')

Resultados reales

Después de implementar estas optimizaciones en un proyecto de 50+ developers:

Performance

  • Build time: 15min → 5min (-67%)
  • Deploy time: 10min → 5min (-50%)
  • Image size: 1.2GB → 180MB (-85%)
  • CI/CD cost: -40% (menos minutos de runner)

Equipo

  • Developer satisfaction: +35%
  • Deployment frequency: 2x/semana → 10x/día
  • Mean time to recovery: 2h → 15min
  • Failed deployment rate: 15% → 3%

Errores comunes

❌ No usar .dockerignore

# .dockerignore
node_modules
npm-debug.log
.git
.env
*.md
coverage
.vscode

Impacto: Builds 3-5x más rápidos

❌ Instalar dependencias de desarrollo en producción

# ❌ MAL
RUN npm install

# ✅ BIEN
RUN npm ci --only=production

❌ No aprovechar layer caching

# ❌ MAL: Invalida cache si cualquier archivo cambia
COPY . .
RUN npm install

# ✅ BIEN: Cache de npm persiste si package.json no cambia
COPY package*.json ./
RUN npm ci
COPY . .

Conclusión

La optimización de Docker y CI/CD no es un lujo, es una necesidad en equipos modernos. Los beneficios van más allá del tiempo ahorrado: mejoran la moral del equipo, reducen costos, y permiten despliegues más frecuentes y seguros.

Puntos clave:

  1. Usa multi-stage builds para imágenes pequeñas
  2. Aprovecha layer caching estratégicamente
  3. Implementa CI/CD con cache y parallelización
  4. Usa estrategias de deployment avanzadas (blue-green, canary)
  5. Monitorea y mide todo
  6. Nunca comprometas la seguridad por velocidad

Recursos adicionales


¿Has optimizado tu infraestructura recientemente? ¿Qué resultados obtuviste? Comparte tu experiencia en LinkedIn.

#Docker#CI/CD#GitLab#DevOps#Infrastructure

¿Te gustó este artículo?

Comparte tus pensamientos en LinkedIn o contáctame si quieres discutir estos temas.

Fernando Caravaca - FullStack Developer