Despliegue de Servidor GraphQL en Linux

GraphQL es un lenguaje de consulta para APIs que permite a los clientes solicitar exactamente los datos que necesitan, eliminando el over-fetching y under-fetching típicos de REST. En producción en Linux, un servidor GraphQL requiere una configuración cuidadosa para manejar queries complejos, suscripciones en tiempo real, caché eficiente y protección contra queries maliciosos. Esta guía cubre el despliegue de Apollo Server como servidor GraphQL en Linux, con configuración de Nginx, suscripciones WebSocket y optimizaciones de seguridad.

Requisitos Previos

  • Ubuntu 20.04+ o Rocky Linux 8+
  • Node.js 18+, npm
  • Nginx instalado
  • PM2 instalado globalmente
  • PostgreSQL o MongoDB (según el proyecto)
# Crear directorio del proyecto
mkdir -p /opt/graphql-server
cd /opt/graphql-server

# Inicializar proyecto Node.js
npm init -y

# Instalar dependencias de Apollo Server
npm install @apollo/server graphql graphql-ws ws
npm install express cors helmet
npm install --save-dev typescript @types/node @types/express

Configurar Apollo Server

// server.js - Servidor GraphQL con Apollo Server 4 y Express
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { ApolloServerPluginDrainHttpServer } = require('@apollo/server/plugin/drainHttpServer');
const http = require('http');
const cors = require('cors');
const helmet = require('helmet');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');

// Importar schema y resolvers
const { typeDefs } = require('./schema');
const { resolvers } = require('./resolvers');

async function startServer() {
    const app = express();
    const httpServer = http.createServer(app);

    // Crear schema ejecutable
    const schema = makeExecutableSchema({ typeDefs, resolvers });

    // Configurar WebSocket para suscripciones
    const wsServer = new WebSocketServer({
        server: httpServer,
        path: '/graphql'
    });

    const serverCleanup = useServer({ schema }, wsServer);

    // Crear instancia de Apollo Server
    const server = new ApolloServer({
        schema,
        plugins: [
            // Apagado gracioso del servidor HTTP
            ApolloServerPluginDrainHttpServer({ httpServer }),
            // Apagado gracioso del servidor WebSocket
            {
                async serverWillStart() {
                    return {
                        async drainServer() {
                            await serverCleanup.dispose();
                        }
                    };
                }
            }
        ],
        // Deshabilitar introspección en producción
        introspection: process.env.NODE_ENV !== 'production',
        // Formatear errores para no exponer detalles internos
        formatError: (formattedError, error) => {
            if (process.env.NODE_ENV === 'production') {
                // No exponer mensajes de error internos
                if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
                    return { message: 'Error interno del servidor' };
                }
            }
            return formattedError;
        }
    });

    await server.start();

    app.use(
        '/graphql',
        cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' }),
        express.json({ limit: '10mb' }),
        expressMiddleware(server, {
            context: async ({ req }) => ({
                // Contexto disponible en todos los resolvers
                user: req.user,  // Añadir autenticación aquí
                dataSources: {}  // Fuentes de datos
            })
        })
    );

    const PORT = process.env.PORT || 4000;
    await new Promise(resolve => httpServer.listen({ port: PORT }, resolve));
    console.log(`GraphQL listo en http://localhost:${PORT}/graphql`);
}

startServer().catch(console.error);

Schema y Resolvers de Producción

// schema.js - Definición del schema GraphQL
const { gql } = require('graphql-tag');

const typeDefs = gql`
    type Query {
        # Obtener lista de usuarios con paginación
        users(page: Int = 1, limit: Int = 10): UserConnection!
        user(id: ID!): User
        
        # Búsqueda de productos
        products(filter: ProductFilter): [Product!]!
    }

    type Mutation {
        createUser(input: CreateUserInput!): User!
        updateUser(id: ID!, input: UpdateUserInput!): User!
        deleteUser(id: ID!): Boolean!
    }

    type Subscription {
        # Actualizaciones en tiempo real
        userCreated: User!
        orderUpdated(userId: ID!): Order!
    }

    type User {
        id: ID!
        name: String!
        email: String!
        createdAt: String!
        orders: [Order!]!  # Campo con resolución lazy
    }

    type UserConnection {
        nodes: [User!]!
        totalCount: Int!
        hasNextPage: Boolean!
    }

    input CreateUserInput {
        name: String!
        email: String!
        password: String!
    }

    input UpdateUserInput {
        name: String
        email: String
    }

    type Order {
        id: ID!
        status: String!
        total: Float!
        userId: ID!
    }

    type Product {
        id: ID!
        name: String!
        price: Float!
        category: String!
    }

    input ProductFilter {
        category: String
        minPrice: Float
        maxPrice: Float
    }
`;

