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.


