devops

Inferencia de IA totalmente automatizada en AWS, Azure y Google Cloud con Pulumi.

Fuente: pulumi.com 24 min de lectura

Compartir

Inferencia de IA totalmente automatizada en AWS, Azure y Google Cloud con Pulumi.

Estás leyendo un resumen. El contenido completo está en pulumi.com.

Poner Ollama en una GPU en la nube es algo a lo que sigo volviendo. Hace un tiempo escribí sobre cómo ejecutar LLMs de código abierto en una caja AWS EC2 con Ollama y Pulumi, y la forma nunca cambia realmente: una instancia de GPU, un servidor de modelos y una regla de firewall enfrente. La infraestructura como código se ganó su lugar al hacer que ese tipo de configuración sea predecible y repetible, y la infraestructura de IA no es una excepción. Una máquina virtual de GPU que sirve un modelo sigue siendo una VM, un disco y una regla de firewall, y debería declararse como tal. Thorsten Hans hizo exactamente ese caso en su publicación de Akamai, Infraestructuras de IA Totalmente Automatizadas con Terraform y Akamai Cloud, que establece una sola instancia de GPU en Linode, instala los controladores, ejecuta Ollama y carga un modelo, sin pasos manuales después de terraform apply. Me gustó la forma de esto, así que esta publicación lleva la misma idea a Pulumi y la ejecuta en AWS, Azure y Google Cloud en lugar de uno solo. El resultado es una forma de programa por cada nube: un solo pulumi up levanta una caja de GPU que instala su propio controlador, ejecuta Ollama y carga un modelo sin pasos manuales, y un solo pulumi destroy la desmantela. En el proceso, elimina los dos bits imperativos en los que se apoya la versión de Terraform: un token de acceso estático sentado en una variable de entorno y un null_resource que ejecuta un bucle de shell para esperar que el modelo esté listo. El primero se convierte en un inicio de sesión OIDC desde un entorno ESC de Pulumi, por lo que no hay ninguna clave de larga duración en ningún lugar. El segundo resulta que no es un recurso en absoluto.

Lo que estás construyendo Ahora, despoja el nombre por nube y cada versión de esto son las mismas tres cosas: una máquina virtual GPU, un firewall en frente de ella y un script de cloud-init que convierte una caja bare Ubuntu en un servidor de inferencias en funcionamiento. La entrega del modelo se ejecuta en Ollama, que expone una API HTTP en el puerto 11434 y mantiene el modelo residente en la memoria GPU entre solicitudes.

Escenario LR Dev([Tu máquina / curl]) -->|"HTTP :11434"| FW["Firewall / grupo de seguridad<br/>(permitir 11434, opcional 22)"] FW --> VM["VM GPU (Ubuntu 24.04)<br/>Controlador NVIDIA + Ollama"] VM -->|"residente en memoria GPU"| Model["qwen2.5:14b"] ESC["Pulumi ESC<br/>pulumi-idp/auth"] -.->|"Inicio de sesión OIDC (credenciales de corta duración)"| Pulumi["pulumi up"] Pulumi -.->|"AWS / Azure / GCP"| VM

Componente Puerto Descripción VM GPU - Ubuntu 24.04 con un GPU clase T4 de NVIDIA. Instala el controlador y Ollama desde cloud-init, y luego carga el modelo. Ollama 11434 Sirve el modelo a través de una API HTTP y lo mantiene en la memoria GPU entre llamadas. Firewall / grupo de seguridad - Permite entrada 11434 (y 22 cuando lo pidas). Abierto al mundo para una demostración; restringe el CIDR para algo real. pulumi-idp/auth (ESC) - Un entorno que media un inicio de sesión OIDC en AWS, Azure y GCP. Sin claves estáticas en el código o en CI. Un detalle aquí merece una pausa, y explica por qué la versión de Pulumi resulta más corta que la de Terraform. El proyecto de Akamai termina con un null_resource que ejecuta un bucle curl en local-exec para bloquear terraform apply hasta que el modelo termine de descargarse. Eso no es infraestructura, sino una verificación en tiempo de ejecución disfrazada de recurso. Pulumi tiene un proveedor de Command que te permitiría reproducirlo línea por línea, y esta publicación deliberadamente no lo hace. El programa aprovisiona la caja, la caja carga el modelo por su cuenta, y el programa imprime el punto final. Si el modelo ya ha terminado de descargarse es una pregunta que se contesta con un curl, no una pregunta que tu herramienta IaC debería mantener abierta para preguntar.

