pablo@bagliere:~$cat ~/posts/streaming-obs-aws-lambda.md
Un botón en la app y 40 servidores de streaming que se encienden solos
18 de octubre de 2026 · #aws #lambda #sns #sqs #digitalocean #obs #docker #streaming #automatización
El problema
Un cliente nuestro produce eventos en vivo de punta a punta: logística, equipo técnico, tele y streaming. Eventos grandes, con semanas de anticipación.
Les apareció un competidor por abajo con una versión mínima: solo streaming, sin producción, sin viaje, avisando en el día. Más barato. Empezaba a comerle los clientes fieles.
Nuestro cliente quiso meterse ahí sin montar una operación paralela cara. Pidió un MVP.
La idea
El cliente ya tenía una app que usan sus clientes finales. Sumamos un flujo nuevo:
- El usuario carga un evento para tal día y tal hora.
- Antes del evento, la infraestructura se arma sola.
- A la hora del evento, el usuario abre la app, toca iniciar y transmite desde el celular con 4G o WiFi.
- Al terminar toca detener. Más tarde el entorno se apaga solo.
Salida a YouTube. La tele quedaba afuera.
Primero probar la conexión
Antes de automatizar nada fuimos a un evento con el cliente, levantamos un servidor con OBS a mano y probamos si la conexión móvil del lugar aguantaba el envío de video. Funcionó. Recién ahí avanzamos.
La arquitectura
App ─► Backend ─► SNS ──┬─► email de notificación
└─► SQS ─► Lambda provisioning
├─► API DigitalOcean (droplet)
├─► API DigitalOcean (DNS A record)
└─► SSH + Docker en el droplet
├─► contenedor OBS + plantilla
└─► contenedor proxy SSL
Droplet listo ─► Backend ─► app habilita "iniciar/detener"
App "detener" ─► Backend espera 4h ─► SNS ─► SQS ─► Lambda delete
├─► API DigitalOcean (droplet)
└─► API DigitalOcean (DNS)
SNS en el medio y no un invoke directo porque sobre el mismo evento se enchufaban otros consumidores (el email de aviso salía en paralelo del mismo mensaje).
El backend publicando en SNS
El mismo endpoint sirve para crear o para pedir baja, se diferencia con un delete en el payload. La contraseña de OBS WebSocket la genera el backend y queda guardada antes de publicar.
<?php
use Aws\Sns\SnsClient;
use Aws\Exception\AwsException;
class SnsVideo
{
public static function randomPassword()
{
$alphabet = 'abcdefghijklmnopqrstuwxyzABCDEFGHIJKLMNOPQRSTUWXYZ0123456789';
$pass = [];
$max = strlen($alphabet) - 1;
for ($i = 0; $i < 12; $i++) {
$pass[] = $alphabet[rand(0, $max)];
}
return implode($pass);
}
public static function publishMessageSNS($message)
{
$client = SnsClient::factory([
'region' => 'us-east-1',
'version' => '2010-03-31',
'credentials' => [
'key' => getenv('AWS_ACCESS_KEY_ID'),
'secret' => getenv('AWS_SECRET_ACCESS_KEY'),
],
]);
try {
$client->publish([
'Message' => $message,
'TopicArn' => getenv('SNS_TOPIC_VIDEO'),
]);
} catch (AwsException $e) {
error_log($e->getMessage());
}
}
public static function Insert($dataContainer)
{
$post = $dataContainer->getHttpRequest()->getParsedBody();
$token = $dataContainer->getHttpRequest()->getAttribute('token');
$message = new \stdClass();
if (array_key_exists('delete', $post)) {
$message->delete = true;
$message->id_droplet = $post['id_droplet'];
$message->id_record = $post['id_record'];
$message->id_transmision = $post['id_transmision'];
$message->token = $token;
$message->DB = $_SESSION['db'];
self::publishMessageSNS(json_encode($message));
return json_encode(['status' => '200']);
}
$password = self::randomPassword();
$message->id_transmision = $post['id_transmision'];
$message->nombre_canal = $post['nombre_canal'];
$message->streaming_key = $post['streaming_key'];
$message->text_cabeza = $post['text_cabeza'];
$message->text_pie = $post['text_pie'];
$message->url_logo = $post['url_logo'];
$message->size = 'c-16';
$message->obs_password = $password;
$message->token = $token;
$message->DB = $_SESSION['db'];
self::publishMessageSNS(json_encode($message));
$dataContainer->setData([
'id_transmision' => $post['id_transmision'],
'status' => $post['status'],
'streaming_key' => $post['streaming_key'],
'youtube_url' => $post['youtube_url'],
'obs_password' => $password,
'obs_port' => '443',
'update_time' => $post['update_time'],
]);
\Histrix\Storage\Database::query($_SESSION['db'], $dataContainer->getUpdate());
return json_encode(['status' => '200']);
}
}
La Lambda que levanta el droplet
Recibe el batch de SQS y por cada mensaje: crea el droplet, le pide la IP, crea un registro DNS, espera a que Docker esté instalado, copia la plantilla de OBS y los certificados, levanta los contenedores y le avisa al backend.
import { exec } from 'child_process';
import { URL } from 'url';
import { randomUUID } from 'crypto';
import { writeFileSync, readFileSync } from 'fs';
import { createApiClient } from 'dots-wrapper';
import dayjs from 'dayjs';
import axios from 'axios';
const __dirname = new URL('.', import.meta.url).pathname;
const DOMAIN = process.env.DNS_DOMAIN;
const PROJECT_ID = process.env.DO_PROJECT_ID;
const DO_TOKEN = process.env.DO_API_TOKEN;
const DOCKER_USER = process.env.DOCKER_REGISTRY_USER;
const DOCKER_PASS = process.env.DOCKER_REGISTRY_PASS;
const OBS_IMAGE = process.env.OBS_IMAGE;
const dots = createApiClient({ token: DO_TOKEN });
const { droplet, sshKey, action, project, domain } = dots;
const sshFlags = `-i ${__dirname}certPrivate -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
export const handler = async (event) => {
for (const record of event.Records) {
const body = JSON.parse(record.body);
const msg = JSON.parse(body.Message);
const apiUrl = `https://api.midominio.com/api/db/${msg.DB}/app/eventos/video/transmision.xml`;
const token = msg.token?.access_token ?? msg.token;
const headers = { Authorization: `Bearer ${token}` };
if (msg.delete) {
await handleDelete(msg, apiUrl, headers);
continue;
}
await handleProvision(msg, apiUrl, headers);
}
return { statusCode: 200, body: 'ok' };
};
async function handleProvision(msg, apiUrl, headers) {
let dropletId;
let recordId;
try {
const [id_droplet, ip] = await upDroplet(msg.size, msg.nombre_canal);
dropletId = id_droplet;
const [id_record, urlObs] = await createDnsRecord(ip);
recordId = id_record;
await generateObsFiles({
password: msg.obs_password,
youtubeKey: msg.streaming_key,
textHeader: msg.text_cabeza,
textFooter: msg.text_pie,
logoUrl: msg.url_logo,
});
const scp = `scp ${sshFlags} /tmp/.env /tmp/obs-scene.json ` +
`${__dirname}cert.crt ${__dirname}cert.key root@${ip}:/root`;
await runRemote(scp, 'scp');
const login = `ssh ${sshFlags} root@${ip} ` +
`docker login -u ${DOCKER_USER} -p ${DOCKER_PASS}`;
await runRemote(login, 'docker login');
const net = `ssh ${sshFlags} root@${ip} ` +
`docker network create -d bridge connection`;
await runRemote(net, 'docker network');
const obs = `ssh ${sshFlags} root@${ip} ` +
`docker run -d --net=connection --name obs ` +
`-v /root/obs-scene.json:/root/.config/obs-studio/basic/scenes/scene.json ` +
`-v /dev/shm:/dev/shm -p 6080:80 --env-file .env ${OBS_IMAGE}`;
await runRemote(obs, 'docker run obs');
const proxy = `ssh ${sshFlags} root@${ip} ` +
`docker run -d --net=connection --name proxy ` +
`-v /root/cert.key:/etc/nginx/certs/key.pem ` +
`-v /root/cert.crt:/etc/nginx/certs/cert.pem ` +
`-p 443:443 -e DOMAIN=${urlObs} ` +
`-e TARGET_PORT=4444 -e TARGET_HOST=obs ` +
`fsouza/docker-ssl-proxy:main`;
await runRemote(proxy, 'docker run proxy');
await axios.put(apiUrl, {
data: {
id_transmision: msg.id_transmision,
id_droplet,
id_record,
obs_url: urlObs,
update_time: formatDate(dayjs().subtract(3, 'hours')),
status: 2,
},
keys: { id_transmision: msg.id_transmision },
}, { headers });
} catch (error) {
console.log('Provision failed:', error);
if (dropletId) await deleteDroplet(dropletId);
if (recordId) await deleteDnsRecord(recordId);
await axios.put(apiUrl, {
data: {
id_transmision: msg.id_transmision,
obs_password: '',
destroy_time: '0000-00-00 00:00:00',
update_time: formatDate(dayjs().subtract(3, 'hours')),
status: 0,
},
keys: { id_transmision: msg.id_transmision },
}, { headers });
throw error;
}
}
Si el scp o el docker run fallan después de haber creado droplet y DNS, el catch los borra. Si lo que falla es la respuesta de DigitalOcean a esa creación, puede quedar algo colgado: para eso existía la DLQ y una Lambda aparte que limpiaba por tag.
La carrera contra apt
Pedimos la máquina con cloud-init para que instale docker.io y esperamos a que la acción de creación esté completed:
async function upDroplet(size, channelName) {
const fingerprints = (await sshKey.listSshKeys({})).data.ssh_keys
.map((k) => k.fingerprint);
const uuid = randomUUID().split('-')[0];
const slug = (channelName ?? '')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const name = `obs-${slug || 'ev'}-${Date.now()}-${uuid}`;
const { data: { droplet: created, links } } = await droplet.createDroplet({
image: 'ubuntu-22-04-x64',
name,
region: 'nyc1',
size,
backups: false,
ipv6: false,
monitoring: true,
ssh_keys: fingerprints,
private_networking: false,
tags: ['obs', 'eventos'],
user_data: '#cloud-config\npackage_update: true\npackages:\n - docker.io\n',
});
await waitAction(links.actions[0].id);
await project.assignResourcesToProject({
project_id: PROJECT_ID,
resources: [`do:droplet:${created.id}`],
});
const { data: { droplet: full } } = await droplet.getDroplet({
droplet_id: created.id,
});
const ip = full.networks.v4.find((n) => n.type === 'public')?.ip_address;
return [created.id, ip];
}
Que la API diga active no significa que SSH esté arriba, y mucho menos que Docker exista. Entre que cloud-init arranca y Docker queda instalado pasa un rato impredecible. La primera versión fallaba acá seguido. La solución fue un wrapper recursivo con backoff que solo reintenta errores conocidos:
async function runRemote(cmd, label, delay = 3000) {
try {
await execShell(cmd);
return;
} catch (error) {
const retriable =
error.includes('Connection refused') ||
error.includes('docker: command not found') ||
error.includes('Is the docker daemon running');
if (!retriable) throw new Error(`${label} failed: ${error}`);
await sleep(delay);
await runRemote(cmd, label, delay + 3000);
}
}
OBS auto-configurado
El contenedor de OBS lee un .env y una escena en JSON que ya trae los overlays preparados. Nosotros le metemos los datos variables del evento:
async function generateObsFiles(info) {
const env = `RESOLUTION=1920x1080
PORT=4444
PASSWORD_WEBSOCKET=${info.password}
KEY_YOUTUBE=${info.youtubeKey}
OPENBOX_ARGS=--startup /root/start.sh
USER=streamer
HTTP_PASSWORD=${info.password}`;
writeFileSync('/tmp/.env', env, { encoding: 'utf-8' });
const template = readFileSync(`${__dirname}template-scene.json`, {
encoding: 'utf-8',
});
const scene = template
.replace('{{TextoCabezera}}', info.textHeader)
.replace('{{TextoPie}}', info.textFooter)
.replace('{{UrlLogo}}', info.logoUrl);
writeFileSync('/tmp/obs-scene.json', scene, { encoding: 'utf-8' });
}
Control desde la app
Entre el proxy SSL y el contenedor de OBS queda expuesto OBS WebSocket. La app se conecta con la contraseña que generó el backend y el botón de iniciar/detener termina siendo esto:
import OBSWebSocket from 'obs-websocket-js';
type StreamAction = 'start' | 'stop';
async function controlStream(transmission: {
obsUrl: string;
obsPassword: string;
}, action: StreamAction) {
const obs = new OBSWebSocket();
await obs.connect(`wss://${transmission.obsUrl}`, transmission.obsPassword);
try {
const { outputActive } = await obs.call('GetStreamStatus');
if (action === 'start' && !outputActive) await obs.call('StartStream');
if (action === 'stop' && outputActive) await obs.call('StopStream');
} finally {
await obs.disconnect();
}
}
El GetStreamStatus antes de disparar es porque la app corre en celulares con conectividad irregular y el usuario toca dos veces un botón que tarda.
Apagar: la demora de 4 horas
Cuando el usuario detenía la transmisión no borrábamos nada. Guardábamos la solicitud con un vencimiento de cuatro horas por si alguien había tocado “detener” sin querer o si el equipo técnico quería mirar la configuración antes de perder la máquina.
No era un DelaySeconds de SQS —las colas con demora tienen un máximo de 15 minutos—. Lo manejaba el backend con un scheduled_at y una tarea que lo revisaba.
El delete lo recibía la misma Lambda:
async function handleDelete(msg, apiUrl, headers) {
try {
await deleteDroplet(msg.id_droplet);
await deleteDnsRecord(msg.id_record);
await axios.put(apiUrl, {
data: {
id_transmision: msg.id_transmision,
id_droplet: '',
id_record: '',
obs_url: '',
obs_password: '',
destroy_time: '0000-00-00 00:00:00',
update_time: formatDate(dayjs().subtract(3, 'hours')),
status: 0,
},
keys: { id_transmision: msg.id_transmision },
}, { headers });
} catch (error) {
console.log('Delete failed:', error);
throw error;
}
}
Si el delete fallaba más de la cuenta, el mensaje caía en una DLQ. Una tercera Lambda se suscribía ahí, intentaba borrar por tag y por nombre, y mandaba un mail con el id_transmision para revisar.
Números y pivot
Al cuarto mes el sistema llegó a más de 40 droplets simultáneos. El cliente podía ofrecer el servicio sin anticipación y más barato. A algunos usuarios habituales incluso les mandó un celular con chip para simplificarles la operación.
Las máquinas eran sin GPU y estaban en nyc1 para entrar en el presupuesto. Eso agregaba latencia entre Argentina y Estados Unidos y limitaba la calidad del encoding.
Pasaron cuatro meses más y el volumen bajó, el competidor se fue del país y el cliente redefinió el servicio: lo cerró a sus usuarios fieles, lo acotó a horario laboral y puso un editor fijo en la oficina. Con esas condiciones, dos PCs con GPU compradas una sola vez salían más baratas por mes que los droplets. Reemplazaron el aprovisionamiento remoto por una VPN a esas máquinas. La app siguió recibiendo los datos del evento y un script le precargaba la escena al editor.
De yapa: mi primera PR a open source
Al armar el proxy TLS me faltaba un detalle que fsouza/docker-ssl-proxy no soportaba todavía, así que lo agregué y lo mandé upstream: fsouza/docker-ssl-proxy#93. Fue mi primera contribución a open source.