// resolvers.js - Resolvers con DataLoader para evitar el problema N+1
const DataLoader = require('dataloader');

// Batch function para DataLoader - carga muchos usuarios en una sola query
async function batchLoadUsers(ids) {
    // Cargar todos los usuarios de una vez
    const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
    return ids.map(id => users.find(u => u.id === id) || null);
}

const resolvers = {
    Query: {
        users: async (_, { page, limit }, context) => {
            const offset = (page - 1) * limit;
            const [users, count] = await Promise.all([
                db.query('SELECT * FROM users LIMIT $1 OFFSET $2', [limit, offset]),
                db.query('SELECT COUNT(*) FROM users')
            ]);
            return {
                nodes: users.rows,
                totalCount: parseInt(count.rows[0].count),
                hasNextPage: offset + limit < parseInt(count.rows[0].count)
            };
        },
        user: async (_, { id }, context) => {
            return context.loaders.user.load(id);
        }
    },
    
    User: {
        // Resolver lazy para campo 'orders' - se ejecuta solo cuando se solicita
        orders: async (user, _, context) => {
            return context.loaders.ordersByUser.load(user.id);
        }
    },
    
    Mutation: {
        createUser: async (_, { input }, context) => {
            // Validar permisos
            if (!context.user?.isAdmin) throw new Error('No autorizado');
            
            const user = await db.query(
                'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
                [input.name, input.email]
            );
            
            // Publicar evento para suscripciones
            context.pubsub.publish('USER_CREATED', { userCreated: user.rows[0] });
            return user.rows[0];
        }
    },
    
    Subscription: {
        userCreated: {
            subscribe: (_, __, context) => context.pubsub.asyncIterator(['USER_CREATED'])
        }
    }
};

module.exports = { typeDefs, resolvers };

Suscripciones con WebSocket

// Configurar PubSub para suscripciones
// npm install graphql-subscriptions
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();

// Para producción con múltiples instancias, usar Redis PubSub
// npm install @graphql-yoga/redis-event-target ioredis
/*
const Redis = require('ioredis');
const { RedisPubSub } = require('graphql-redis-subscriptions');

const pubsub = new RedisPubSub({
    publisher: new Redis({ host: 'redis-server', port: 6379 }),
    subscriber: new Redis({ host: 'redis-server', port: 6379 })
});
*/

// Pasar pubsub al contexto de todos los resolvers
// En expressMiddleware context:
// context: async ({ req }) => ({ pubsub, ... })

Nginx para GraphQL

cat > /etc/nginx/sites-available/graphql-server << 'EOF'
# Rate limiting para GraphQL
limit_req_zone $binary_remote_addr zone=graphql_limit:10m rate=20r/s;

upstream graphql_app {
    server 127.0.0.1:4000;
    keepalive 32;
}

server {
    listen 80;
    server_name api.midominio.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.midominio.com;

    ssl_certificate /etc/letsencrypt/live/api.midominio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.midominio.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # Endpoint principal de GraphQL
    location /graphql {
        limit_req zone=graphql_limit burst=30 nodelay;

        proxy_pass http://graphql_app;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Soporte WebSocket para suscripciones GraphQL
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Timeout mayor para suscripciones de larga duración
        proxy_read_timeout 3600s;  # 1 hora para suscripciones
        proxy_send_timeout 3600s;

        # No limitar el tamaño del cuerpo para queries grandes
        client_max_body_size 10M;
    }
}
EOF

ln -s /etc/nginx/sites-available/graphql-server /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Caché y Optimización

# Instalar dependencias de caché
npm install @apollo/server-plugin-response-cache keyv @keyv/redis

# DataLoader para resolver el problema N+1
npm install dataloader

# En el contexto de Apollo Server, crear DataLoaders por request
# (NUNCA compartir DataLoaders entre requests - causaría datos incorrectos)
cat >> server.js << 'EOF'