Requisitos Antes de comenzar, asegúrate de tener: CLI de Pulumi instalado y configurado Una cuenta de Pulumi Cloud (ESC y OIDC viven aquí) Una cuenta en al menos uno de AWS, Azure o Google Cloud, con un vínculo OIDC configurado para Pulumi (cubierto abajo) Suficiente cuota de GPU en tu región objetivo para una instancia clase T4; una cuenta nueva a menudo comienza en cero, que es la razón más común por la que un primer despliegue falla, así que verifica esto antes de ejecutar pulumi up Node.js 18+ para el programa de TypeScript curl para hablar con el punto de inferencia una vez que esté funcionando

Esta publicación sirve el modelo qwen2.5:14b, el mismo que utiliza el artículo de Akamai, porque la cuantificación de 4 bits se ajusta cómodamente en un solo T4 de 16 GB. El modelo es un valor de configuración (modelo), así que cambia por cualquier cosa que Ollama soporte; ajusta la GPU a la huella de memoria del modelo, porque un modelo que desborde VRAM aún se ejecutará pero se desbordará en la CPU y se ralentizará. Credenciales sin copiar y pegar La versión de Terraform se autentica de la única manera en que un demo de una sola nube puede: generas un token de acceso personal, lo exportas como LINODE_TOKEN, y el proveedor lo lee desde el entorno. Funciona, pero ese token es de larga duración, queda en tu historial de shell y tus secretos de CI, y generas uno por nube. En tres nubes, eso son tres claves estáticas para rotar y preocuparse. Pulumi ESC (Entornos, Secretos y Configuración) lo reemplaza todo con un inicio de sesión OIDC. La idea: en lugar de almacenar una clave de nube, almacenas una relación de confianza. En el momento de desplegar, ESC presenta un token OIDC de corta duración a AWS, Azure o Google Cloud, y cada uno devuelve credenciales temporales delimitadas a un rol que controlas. Nada de larga duración se escribe nunca. Mantengo ese cableado en un solo entorno, pulumi-idp/auth, y cada stack lo importa. Aquí está todo:

# pulumi-idp/auth: un entorno ESC que media un inicio de sesión OIDC en las tres nubes. Cada stack lo importa. No hay clave estática de nube aquí ni en ningún otro lugar. valores: aws: inicio de sesión: fn::open::aws-login: oidc: roleArn: arn:aws:iam::123456789012:role/pulumi-esc sessionName: pulumi-esc azure: inicio de sesión: fn::open::azure-login: clientId: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee tenantId: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee subscriptionId: /subscriptions/00000000-0000-0000-0000-000000000000 oidc: verdadero gcp: inicio de sesión: fn::open::gcp-login: project: 123456789012

# número de proyecto numérico, no el ID del proyecto oidc: workloadPoolId: pulumi-esc providerId: pulumi-esc serviceAccount: pulumi-esc@my-project.iam.gserviceaccount.com environmentVariables: # AWS: leído por el proveedor Pulumi AWS, los SDK de AWS, y el CLI de aws AWS_ACCESS_KEY_ID: ${aws.login.accessKeyId} AWS_SECRET_ACCESS_KEY: ${aws.login.secretAccessKey} AWS_SESSION_TOKEN: ${aws.login.sessionToken} # Azure: leído por el proveedor azure-native ARM_USE_OIDC: "true" ARM_CLIENT_ID: ${azure.login.clientId} ARM_TENANT_ID: ${azure.login.tenantId} ARM_SUBSCRIPTION_ID: ${azure.login.subscriptionId} ARM_OIDC_TOKEN: ${azure.login.oidc.token} # Google Cloud: leído por el proveedor de Pulumi Google Cloud GOOGLE_CLOUD_PROJECT: ${gcp.login.project} GOOGLE_OAUTH_ACCESS_TOKEN: ${gcp.login.accessToken}

