En el artículo anterior escribí sobre la filosofía de un blog personal sin anuncios que funciona por unos pocos cientos de yenes al mes.
Esta vez toca la parte técnica. Me centraré en por qué elegí esta arquitectura y dejaré anotados los puntos clave de diseño. No incluyo código, así que léelo como referencia de arquitectura para quien quiera construir algo parecido.
Arquitectura general
A grandes rasgos, funciona así.
[Operador] --aws s3 cp--> [S3 source/]
|
v
[Lambda: schedule] ← EventBridge diario 09:20 JST
| + llamada a Bedrock
v
[S3 staging/]
|
v
[Lambda: publish] ← EventBridge lunes 06:00 JST
|
┌───────────┼─────────────┐
v v v
[S3 blog/] [S3 assets/] [S3 archive/]
| |
└─────┬─────┘
v
[CloudFront] ──> [Lector]
Todo se resuelve dentro de S3. Sin base de datos, sin contenedores, sin servidores permanentes.
Decisión 1: Por qué no usé DynamoDB
Al diseñar el sistema del blog, lo primero que pensé fue «¿dónde guardo los metadatos de los artículos?». Lo normal sería meter DynamoDB o RDS.
Pero pensándolo bien, el número de artículos, aunque sea mucho, será de unos pocos cientos. Una escala que cabe perfectamente en un ListObjectsV2 de S3. Y tarda menos de un segundo.
Entonces, es más sencillo guardar los metadatos de cada artículo como manifest.json junto al propio artículo en S3.
archive/20260518-claude-code-basics/
├── claude-code-basics.html
├── manifest.json ← toda la información sobre este artículo
├── _attachments/
│ └── screenshot.png
└── _source/
└── claude-code-basics.md ← archivo original
El manifest.json contiene información como esta.
{
"title_kebab": "claude-code-basics",
"title_display": "Claude Code 基礎",
"publish_date_jst": "2026-05-18",
"category": "tech",
"ai": {
"calls": [
{ "prompt": "convertToArticle", "model_id": "claude-sonnet-4-6", "input_tokens": 5586, "output_tokens": 2282 },
{ "prompt": "generateSlug", "model_id": "claude-haiku-4-5", "input_tokens": 1762, "output_tokens": 24 }
]
},
"stages": {
"staged_at": "2026-05-09T07:53:50+00:00",
"published_at":"2026-05-11T06:00:13+00:00"
}
}
Cuando regenero la página principal o las páginas de categoría, simplemente leo todos los archive/*/manifest.json y los agrego. Con eso es suficiente.
Si meto DynamoDB, inevitablemente acabo peleando con el problema de «los datos en S3 y en la BD se desincronizaron». Si el manifest.json vive junto al artículo en S3, la fuente de verdad siempre es una sola.
Decisión 2: Por qué usé los Behaviors de CloudFront para definir el perímetro de seguridad
En el bucket de este blog conviven «carpetas públicas» y «carpetas privadas».
s3://da-leca-blog/
├── source/ ← privado (donde guardo los borradores)
├── staging/ ← privado (pendiente de publicar)
├── archive/ ← privado (historial)
├── blog/ ← público
└── assets/ ← público
Lo habitual sería usar buckets separados para source/staging/archive. Pero eso haría que la Lambda de generación de artículos tuviera que cruzar entre varios buckets, complicando las políticas IAM.
En su lugar, opté por meter todo en un solo bucket y restringir lo que es público enumerando Behaviors en CloudFront.
Los Behaviors de CloudFront son solo estos.
| Path Pattern | Origin |
|---|---|
/blog |
bucket blog |
/blog/* |
bucket blog |
/assets/blog/* |
bucket blog |
/sitemap.xml |
bucket blog |
/robots.txt |
bucket blog |
Como no hay ningún Behavior para /source/* ni /staging/*, cualquier petición a esas rutas a través de CloudFront devuelve un 404. El acceso directo al bucket está bloqueado con S3 Block Public Access + OAC, así que el resultado es un perímetro definido por una regla muy simple: «solo se puede llegar a los paths que están escritos en los Behaviors».
Sin VPC, sin grupos de seguridad. La idea de «los Behaviors como lista de permisos» me gusta bastante.
Decisión 3: Por qué elegí Bedrock (en lugar de llamar directamente a la API de Anthropic)
Si uso Claude, lo normal es llamar directamente a la API de Anthropic. El precio es prácticamente igual que con Bedrock.
Aun así, preferí ir por Bedrock porque la autenticación se resuelve solo con el rol IAM.
No hace falta guardar ninguna API key en ningún sitio. Ni en Secrets Manager, ni en variables de entorno, ni en Parameter Store. Con solo añadir bedrock:InvokeModel al rol de ejecución de Lambda, ya puedo llamar a Claude.
# Se puede escribir algo así (sin credenciales en ningún lado)
import boto3
client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
response = client.invoke_model(
modelId="global.anthropic.claude-sonnet-4-6",
body=json.dumps({...})
)
El coste de gestionar una API key se va acumulando sin que te des cuenta.
- ¿Dónde la guardo?
- ¿Cada cuánto la roto?
- ¿Qué hago si se filtra?
- ¿Cuánto overhead añade al arranque de Lambda?
Eliminar todo eso de golpe es un gran alivio. Es el principio de «la llave más segura es la que no existe», aplicado tal cual.
Decisión 4: Por qué dividí EventBridge en dos
Separé «el proceso de convertir borradores en artículos» y «el proceso de publicar artículos» en Lambdas distintas con schedules distintos.
| Lambda | Schedule | Tarea |
|---|---|---|
schedule_lambda |
Diario 09:20 JST | Toma un borrador aleatorio de source y crea un artículo en staging |
publish_lambda |
Lunes 06:00 JST | Publica en producción el artículo de staging |
No los unifiqué porque quería dejar tiempo para que un humano pueda revisarlo.
Cada mañana a las 9:20 se ejecuta schedule_lambda y llega una notificación cuando staging se actualiza. Si durante el fin de semana hay algún borrador que me chirría, puedo cambiarlo antes de la publicación del lunes.
En la práctica casi nunca cambio nada, pero saber que puedo intervenir en cualquier momento me da tranquilidad. La automatización total puede parecer muy elegante, pero si no hay ningún hueco para que el operador tome decisiones, cuando algo salga mal vas a llorar.
Decisión 5: Diseñé la idempotencia desde el principio
Construí las Lambdas asumiendo que se van a volver a ejecutar pase lo que pase. Reintentos de EventBridge, invocaciones manuales, re-ejecuciones manuales desde CloudWatch… que no se rompa nada venga lo que venga.
Concretamente, apliqué estos trucos.
Cada directorio en staging es único
staging/20260518-claude-code-basics/
Único por YYYYMMDD-slug. Si se vuelve a ejecutar lo mismo, simplemente sobreescribe el mismo path.
publish solo «copia el mismo archivo al mismo path»
CopyObject de S3 es idempotente. El resultado es siempre el mismo sin importar cuántas veces se ejecute.
El campo published_at del manifest también se sobreescribe sin problema.
El movimiento a archive es «copia + dejar en su sitio»
Al publicar, staging se «copia» a archive, pero el borrado del lado de staging se hace al final del todo. Si falla a mitad, staging sigue ahí y se puede volver a ejecutar.
Añadir idempotencia a posteriori es un infierno, así que lo recomendable es preguntarse desde el principio «¿se rompe si se vuelve a ejecutar?» en cada función.
Decisión 6: Externalicé los prompts de IA a AppConfig
Los system prompts que le mando a Claude no están embebidos en el código, sino en AWS AppConfig.
prompts:
convertToArticle:
revision: v1.1
system: |
あなたはブログ編集者です...
generateSlug:
revision: v1.1
system: |
Generate a URL-friendly kebab-case slug...
pickCategory:
revision: v1.0
system: |
以下の記事をカテゴリ分けします...
Lambda los obtiene de AppConfig al arrancar y los cachea con un TTL de 5 minutos.
La ventaja es que cuando quiero ajustar un prompt, no necesito hacer un CDK deploy.
El tuning de prompts es mucho ensayo y error; si tuviera que hacer un deploy cada vez, sería insostenible. Con AppConfig los cambios se reflejan en segundos.
Además, como la revision queda registrada en el manifest, después puedo distinguir «artículo generado con el prompt v1.0» de «artículo generado con v1.1». Poder rastrear los cambios en la calidad de la salida de la IA es más importante de lo que parece.
Lo que no hice (a propósito)
- Guardar Markdown en una BD — Es más cómodo tenerlo como archivo en S3; grep y copiar son más fáciles
- UI de CMS — Con
aws s3 cpes suficiente - Control de versiones de artículos — Con dejar snapshots en archive basta. No uso Git
- Soporte multiidioma — Lo ampliaré en las plantillas cuando haga falta ←después lo añadí en inglés y español
- Generación automática de imágenes OGP — Lo añadiré con otra Lambda cuando haga falta ←después lo añadí
- Enlaces prev / next — Se puede añadir después extendiendo el manifest, por ahora no están ←después los añadí
Fui estricto con el principio de «lo que no hace falta ahora, no se construye». Solo me aseguré de que el diseño permitiera añadirlo después.
A quién le viene bien esta arquitectura
- Blog gestionado por una persona o un equipo de 1-2
- Entre 1 y 10 artículos al mes
- Sin ganas de poner anuncios
- Querer mantener los costes de infraestructura por debajo de 1.000 yenes al mes
- Tener cierta soltura con AWS y Python
Y a quién no le viene bien:
- Blog de equipo con edición simultánea de varias personas → mejor usar un CMS headless
- Medios que necesitan tiempo real → Lambda + S3 es asíncrono por naturaleza
- Querer servir contenido dinámico con mucho tráfico → solo con CloudFront no es suficiente
Para terminar
Mientras construía este blog, la pregunta que me hice una y otra vez fue: «¿esto realmente hace falta?».
Añadir funcionalidades es fácil; quitarlas es lo difícil. Lo que quedó después de quitar es el sistema que está funcionando ahora mismo.
S3 + CloudFront + Lambda + Bedrock. Solo cuatro servicios gestionados. Con eso basta para tener un blog personal donde la IA convierte notas en artículos y los publica sola cada lunes por la mañana.
Espero que le sirva de referencia a quien quiera montar algo parecido.
Artículo anterior: Cómo monté un blog de «solo sube el archivo» por unos pocos cientos de yenes al mes y cero anuncios