Un frontend en una sola región está bien para el 80% de los casos. Pero cuando tu app crece a usuarios globales, o cuando un cliente te pide SLA de 99.99%, una sola región ya no alcanza. Multi-region no es solo "desplegar en dos lugares", es una arquitectura donde Route 53 hace health checks, CloudFront sirve desde el edge más cercano, y los datos se replican con estrategias específicas según su naturaleza. Este artículo cubre la arquitectura completa.
Qué resuelve y qué no resuelve
flowchart TB
subgraph Problemas[Problemas que resuelve]
P1[Latencia geográfica]
P2[Disaster recovery]
P3[Compliance por región]
P4[99.99% uptime real]
end
subgraph NoResuelve[Lo que NO resuelve]
N1[Base de datos lenta]
N2[Código con bugs]
N3[Costos bajos]
N4[Simplicidad operacional]
end
style Problemas fill:#2ecc71,color:#fff
style NoResuelve fill:#e74c3c,color:#fff
Multi-region no es primera solución. Es última. Primero optimiza single region: cache, índices DB, CDN. Cuando eso no alcanza, entonces multi-region.
Arquitectura objetivo
flowchart TB
User[Usuarios globales] --> R53[Route 53<br/>Latency-based + Failover]
R53 -->|US/LATAM| USEast[us-east-1<br/>Primary region]
R53 -->|EU| EUWest[eu-west-1<br/>Secondary region]
subgraph USEast
CF1[CloudFront]
L1[Lambda SSR]
DDB1[(DynamoDB Global)]
S31[S3 with replication]
end
subgraph EUWest
CF2[CloudFront]
L2[Lambda SSR]
DDB2[(DynamoDB Global)]
S32[S3 replicated]
end
DDB1 <-.->|Global Tables| DDB2
S31 <-.->|Cross-region replication| S32
style R53 fill:#8e44ad,color:#fff
Route 53 con failover y latencia
El truco de Route 53 está en combinar dos tipos de routing:
- Latency-based: manda al usuario a la región más cercana.
- Failover: si la primary falla, redirige a la secondary.
// infra/dns-stack.ts
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
export class DnsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: DnsStackProps) {
super(scope, id, props);
const zone = route53.HostedZone.fromLookup(this, 'Zone', {
domainName: 'miempresa.com',
});
// Health check de la primary region
const primaryHealthCheck = new route53.CfnHealthCheck(this, 'PrimaryHealth', {
healthCheckConfig: {
type: 'HTTPS',
fullyQualifiedDomainName: props.primaryOriginDns,
port: 443,
resourcePath: '/api/health',
requestInterval: 30,
failureThreshold: 3,
regions: ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1'],
},
healthCheckTags: [{ key: 'Name', value: 'primary-health' }],
});
const secondaryHealthCheck = new route53.CfnHealthCheck(this, 'SecondaryHealth', {
healthCheckConfig: {
type: 'HTTPS',
fullyQualifiedDomainName: props.secondaryOriginDns,
port: 443,
resourcePath: '/api/health',
requestInterval: 30,
failureThreshold: 3,
},
});
// Latency record: primary (us-east-1)
new route53.CfnRecordSet(this, 'PrimaryLatency', {
hostedZoneId: zone.hostedZoneId,
name: 'app.miempresa.com',
type: 'A',
setIdentifier: 'primary-us-east-1',
aliasTarget: {
dnsName: props.primaryDistributionDomain,
hostedZoneId: 'Z2FDTNDATAQYW2',
},
region: 'us-east-1',
healthCheckId: primaryHealthCheck.attrHealthCheckId,
});
// Latency record: secondary (eu-west-1)
new route53.CfnRecordSet(this, 'SecondaryLatency', {
hostedZoneId: zone.hostedZoneId,
name: 'app.miempresa.com',
type: 'A',
setIdentifier: 'secondary-eu-west-1',
aliasTarget: {
dnsName: props.secondaryDistributionDomain,
hostedZoneId: 'Z2FDTNDATAQYW2',
},
region: 'eu-west-1',
healthCheckId: secondaryHealthCheck.attrHealthCheckId,
});
}
}
Con esta config, un usuario en París va a eu-west-1. Si eu-west-1 falla, Route 53 lo manda a us-east-1.
Health check endpoint
El endpoint debe probar TODAS las dependencias, no solo responder 200:
// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';
const dynamo = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const s3 = new S3Client({});
export async function GET() {
const checks: Record<string, { status: 'ok' | 'fail'; duration: number; error?: string }> = {};
// Check DynamoDB
const dynStart = Date.now();
try {
await dynamo.send(
new GetCommand({
TableName: process.env.POSTS_TABLE,
Key: { id: '__health_check__' },
})
);
checks.dynamodb = { status: 'ok', duration: Date.now() - dynStart };
} catch (error: any) {
checks.dynamodb = { status: 'fail', duration: Date.now() - dynStart, error: error.message };
}
// Check S3
const s3Start = Date.now();
try {
await s3.send(new HeadBucketCommand({ Bucket: process.env.ASSETS_BUCKET }));
checks.s3 = { status: 'ok', duration: Date.now() - s3Start };
} catch (error: any) {
checks.s3 = { status: 'fail', duration: Date.now() - s3Start, error: error.message };
}
const allOk = Object.values(checks).every(c => c.status === 'ok');
return NextResponse.json(
{
status: allOk ? 'healthy' : 'degraded',
region: process.env.AWS_REGION,
checks,
timestamp: new Date().toISOString(),
},
{ status: allOk ? 200 : 503 }
);
}
El status code es crítico: 503 hace que Route 53 marque el health check como failed y cambie el routing.
DynamoDB Global Tables
Para replicación de datos transparente:
// infra/data-stack.ts
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
export class DataStack extends cdk.Stack {
public readonly postsTable: dynamodb.TableV2;
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
this.postsTable = new dynamodb.TableV2(this, 'PostsTable', {
tableName: 'posts',
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
dynamoStream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
pointInTimeRecovery: true,
replicas: [
{ region: 'eu-west-1' },
{ region: 'ap-southeast-1' },
],
globalSecondaryIndexes: [
{
indexName: 'by-slug',
partitionKey: { name: 'slug', type: dynamodb.AttributeType.STRING },
},
],
});
}
}
Cuando escribes en la tabla de us-east-1, DynamoDB replica automáticamente a eu-west-1 y ap-southeast-1. Latencia típica: 1-2 segundos.
S3 cross-region replication
const primaryBucket = new s3.Bucket(this, 'PrimaryBucket', {
versioned: true, // Requerido para replication
encryption: s3.BucketEncryption.S3_MANAGED,
});
const replicaBucket = new s3.Bucket(this, 'ReplicaBucket', {
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
});
const replicationRole = new iam.Role(this, 'ReplicationRole', {
assumedBy: new iam.ServicePrincipal('s3.amazonaws.com'),
});
primaryBucket.grantRead(replicationRole);
replicaBucket.grantWrite(replicationRole);
const cfnPrimaryBucket = primaryBucket.node.defaultChild as s3.CfnBucket;
cfnPrimaryBucket.replicationConfiguration = {
role: replicationRole.roleArn,
rules: [
{
id: 'replicate-to-eu',
status: 'Enabled',
priority: 1,
destination: {
bucket: replicaBucket.bucketArn,
storageClass: 'STANDARD_IA',
replicationTime: {
status: 'Enabled',
time: { minutes: 15 },
},
metrics: {
status: 'Enabled',
eventThreshold: { minutes: 15 },
},
},
deleteMarkerReplication: { status: 'Disabled' },
filter: { prefix: '' },
},
],
};
El stack de región regional
El mismo stack se deploya en cada región:
// infra/regional-stack.ts
export class RegionalStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: RegionalStackProps) {
super(scope, id, props);
// Lambda SSR
const ssrFn = new lambda.Function(this, 'SSR', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('../build'),
memorySize: 1024,
timeout: cdk.Duration.seconds(30),
environment: {
AWS_REGION_NAME: this.region,
POSTS_TABLE: props.postsTableName,
ASSETS_BUCKET: props.assetsBucketName,
},
architecture: lambda.Architecture.ARM_64,
});
// Permisos a DynamoDB Global Table
ssrFn.addToRolePolicy(
new iam.PolicyStatement({
actions: [
'dynamodb:GetItem',
'dynamodb:Query',
'dynamodb:PutItem',
'dynamodb:UpdateItem',
],
resources: [
`arn:aws:dynamodb:${this.region}:${this.account}:table/${props.postsTableName}`,
`arn:aws:dynamodb:${this.region}:${this.account}:table/${props.postsTableName}/index/*`,
],
})
);
const ssrUrl = ssrFn.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
});
// CloudFront
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: new origins.FunctionUrlOrigin(ssrUrl),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
});
new cdk.CfnOutput(this, 'DistributionDomain', {
value: distribution.distributionDomainName,
exportName: `${this.region}-distribution-domain`,
});
}
}
// Deploy en cada región
const app = new cdk.App();
new RegionalStack(app, 'RegionalUsEast', {
env: { region: 'us-east-1', account: process.env.CDK_DEFAULT_ACCOUNT },
postsTableName: 'posts',
assetsBucketName: 'mi-empresa-assets-us-east-1',
});
new RegionalStack(app, 'RegionalEuWest', {
env: { region: 'eu-west-1', account: process.env.CDK_DEFAULT_ACCOUNT },
postsTableName: 'posts',
assetsBucketName: 'mi-empresa-assets-eu-west-1',
});
Manejo de sesiones cross-region
El problema: un usuario logea en us-east-1, su cookie sale de ahí. Si Route 53 lo manda a eu-west-1, la cookie debe ser válida.
Solución: firmar las sesiones con un secreto compartido vía Secrets Manager con replicación:
const sessionSecret = new secretsmanager.Secret(this, 'SessionSecret', {
secretName: 'session-secret',
replicaRegions: [
{ region: 'eu-west-1' },
{ region: 'ap-southeast-1' },
],
});
En la app:
// lib/session.ts
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
let cachedSecret: string | null = null;
async function getSessionSecret(): Promise<string> {
if (cachedSecret) return cachedSecret;
const result = await sm.send(
new GetSecretValueCommand({ SecretId: 'session-secret' })
);
cachedSecret = result.SecretString!;
return cachedSecret;
}
export async function verifySession(token: string) {
const secret = await getSessionSecret();
return jwtVerify(token, new TextEncoder().encode(secret));
}
Como el secret está replicado, ambas regiones pueden verificar la misma sesión.
Escribir con consistencia global
DynamoDB Global Tables son eventualmente consistentes. Si escribes en us-east-1 y lees inmediatamente en eu-west-1, puedes no ver el cambio.
Estrategia: escribir siempre en la región del usuario, pero si necesitas leer tu escritura, forzar lectura fuerte de esa región.
async function createPost(post: Post) {
// Escribir localmente
await dynamo.send(
new PutCommand({
TableName: 'posts',
Item: post,
})
);
// Si necesitamos confirmar la escritura, leer con consistency strong
const read = await dynamo.send(
new GetCommand({
TableName: 'posts',
Key: { id: post.id },
ConsistentRead: true,
})
);
return read.Item;
}
Costos reales
Multi-region duplica mucho del costo. Ejemplo de un setup con 1M visitas/mes:
| Componente | Single region | Multi-region (2) |
|---|---|---|
| Lambda | $45 | $48 (tráfico distribuido) |
| DynamoDB | $120 | $280 (replicación + writes x2) |
| CloudFront | $85 | $85 (mismo, es global) |
| S3 replication | $0 | $18 |
| Route 53 health checks | $0 | $12 |
| Total | $250 | $443 |
El costo sube ~75%. Hay que justificar ese número.
Failover testing
Nunca asumas que el failover funciona. Pruébalo:
# Simular fallo de primary region
aws route53 update-health-check \
--health-check-id abc-123 \
--disabled
# Esperar 2-3 minutos (tiempo de propagación DNS)
sleep 180
# Verificar que el tráfico se fue a secondary
dig app.miempresa.com +short
# Debería resolver al IP de eu-west-1
# Re-habilitar primary
aws route53 update-health-check \
--health-check-id abc-123 \
--no-disabled
Un ejercicio mensual vale la pena. Las veces que fallará en producción, vas a estar preparado.
Lo que aprendí
1. Multi-region es para disaster recovery, no performance.
CloudFront ya te da edge locations globales. La única razón real para multi-region es DR.
2. La latencia de replicación es real.
DynamoDB Global Tables tarda 1-2 segundos. Para apps realtime (chats, dashboards), esto importa.
3. Los costos sorprenden.
Calcula antes. Las writes de DynamoDB se duplican (o triplican con 3 regiones). S3 replication traffic es pagado.
4. Route 53 failover no es instantáneo.
DNS caching en clientes puede tomar 5-10 minutos en propagarse totalmente, incluso con TTL bajo.
5. Datos que NO deben replicarse.
No todo: datos regulatorios (GDPR), datos de sesión, datos temporales. Piensa qué replicar y qué mantener regional.
Cuándo NO ir multi-region
- Tráfico todavía manejable en una región.
- No hay SLA explícito de 99.99%+.
- Presupuesto limitado.
- Equipo operacionalmente junior.
Para la mayoría de proyectos, CloudFront + una región bien configurada es más que suficiente.
Cierre
Multi-region frontend es una arquitectura madura para escenarios específicos. La complejidad operacional es real pero el payoff (uptime, latencia global) vale la pena cuando los números lo justifican. Route 53 + Global Tables + CloudFront te dan la base; la estrategia de datos y sesiones es lo que debes diseñar con cuidado.
En el próximo artículo: Remix en AWS con Architect para un stack alternativo a Next.js más liviano.
Top comments (0)