Solo necesitas los bloques para las nubes a las que realmente despliegas. Abrir este entorno hace que ESC realice un inicio de sesión OIDC en vivo para cada nube listada, así que recorta a la que uses, o mantén las tres si, como yo, saltas entre ellas. Cada nube necesita una configuración de confianza única para que este inicio de sesión se permita: un proveedor de identidad OIDC de IAM y un rol en AWS, una credencial federada en un registro de aplicación de Azure y un Pool de Identidad de Carga de Trabajo en Google Cloud. Haces esto una vez; después de eso, pulumi-idp/auth es la única cosa que cualquier stack referencia para credenciales. Un stack se opta por ello con un bloque en su configuración de stack. La Pulumi.aws.yaml del stack de AWS:

# Pulumi.aws.yaml entorno: - pulumi-idp/auth

Esa es toda la historia de credenciales. No hay claves en el programa, no hay claves en CI, nada que rotar. El programa que sigue nunca menciona un secreto; solo crea recursos, y las credenciales ambientales de pulumi-idp/auth llevan la solicitud. Un cloud-init, tres nubes La parte que convierte una caja bare Ubuntu en un servidor de inferencias es idéntica en cada nube, así que vive en un solo archivo, cloud-init.yaml, que los tres programas leen. Sigue de cerca el cloud-config de Akamai, con un par de ajustes de robustez para una ejecución en múltiples nubes, y se ejecuta en un orden deliberado porque el controlador de GPU necesita un reinicio antes de que Ollama pueda ver la tarjeta: Actualizar paquetes e instalar los encabezados del kernel y ubuntu-drivers. Instalar el controlador NVIDIA, luego reiniciar para que el kernel lo cargue. En el siguiente inicio, un servicio systemd de una vez instala Ollama, lo vincula a 0.0.0.0:11434 y carga el modelo. El servicio se desactiva, así que nunca se ejecuta de nuevo.

#cloud-config # Caja de GPU Ollama sin contacto. Sin dependencia de proveedor: sin metadatos de proveedor, sin credenciales estáticas. # El programa de Pulumi sustituye el nombre del modelo en la línea de `ollama pull` a continuación antes de que se pase como user-data (AWS/GCP) o custom-data (Azure). write_files: # Hacer que Ollama escuche en cada interfaz y nunca descargar el modelo de VRAM. - path: /etc/systemd/system/ollama.service.d/override.conf content: | [Service] Environment="OLLAMA_HOST=0.0.0.0:11434" Environment="OLLAMA_KEEP_ALIVE=-1" # Se ejecuta una vez en el inicio posterior, después de que se carga el controlador GPU. - path: /usr/local/bin/ollama-setup.sh permissions: '0755' content: | #!/usr/bin/env bash set -euxo pipefail # Instalar Ollama; el instalador crea e inicia la unidad systemd de ollama. curl -fsSL https://ollama.com/install.sh | sh # Recoger la anulación de OLLAMA_HOST / OLLAMA_KEEP_ALIVE escrita arriba. systemctl daemon-reload systemctl enable ollama.service systemctl restart ollama.service # Esperar a que el demonio vincule su socket antes de cargar. Este es el # caja esperando a su PROPIO demonio local, no una puerta de entrada de disponibilidad externa. until curl -fsS http://127.0.0.1:11434/api/tags >/dev/null 2>&1; do sleep 2 done # Pre-cargar el modelo para que el punto final responda en la primera solicitud. ollama pull __MODEL__ # De una sola vez: nunca se ejecuta de nuevo en futuros inicios. systemctl disable ollama-setup.service - path: /etc/systemd/system/ollama-setup.service content: | [Unit] Description=Instalación única de Ollama y carga del modelo After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/local/bin/ollama-setup.sh RemainAfterExit=true # La carga del modelo se ejecuta durante varios minutos; sin esto, el timeout de inicio # predeterminado de systemd de 90 segundos mata el servicio a la mitad de la descarga y el modelo nunca llega. TimeoutStartSec=0 [Install] WantedBy=multi-user.target runcmd: - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get -y upgrade # linux-headers-generic también abarca el kernel que la actualización anterior puede haber traído, # así que DKMS construye el módulo NVIDIA para el kernel que arranca a continuación. - DEBIAN_FRONTEND=noninteractive apt-get install -y "linux-headers-$(uname -r)" linux-headers-generic ubuntu-drivers-common # --gpgpu selecciona la rama de controlador de servidor sin cabeza, la correcta para un GPU de cálculo # como el T4; bare `ubuntu-drivers install` tiraría la pila de escritorio. - ubuntu-drivers install --gpgpu - systemctl enable ollama-setup.service power_state: mode: reboot message: Reiniciando para cargar el controlador NVIDIA antes de la configuración de Ollama condition: true

