Escala Symfony con Memcached, múltiples PHP‑FPM y Nginx

Resumen: configuración práctica para escalar una aplicación Symfony en Docker con Nginx como proxy, tres (o más) contenedores PHP‑FPM y Memcached para sesiones compartidas.

El artículo describe la arquitectura, los archivos clave, comandos para levantar el entorno y pruebas para verificar persistencia de sesión entre contenedores.

Introducción

Cuando una aplicación Symfony supera la capacidad de un solo contenedor PHP‑FPM, la solución es ejecutar múltiples instancias de PHP‑FPM y balancearlas con Nginx. Para que las sesiones funcionen entre instancias se requiere un almacén centralizado: Memcached.

Prerrequisitos

Antes de empezar, confirma lo siguiente en tu entorno:

  1. Docker y Docker Compose instalados en la máquina host.
  2. Una aplicación Symfony ubicada en ./src dentro del proyecto.
  3. Conocimientos básicos de Nginx, PHP‑FPM y configuración de servicios en contenedores.

Desarrollo

La arquitectura recomendada mínima incluye: un contenedor Nginx (proxy/load‑balancer), tres contenedores PHP‑FPM idénticos, un contenedor Memcached y un contenedor PostgreSQL. Nginx distribuye peticiones entre los PHP‑FPM y Memcached almacena sesiones.

project/
├── docker/
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── default.conf
│   ├── php-fpm/
│   │   ├── Dockerfile
│   │   └── php.ini
│   └── memcached/
│       └── memcached.conf
├── src/  (tu aplicación Symfony)
├── docker-compose.yml
└── .env
Lenguaje del código: texto plano (plaintext)

Procedimiento

Los pasos esenciales: definir docker-compose, configurar Nginx upstream, preparar la imagen PHP‑FPM con memcached y opcache, ajustar php.ini y la pool de PHP‑FPM, configurar Symfony para usar Memcached como handler de sesión y levantar el conjunto.

version: '3.8'

services:
  nginx:
    build:
      context: ./docker/nginx
      dockerfile: Dockerfile
    ports:
      - "80:80"
    volumes:
      - ./src:/var/www/symfony
      - php-fpm-sockets1:/var/run/sock/fpm1
      - php-fpm-sockets2:/var/run/sock/fpm2
      - php-fpm-sockets3:/var/run/sock/fpm3
    depends_on:
      - php-fpm-1
      - php-fpm-2
      - php-fpm-3
    networks:
      - symfony-network

  php-fpm-1:
    build:
      context: ./docker/php-fpm
      dockerfile: Dockerfile
    volumes:
      - ./src:/var/www/symfony
      - php-fpm-sockets1:/var/run/sock:rw
    environment:
      - APP_ENV=prod
      - DATABASE_URL=postgresql://symfony:secret@postgres:5432/symfony_db
      - MEMCACHED_HOST=memcached
      - MEMCACHED_PORT=11211
    depends_on:
      - memcached
      - postgres
    networks:
      - symfony-network

  php-fpm-2:
    build:
      context: ./docker/php-fpm
      dockerfile: Dockerfile
    volumes:
      - ./src:/var/www/symfony
      - php-fpm-sockets2:/var/run/sock:rw
    environment:
      - APP_ENV=prod
      - DATABASE_URL=postgresql://symfony:secret@postgres:5432/symfony_db
      - MEMCACHED_HOST=memcached
      - MEMCACHED_PORT=11211
    depends_on:
      - memcached
      - postgres
    networks:
      - symfony-network

  php-fpm-3:
    build:
      context: ./docker/php-fpm
      dockerfile: Dockerfile
    volumes:
      - ./src:/var/www/symfony
      - php-fpm-sockets3:/var/run/sock:rw
    environment:
      - APP_ENV=prod
      - DATABASE_URL=postgresql://symfony:secret@postgres:5432/symfony_db
      - MEMCACHED_HOST=memcached
      - MEMCACHED_PORT=11211
    depends_on:
      - memcached
      - postgres
    networks:
      - symfony-network

  memcached:
    image: memcached:1.6-alpine
    ports:
      - "11211:11211"
    command: memcached -m 256
    networks:
      - symfony-network

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=symfony_db
      - POSTGRES_USER=symfony
      - POSTGRES_PASSWORD=secret
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - symfony-network

networks:
  symfony-network:
    driver: bridge

volumes:
  postgres-data:
  php-fpm-sockets1:
  php-fpm-sockets2:
  php-fpm-sockets3:
Lenguaje del código: YAML (yaml)
FROM nginx:1.25-alpine

COPY default.conf /etc/nginx/conf.d/default.conf
RUN mkdir -p /var/www/symfony
WORKDIR /var/www/symfony
EXPOSE 80
Lenguaje del código: Dockerfile (dockerfile)
upstream php_fpm_backend {
    least_conn;
    server unix:/var/run/sock/fpm1/php-fpm.sock weight=1 max_fails=3 fail_timeout=30s;
    server unix:/var/run/sock/fpm2/php-fpm.sock weight=1 max_fails=3 fail_timeout=30s;
    server unix:/var/run/sock/fpm3/php-fpm.sock weight=1 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;
    server_name localhost;
    root /var/www/symfony/public;
    index index.php;
    client_max_body_size 20M;
    client_body_buffer_size 128k;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass php_fpm_backend;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;

        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;

        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;
        fastcgi_temp_file_write_size 256k;

        fastcgi_read_timeout 300;
        fastcgi_send_timeout 300;

        fastcgi_intercept_errors off;
        internal;
    }

    location ~ \.php$ {
        return 404;
    }

    error_log /var/log/nginx/symfony_error.log;
    access_log /var/log/nginx/symfony_access.log;
}
Lenguaje del código: Nginx (nginx)
FROM php:8.2-fpm-alpine