// Función para crear DataLoaders frescos por cada request
function createDataLoaders(db) {
    return {
        user: new DataLoader(async (ids) => {
            const result = await db.query(
                'SELECT * FROM users WHERE id = ANY($1::int[])', [ids]
            );
            return ids.map(id => result.rows.find(u => u.id == id) || null);
        }),
        ordersByUser: new DataLoader(async (userIds) => {
            const result = await db.query(
                'SELECT * FROM orders WHERE user_id = ANY($1::int[])', [userIds]
            );
            return userIds.map(id => result.rows.filter(o => o.user_id == id));
        })
    };
}
EOF

Seguridad en GraphQL

# Instalar herramientas de seguridad para GraphQL
npm install graphql-depth-limit graphql-query-complexity

# Añadir validaciones de seguridad al servidor
cat >> server.js << 'EOF'

const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-query-complexity');

// Añadir reglas de validación al servidor Apollo
const server = new ApolloServer({
    schema,
    validationRules: [
        // Limitar profundidad de queries (previene queries recursivos)
        depthLimit(7),
        
        // Limitar complejidad de queries (previene queries muy costosos)
        createComplexityLimitRule(1000, {
            onCost: (cost) => console.log('Query cost:', cost),
            createError: (max, actual) => {
                return new Error(`Query demasiado complejo: ${actual}. Máximo: ${max}`);
            }
        })
    ]
});

// Ejemplo de middleware de autenticación JWT
app.use('/graphql', (req, res, next) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (token) {
        try {
            req.user = jwt.verify(token, process.env.JWT_SECRET);
        } catch (e) {
            // Token inválido - continuar sin usuario autenticado
        }
    }
    next();
});
EOF

Despliegue con PM2

# Crear configuración de PM2 para GraphQL
cat > /opt/graphql-server/ecosystem.config.js << 'EOF'
module.exports = {
    apps: [{
        name: 'graphql-api',
        script: 'server.js',
        cwd: '/opt/graphql-server',
        
        // GraphQL con suscripciones NO debe usar cluster mode
        // porque los WebSockets son stateful
        // Usar fork mode con una instancia por servidor
        instances: 1,
        exec_mode: 'fork',
        
        // Para múltiples instancias sin suscripciones:
        // instances: 'max',
        // exec_mode: 'cluster',
        
        env_production: {
            NODE_ENV: 'production',
            PORT: 4000
        },
        
        autorestart: true,
        max_memory_restart: '1G',
        
        log_file: '/var/log/graphql/combined.log',
        out_file: '/var/log/graphql/out.log',
        error_file: '/var/log/graphql/error.log'
    }]
};
EOF

mkdir -p /var/log/graphql
pm2 start /opt/graphql-server/ecosystem.config.js --env production
pm2 save
pm2 startup systemd

Solución de Problemas

Error "Cannot return null for non-nullable field":

# El resolver retorna null en un campo requerido
# Verificar el resolver y la fuente de datos
pm2 logs graphql-api --err --lines 50
# Añadir logging en el resolver problemático

Las suscripciones WebSocket se desconectan:

# Verificar que Nginx tiene timeout adecuado para WebSocket
grep proxy_read_timeout /etc/nginx/sites-available/graphql-server
# Aumentar si es necesario a 3600s
# También verificar que el cliente reconecta automáticamente

Queries lentos o timeout:

# Verificar que DataLoader está configurado correctamente
# Sin DataLoader, una query de 100 usuarios haría 100 queries a la DB
# Activar logging de queries en PostgreSQL
# ALTER SYSTEM SET log_min_duration_statement = 1000;  -- queries >1s

# Ver queries lentos de GraphQL en los logs
pm2 logs graphql-api | grep "took"

Introspección expuesta en producción:

# Verificar configuración del servidor
grep introspection /opt/graphql-server/server.js
# Asegurar que NODE_ENV=production está configurado
pm2 show graphql-api | grep NODE_ENV

Conclusión

Desplegar un servidor GraphQL en producción requiere atención especial a los problemas de rendimiento únicos de GraphQL: el problema N+1 de resolvers (resuelto con DataLoader), queries maliciosos de alta complejidad (limitados con validaciones) y suscripciones de larga duración (configuradas correctamente en Nginx). Con Apollo Server, PM2 y Nginx configurados adecuadamente, puedes servir una API GraphQL de alto rendimiento que escale con la demanda de tu aplicación.