El nombre del modelo es el token literal __MODEL__. El programa de Pulumi lee este archivo en el momento del despliegue y sustituye tu valor de configuración de modelo antes de entregarlo a la instancia. Dos configuraciones de entorno están haciendo un trabajo silencioso pero importante: OLLAMA_HOST=0.0.0.0:11434 hace que Ollama escuche en cada interfaz en lugar de solo localhost, y OLLAMA_KEEP_ALIVE=-1 mantiene el modelo fijado en la memoria GPU para que solo la primera solicitud pague el costo de carga.

Los programas Cada programa tiene los mismos cinco pasos: leer la configuración, crear el template de cloud-init.yaml, abrir los puertos de entrada, lanzar la instancia de GPU con ese cloud-init como sus datos de usuario, y exportar el punto final. Lo que difiere es solo el dialecto que cada nube habla para "instancia de GPU" y "regla de firewall". El contrato compartido mantiene las tres listas legibles lado a lado: el mismo modelo y las mismas claves de configuración allowSsh, el mismo cloud-init.yaml y los mismos cuatro exports (publicIp, ollamaEndpoint, generateEndpoint, tagsEndpoint).

Elige tu nube:

import * as pulumi from "@pulumi/pulumi"; import * as aws from "@pulumi/aws"; import * as fs from "fs"; const cfg = new pulumi.Config(); const model = cfg.get("model") ?? "qwen2.5:14b"; const allowSsh = cfg.getBoolean("allowSsh") ?? false; const region = (cfg.get("region") ?? "us-east-1") as aws.Region; // Inyecta el nombre del modelo en cloud-init en el momento del despliegue (una lectura de archivo, no un shell-out). const userData = fs.readFileSync("cloud-init.yaml", "utf8").replace(/__MODEL__/g, model); // La región proviene de la configuración; las credenciales llegan de manera ambiental desde pulumi-idp/auth. const provider = new aws.Provider("aws", { region }); // Ubuntu 24.04 LTS más reciente (amd64, hvm, gp3) publicada por Canonical. const ubuntu = aws.ec2.getAmi({ mostRecent: true, owners: ["099720109477"], filters: [ { name: "name", values: ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] }, { name: "virtualization-type", values: ["hvm"] }, ], }, { provider }); const ingress: aws.types.input.ec2.SecurityGroupIngress[] = [{ description: "API HTTP de Ollama. NOTA: restringe cidrBlocks a tu propio rango en producción.", fromPort: 11434, toPort: 11434, protocol: "tcp", cidrBlocks: ["0.0.0.0/0"], }]; if (allowSsh) { ingress.push({ description: "SSH", fromPort: 22, toPort: 22, protocol: "tcp", cidrBlocks: ["0.0.0.0/0"], }); } const sg = new aws.ec2.SecurityGroup("ollama", { description: "Acceso de inferencia Ollama", ingress, egress: [{ description: "Permitir todo el tráfico saliente", fromPort: 0, toPort: 0, protocol: "-1", cidrBlocks: ["0.0.0.0/0"], }], }, { provider, }); const server = new aws.ec2.Instance("ollama", { ami: ubuntu.then(a => a.id), instanceType: "g4dn.xlarge", // 1x NVIDIA T4 vpcSecurityGroupIds: [sg.id], associatePublicIpAddress: true, userData, // texto plano; el proveedor de AWS lo codifica en base64 para ti rootBlockDevice: { volumeSize: 40, volumeType: "gp3", }, tags: { Name: "ollama" }, }, { provider, }); export const publicIp = server.publicIp; export const ollamaEndpoint = pulumi.interpolate`http://${publicIp}:11434`; export const generateEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/generate`; export const tagsEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/tags`;

import * as pulumi from "@pulumi/pulumi"; import * as gcp from "@pulumi/gcp"; import * as fs from "fs"; const cfg = new pulumi.Config(); const model = cfg.get("model") ?? "qwen2.5:14b"; const allowSsh = cfg.getBoolean("allowSsh") ?? false; const project = cfg.get("project"); const zone = cfg.get("zone") ?? "us-central1-a"; // Inyecta el nombre del modelo en cloud-init en el momento del despliegue (una lectura de archivo, no un shell-out). const userData = fs.readFileSync("cloud-init.yaml", "utf8").replace(/__MODEL__/g, model); // Imagen de Ubuntu 24.04 LTS amd64 más reciente. const ubuntu = gcp.compute.getImage({ family: "ubuntu-2404-lts-amd64", project: "ubuntu-os-cloud", }); // La etiqueta de red vincula la regla de firewall a esta instancia. const networkTag = "ollama"; const firewall = new gcp.compute.Firewall("ollama-fw", { network: "default", project: project, // Entrante a Ollama desde cualquier lugar; restringe este CIDR para producción. allows: allowSsh ? [{ protocol: "tcp", ports: ["11434"] }, { protocol: "tcp", ports: ["22"] }] : [{ protocol: "tcp", ports: ["11434"] }], sourceRanges: ["0.0.0.0/0"], targetTags: [networkTag], }); const instance = new gcp.compute.Instance("ollama", { machineType: "n1-standard-4", zone: zone, project: project, tags: [networkTag], bootDisk: { initializeParams: { image: ubuntu.then(i => i.selfLink), size: 40, }, }, guestAccelerators: [{ type: "nvidia-tesla-t4", count: 1, }], // Las GPUs no pueden vivir migrar, así que el mantenimiento del host debe terminar (y reiniciar) la VM. scheduling: { onHostMaintenance: "TERMINATE", automaticRestart: true, }, networkInterfaces: [{ network: "default", accessConfigs: [{}], // una configuración vacía solicita una IP pública efímera }], metadata: { "user-data": userData, // Ubuntu cloud-init lee esta clave, no startup-script }, }, { dependsOn: firewall, }); export const publicIp = instance.networkInterfaces.apply(nics => nics[0].accessConfigs![0].natIp); export const ollamaEndpoint = pulumi.interpolate`http://${publicIp}:11434`; export const generateEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/generate`; export const tagsEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/tags`;

import * as pulumi from "@pulumi/pulumi"; import * as resources from "@pulumi/azure-native/resources"; import * as network from "@pulumi/azure-native/network"; import * as compute from "@pulumi/azure-native/compute"; import * as random from "@pulumi/random"; import * as fs from "fs"; const cfg = new pulumi.Config(); const model = cfg.get("model") ?? "qwen2.5:14b"; const allowSsh = cfg.getBoolean("allowSsh") ?? false; const location = cfg.get("location") ?? "eastus"; // Inyecta el nombre del modelo en cloud-init en el momento del despliegue (una lectura de archivo, no un shell-out). const userData = fs.readFileSync("cloud-init.yaml", "utf8").replace(/__MODEL__/g, model); const resourceGroup = new resources.ResourceGroup("ollama-rg", { location, }); const vnet = new network.VirtualNetwork("ollama-vnet", { resourceGroupName: resourceGroup.name, addressSpace: { addressPrefixes: ["10.0.0.0/16"] }, }); const subnet = new network.Subnet("ollama-subnet", { resourceGroupName: resourceGroup.name, virtualNetworkName: vnet.name, addressPrefix: "10.0.1.0/24", }); // Las IP públicas SKU estándar utilizan asignación estática. const publicIpAddress = new network.PublicIPAddress("ollama-pip", { resourceGroupName: resourceGroup.name, sku: { name: network.PublicIPAddressSkuName.Standard }, publicIPAllocationMethod: network.IPAllocationMethod.Static, }); // Azure permite toda salida por defecto, por lo que solo se necesitan reglas de entrada. const nsg = new network.NetworkSecurityGroup("ollama-nsg", { resourceGroupName: resourceGroup.name, securityRules: [ { name: "allow-ollama", priority: 1000, direction: network.SecurityRuleDirection.Inbound, access: network.SecurityRuleAccess.Allow, protocol: network.SecurityRuleProtocol.Tcp, sourcePortRange: "*", destinationPortRange: "11434", sourceAddressPrefix: "0.0.0.0/0", // prod: restringe al CIDR de tu cliente destinationAddressPrefix: "*", }, ...(allowSsh ? [{ name: "allow-ssh", priority: 1001, direction: network.SecurityRuleDirection.Inbound, access: network.SecurityRuleAccess.Allow, protocol: network.SecurityRuleProtocol.Tcp, sourcePortRange: "*", destinationPortRange: "22", sourceAddressPrefix: "0.0.0.0/0", destinationAddressPrefix: "*", }] : []), ], }); const nic = new network.NetworkInterface("ollama-nic", { resourceGroupName: resourceGroup.name, networkSecurityGroup: { id: nsg.id }, ipConfigurations: [{ name: "ipconfig1", subnet: { id: subnet.id }, publicIPAddress: { id: publicIpAddress.id }, privateIPAllocationMethod: network.IPAllocationMethod.Dynamic, primary: true, }], }); // Azure requiere una credencial administrativa incluso si nunca inicias sesión. Genera una // en lugar de codificarla; se mantiene como un secreto de Pulumi y nunca se exporta. const adminPassword = new random.RandomPassword("ollama-admin-password", { length: 24, special: true, overrideSpecial: "!#$%*", minLower: 1, minUpper: 1, minNumeric: 1, minSpecial: 1, // Azure requiere 3 de 4 clases de caracteres }); const vm = new compute.VirtualMachine("ollama-vm", { resourceGroupName: resourceGroup.name, hardwareProfile: { vmSize: "Standard_NC4as_T4_v3" }, // 1x NVIDIA T4 networkProfile: { networkInterfaces: [{ id: nic.id, primary: true }], }, osProfile: { computerName: "ollama", adminUsername: "azureuser", adminPassword: adminPassword.result, customData: Buffer.from(userData).toString("base64"), // Azure quiere base64 linuxConfiguration: { disablePasswordAuthentication: false, }, }, storageProfile: { imageReference: { publisher: "Canonical", offer: "ubuntu-24_04-lts", sku: "server", // Gen2; NC4as_T4_v3 es una versión de tamaño Gen2: "latest", }, osDisk: { name: "ollama-osdisk", createOption: "FromImage", diskSizeGB: 40, managedDisk: { storageAccountType: "StandardSSD_LRS" }, }, }, }); // Una IP pública estándar/estática se reserva en la creación, así que su dirección es conocida // una vez que el recurso existe; no se necesita una búsqueda separada. export const publicIp = publicIpAddress.ipAddress.apply(ip => ip!); export const ollamaEndpoint = pulumi.interpolate`http://${publicIp}:11434`; export const generateEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/generate`; export const tagsEndpoint = pulumi.interpolate`http://${publicIp}:11434/api/tags`;

Algunos detalles por nube merecen ser destacados, ya que son los lugares donde la abstracción de "mismo programa" se filtra: AWS es el programa más corto, porque la GPU viene con la forma de la instancia: una g4dn.xlarge es una caja T4, por lo que no hay un acelerador separado que adjuntar. Lo único que tienes que hacer antes de desplegar es aumentar la cuota de vCPU de Instancias G y VT bajo demanda en tu región; una cuenta nueva comienza en cero, y pulumi up falla con VcpuLimitExceeded hasta que lo hagas. Google Cloud adjunta la GPU explícitamente con guestAccelerators, y eso trae la regla que complica a las personas: una instancia de GPU no puede vivir migrar, por lo que scheduling.onHostMaintenance debe ser "TERMINATE" o se rechaza el apply. El cloud-init también debe estar en la clave de metadatos user-data, no en startup-script, y accessConfigs: [{}] es lo que entrega la caja una IP pública. La cuota de T4 también es cero en un nuevo proyecto. Azure tiene la lista más larga, porque la red es à la carte: el grupo de recursos, la red virtual, el subnet, la IP pública, el grupo de seguridad y la NIC son cada uno su propio recurso antes de llegar a la VM. Un detalle sorprende a la gente: una VM de Linux requiere una credencial administrativa incluso cuando nunca inicias sesión, así que el programa genera una contraseña desechable con random.RandomPassword en lugar de comprometer una. El NC4as_T4_v3 es una GPU de cálculo, por lo que el controlador de servidor estándar (CUDA) del cloud-init es la elección correcta aquí; el controlador GRID solo se necesita para cargas de trabajo de visualización aceleradas por GPU. Los programas completos, los tres archivos Pulumi.<cloud>.yaml, y el cloud-init.yaml compartido están en el repositorio complementario: github.com/dirien/fully-automated-ai-inference-pulumi

Despliegue Crea un proyecto, instala el proveedor para tu nube, apunta el stack a pulumi-idp/auth y despliega. Para AWS: mkdir ai-inference && cd ai-inference pulumi new typescript npm install @pulumi/aws

Deja la lista de AWS en index.ts, copia el cloud-init.yaml mostrado anteriormente en el mismo directorio (o tómalo del repositorio complementario), apunta el stack al entorno de auth, y configura tu región: pulumi stack init aws # añade `environment: [pulumi-idp/auth]` a Pulumi.aws.yaml (mostrado arriba) pulumi config set region us-east-1 pulumi config set model qwen2.5:14b pulumi up

Las otras dos nubes tienen el mismo flujo con un paquete de proveedor diferente y un par de claves de configuración: npm install @pulumi/gcp con pulumi config set project <tu-id-de-proyecto> y pulumi config set zone us-central1-a, o npm install @pulumi/azure-native @pulumi/random con pulumi config set location eastus.

La parte lenta es la caja de GPU: se inicia, instala el controlador, reinicia, instala Ollama y carga el modelo, todo sin ti. Dependiendo de la instancia, la región y el tamaño del modelo, planea aproximadamente 10 a 15 minutos antes de que el punto final responda. Cuando pulumi up termina, obtienes los puntos finales directos como salidas del stack: Salidas: generateEndpoint: "http://<ip-pública>:11434/api/generate" ollamaEndpoint : "http://<ip-pública>:11434" publicIp : "<ip-pública>" tagsEndpoint : "http://<ip-pública>:11434/api/tags"

Pruebas del servidor de inferencias pulumi up devuelve tan pronto como la infraestructura existe, que es antes de que el modelo haya terminado de descargarse. Aquí es exactamente donde Terraform buscó ese bucle null_resource, y donde Pulumi te entrega una URL en su lugar. Para comprobar si el modelo está listo, pregunta a Ollama qué ha cargado: curl -s $(pulumi stack output tagsEndpoint) | grep -q "qwen2.5:14b" && echo "listo" || echo "aún cargando"

Una vez que informa que está listo, envíale un prompt. Esta es la misma solicitud que hace la publicación de Akamai, apuntada a tu salida generateEndpoint: curl -s $(pulumi stack output generateEndpoint) -d '{ "model": "qwen2.5:14b", "system": "Responde a cada pregunta con un poema de tres líneas.", "prompt": "¿Por qué es azul el cielo?", "stream": false }'

La primera llamada es más lenta, porque Ollama carga el modelo en la memoria GPU antes de responder. Cada llamada después de eso es rápida, ya que OLLAMA_KEEP_ALIVE=-1 lo mantiene residente: { "model": "qwen2.5:14b", "response": "La luz del sol se dispersa, las ondas cortas vuelan,\nLa luz azul pinta el cielo abierto,\nEl violeta se desvanece a medida que el día avanza.", "done": true }

Cuando termines, desmantela todo con un solo comando, así que la GPU deja de facturar: pulumi destroy

Costo Una instancia de GPU es toda la factura, y pagas por hora, ya sea que el modelo esté ocupado o inactivo. Los números a continuación son tasas aproximadas bajo demanda para una sola caja clase T4 dejada funcionando 24/7; en la práctica, la inicias, la usas y la destruyes con pulumi, así que lo que realmente pagas sigue las horas que permanece activa.

Nube Instancia GPU ~Por Hora ~Mensual (24/7) AWS g4dn.xlarge 1× T4 (16 GB) ~$0.53 ~$384 Azure Standard_NC4as_T4_v3 1× T4 (16 GB) ~$0.53 ~$384 Google Cloud n1-standard-4 + 1× T4 1× T4 (16 GB) ~$0.54 ~$394

Estas son tarifas de Linux bajo demanda en una región económica de EE. UU. (us-east-1, eastus, us-central1) a mediados de 2026, y la GPU domina la factura. La capacidad spot o preemptible la recorta drásticamente si tu carga de trabajo tolera interrupciones (a menudo 60 a 70 por ciento menos), el descuento por uso sostenido de Google Cloud recorta una instancia siempre activa por sí solo, y el disco de 40 GB agrega solo unos pocos dólares al mes. Una caja de GPU dejada funcionando durante la noche es el error costoso aquí.

Debido a que toda la pila es declarativa, el hábito seguro es barato: pulumi destroy cuando dejes de usarla, pulumi up cuando la necesites de nuevo. El estado y la configuración están controlados por versiones, así que volver a ponerlo en funcionamiento es un comando, no una reconstrucción.

Consideraciones de seguridad La arquitectura en esta publicación coincide con el original de Akamai a propósito, lo que significa que lleva la misma advertencia: Ollama está expuesto a través de HTTP simple en un puerto abierto a toda Internet. Eso está bien para una demostración en una caja que desmantelas el mismo día. No está bien para nada que sobreviva a la tarde, y los puntos finales de IA autohospedados se encuentran rápidamente: escáneres como Shodan enumeran uno recién expuesto en cuestión de horas, y una instancia abierta de Ollama es computación gratuita para quien lo encuentre primero. Sin embargo, el lado de las credenciales es realmente mejor que una configuración de token estático, y esa es la parte que vale la pena conservar. No hay clave de nube de larga duración en el programa, en tu shell o en CI; cada despliegue obtiene credenciales de corta duración generadas a través de pulumi-idp/auth y se desechan al finalizar.

Preocupación demo de Akamai/Terraform Este despliegue Credenciales de la nube Token PAT estático en una variable de entorno Inicio de sesión OIDC, de corta duración, a través de pulumi-idp/auth Exposición entrante Puerto 11434 abierto a 0.0.0.0/0 Igual por defecto; un flag de configuración delimita el CIDR Transporte HTTP simple HTTP simple (frontal con TLS para un uso real) Esperar disponibilidad null_resource + bucle local-exec Un curl en tiempo de ejecución, sin recurso

Mis recomendaciones para llevar esto más allá de una demostración: Restringe la regla de entrada a tu propio CIDR en lugar de 0.0.0.0/0; el flag allowSsh en el programa es el patrón a copiar para el puerto de Ollama. Coloca un proxy inverso con TLS frente a Ollama, o mantén la caja fuera de Internet pública y accede a ella a través de una red privada o una tailnet de Tailscale, como hice con el agente Hermes. Mantén las credenciales en OIDC a través de ESC; nunca vuelvas a una clave estática para "simplemente hacerlo funcionar". Trata la caja como desechable y pulumi destroy cuando no la estés usando, lo que reduce tanto la factura como la ventana de ataque.

¿Qué sigue? El programa es una base, y el trabajo interesante es lo que agregas una vez que un modelo está a un pulumi up de distancia: Asegúralo. Restringe el firewall, añade TLS, o mueve la caja detrás de una red privada para que el punto final no esté en Internet pública en absoluto. Escala el modelo a la GPU. qwen2.5:14b en un T4 es el punto de entrada. Los modelos más grandes quieren más memoria, lo que significa un A10G, un L4 o un A100, y el único cambio en el programa es el tipo de instancia y el valor del modelo. Intercambia el motor de entrega. Ollama es lo más simple que funciona. Para un mayor rendimiento, la misma forma se mantiene con vLLM en lugar de Ollama en el cloud-init. Genera el siguiente. Pulumi Neo puede tomar un objetivo como "una caja de inferencia de GPU en Azure detrás de un punto final privado" y producir un primer borrador del programa, que luego revisas en un PR como cualquier otro cambio.

Conclusión Declarar un servidor de inferencia de IA como código te compra lo mismo que le compra a cualquier otra infraestructura: puedes reproducirlo en cualquiera de las tres nubes, controlar su versión y derribarlo con un solo pulumi destroy. Portar el proyecto de Akamai hizo que el contraste fuera agudo. Las credenciales pasaron de un token estático a un inicio de sesión OIDC que no deja nada atrás, y la espera de disponibilidad desapareció por completo una vez que dejas de tratar una verificación en tiempo de ejecución como un recurso. Esa es la línea que vale la pena llevar a lo siguiente que despliegues. No cada paso en un libro de ejecución es un recurso. Una caja de GPU y una regla de firewall lo son; esperar a que termine una descarga no. Dibuja esa línea primero, y el programa se acorta solo. Si encuentras problemas o tienes preguntas, visita Slack de la Comunidad de Pulumi o GitHub Discussions. ¿Nuevo en Pulumi? Comienza aquí.

Artículos relacionados