RUN apk add --no-cache \
    postgresql-dev \
    libzip-dev \
    libmemcached-dev \
    zlib-dev \
    cyrus-sasl-dev \
    git \
    unzip
RUN docker-php-ext-install \
    pdo \
    pdo_pgsql \
    opcache \
    zip
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install memcached-3.2.0 \
    && docker-php-ext-enable memcached \
    && apk del .build-deps
COPY php.ini /usr/local/etc/php/conf.d/custom.ini
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
WORKDIR /var/www/symfony
RUN addgroup -g 1000 symfony && adduser -D -u 1000 -G symfony symfony
RUN chown -R symfony:symfony /var/www/symfony
USER symfony
EXPOSE 9000
CMD ["php-fpm"]
Lenguaje del código: Dockerfile (dockerfile)
[PHP]
memory_limit = 512M
upload_max_filesize = 20M
post_max_size = 20M
max_execution_time = 300
max_input_time = 300

[opcache]
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.revalidate_freq = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1

[Session]
session.save_handler = memcached
session.save_path = "memcached:11211"
session.gc_maxlifetime = 3600

[Date]
date.timezone = Europe/Paris
Lenguaje del código: TOML, también INI (ini)
[www]
user = symfony
group = symfony
listen = 9000
listen.owner = symfony
listen.group = symfony

pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 10
pm.max_spare_servers = 20
pm.max_requests = 500
pm.status_path = /fpm-status
ping.path = /fpm-ping
request_terminate_timeout = 300
request_slowlog_timeout = 10
slowlog = /var/log/php-fpm/slow.log
catch_workers_output = yes
decorate_workers_output = no
php_admin_value[error_log] = /var/log/php-fpm/error.log
php_admin_flag[log_errors] = on
Lenguaje del código: TOML, también INI (ini)
framework:
    secret: '%env(APP_SECRET)%'
    session:
        handler_id: session.handler.memcached
        cookie_secure: auto
        cookie_samesite: lax
        save_path: '%env(MEMCACHED_HOST)%:%env(MEMCACHED_PORT)%'
        gc_maxlifetime: 3600

services:
    session.handler.memcached:
        class: Symfony\Component\HttpFoundation\Session\Storage\Handler\MemcachedSessionHandler
        arguments:
            - '@memcached.connection'
    
    memcached.connection:
        class: Memcached
        calls:
            - method: addServer
              arguments:
                  - '%env(MEMCACHED_HOST)%'
                  - '%env(int:MEMCACHED_PORT)%'
            - method: setOption
              arguments:
                  - !php/const Memcached::OPT_PREFIX_KEY
                  - 'symfony_'
            - method: setOption
              arguments:
                  - !php/const Memcached::OPT_BINARY_PROTOCOL
                  - true
Lenguaje del código: YAML (yaml)
docker-compose build
docker-compose up -d
docker-compose ps

docker-compose exec php-fpm-1 composer install
docker-compose exec php-fpm-1 php bin/console doctrine:migrations:migrate --no-interaction
Lenguaje del código: Bash (bash)

Ejemplos

Controlador de ejemplo para verificar persistencia de sesión y cuál contenedor atiende la petición.

<?php

namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class TestController extends AbstractController
{
    #[Route('/test/session', name: 'test_session')]
    public function testSession(Request $request): Response
    {
        $session = $request->getSession();
        
        $counter = $session->get('counter', 0);
        $counter++;
        $session->set('counter', $counter);
        
        $hostname = gethostname();
        
        return $this->json([
            'container' => $hostname,
            'session_id' => $session->getId(),
            'counter' => $counter,
            'timestamp' => time()
        ]);
    }
}
Lenguaje del código: PHP (php)

Prueba desde la terminal usando cookies para mantener la sesión entre llamadas:

curl -c cookies.txt -b cookies.txt http://localhost/test/session
curl -c cookies.txt -b cookies.txt http://localhost/test/session
curl -c cookies.txt -b cookies.txt http://localhost/test/session
Lenguaje del código: Bash (bash)

Checklist

  • Verificar que todos los PHP‑FPM apunten al mismo Memcached (host/puerto).
  • Asegurar permisos de lectura/escritura sobre sockets compartidos si se usan sockets Unix.
  • Configurar health checks (fpm-ping, nginx stub_status) y revisar logs.
  • Ajustar pm.max_children según RAM disponible y uso por worker.
  • Usar .env o secretos para credenciales y no versionarlas.

Conclusión

Con Nginx como balanceador, múltiples contenedores PHP‑FPM y Memcached para sesiones puedes escalar horizontalmente una aplicación Symfony sin cambios en el código de sesión.

Comienza con tres contenedores PHP‑FPM, monitoriza CPU/memoria y ajusta pm.* o añade contenedores según la demanda. Implementa health checks y protege credenciales en producción.

Comments

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *