Cómo llevar un script Python a Cloud Run


1. Qué es

Cloud Run es un servicio de Google Cloud que toma un script local, lo empaqueta y lo sube a la nube. A cambio te da un URL. Cada vez que alguien llama ese URL, tu script se ejecuta.

El único requisito es que el script "hable HTTP" — o sea, que tenga un servidor que esté escuchando y sepa recibir peticiones.

2. El script base (solo lógica, sin HTTP)

Punto de partida: un script Python que hace algo y devuelve un resultado. En la práctica usamos contar_palabras.py — recibe un texto y devuelve cuántas palabras tiene.

def contar_palabras(texto: str) -> int:
    return len(texto.split())


if __name__ == "__main__":
    texto = input("Ingresa un texto: ")
    resultado = contar_palabras(texto)
    print(f"El texto tiene {resultado} palabras")

Se puede probar directamente desde la terminal:

cd ~/scripts-datablog/google-cloud/script-python-a-cloud-run
python3 -m venv venv && source venv/bin/activate
python contar_palabras.py

Esto funciona perfecto en local. El problema es que Cloud Run no puede activarlo porque no tiene forma de llamarlo desde afuera.

3. La diferencia entre local y la nube

En local y en la nube el script hace lo mismo, pero el canal para pasarle los datos es completamente distinto:

En local:

  • Tú arrancas el script manualmente: python contar_palabras.py
  • El script te pregunta: Ingresa un texto:
  • Tú escribes en la terminal y el script responde
  • El canal es la terminal

En la nube:

  • Nadie arranca el script manualmente — Cloud Run lo despierta cuando llega una palmadita HTTP
  • No hay terminal donde escribir
  • El usuario manda los datos dentro del URL
  • El canal es la petición HTTP
# en local
python contar_palabras.py
→ Ingresa un texto: hola mundo

# en la nube
GET https://mi-script.run.app/contar-palabras?texto=hola mundo

El input() desaparece. El texto ya no lo escribe nadie en una terminal — viaja dentro del URL. Para que el script sepa recibir eso, necesita hablar HTTP. Eso es lo que resuelve FastAPI.

4. Hacer que el script hable HTTP en local

palmadita HTTP → uvicorn (servidor local) → FastAPI (intermediario) → tu función Python

La palmadita no le llega al script directamente — le llega al servidor. El servidor la recibe y llama al script. El script solo ejecuta lógica, no sabe nada de HTTP.

  • uvicorn — abre el canal y recibe la palmadita
  • FastAPI — lee la palmadita y decide qué función ejecutar. Sin FastAPI, uvicorn recibiría la palmadita y no sabría qué hacer con ella. FastAPI es el que tiene las instrucciones: a qué ruta llegó, qué método es, qué datos trae y qué función ejecutar con eso
  • tu función — ejecuta la lógica y devuelve el resultado

Las opciones más usadas de framework en Python son Flask y FastAPI. En este tema usamos FastAPI.

4.1 Levantar un servidor en local

Antes de subir a Cloud Run, se prueba todo en local levantando un servidor — es decir, abrir un canal en tu Mac que quede esperando palmaditas. Es el equivalente a npm run dev en JavaScript.

uvicorn no se instala por separado — viene incluido en requirements.txt. Al correr pip install -r requirements.txt se instalan tanto fastapi como uvicorn juntos.

# primera vez
source venv/bin/activate
pip install -r requirements.txt

# siguientes veces
source venv/bin/activate
uvicorn main:app --reload

Mientras ese comando está corriendo en la terminal, tu Mac escucha en http://localhost:8000. Esa es la base — el host más el puerto. Los endpoints son rutas que se agregan encima:

http://localhost:8000        →  la base (no es un endpoint en sí)
http://localhost:8000/       →  endpoint raíz, definido con @app.get("/")
http://localhost:8000/contar-palabras →  otro endpoint, definido con @app.get("/contar-palabras")

Cuando escribes http://localhost:8000 en el navegador, estás llamando al endpoint / — la barra final está implícita. Cada endpoint responde de forma independiente.

5. Modificar el script para que hable HTTP

El script base contar_palabras.py solo tiene lógica — no sabe nada de HTTP. Para exponerlo como endpoint creamos un segundo archivo main.py que actúa como puente: importa la función y le dice a FastAPI qué hacer cuando llega una palmadita.

La convención para nombres de endpoints es kebab-case: palabras en minúsculas separadas por guiones (/contar-palabras). El endpoint / se reserva para el health check — confirma que el servicio está vivo sin ejecutar lógica de negocio.

from fastapi import FastAPI
from contar_palabras import contar_palabras

app = FastAPI()

@app.get("/")
def root():
    return {"status": "ok"}

@app.get("/contar-palabras")
def contar(texto: str):
    resultado = contar_palabras(texto)
    return {"texto": texto, "palabras": resultado}

Lo que hace cada línea:

  • from contar_palabras import contar_palabras — trae la función del otro archivo
  • FastAPI() — crea el intermediario que define qué hacer con cada palmadita
  • @app.get("/") — health check: responde {"status": "ok"} sin parámetros ni lógica
  • @app.get("/contar-palabras") — el endpoint real, nombrado en kebab-case
  • texto: str — recibe el texto desde el URL como parámetro (?texto=hola mundo)
  • return {...} — lo que devuelve se convierte automáticamente en JSON

5.1 Qué responde cada endpoint

/ — health check:

curl "http://localhost:8000/"
# {"status":"ok"}

/contar-palabras sin parámetro:

curl -i "http://localhost:8000/contar-palabras"
# HTTP/1.1 422 Unprocessable Entity
# {"detail":[{"type":"missing","loc":["query","texto"],"msg":"Field required","input":null}]}

FastAPI valida que todos los parámetros requeridos lleguen antes de ejecutar la función. Como texto: str es obligatorio y no llegó, responde 422 sin ejecutar la lógica.

/contar-palabras con parámetro:

Desde el navegador — los espacios se codifican con %20:

http://localhost:8000/contar-palabras?texto=esto%20es%20una%20prueba
→ {"texto":"esto es una prueba","palabras":4}

Desde curl — dos formas equivalentes:

# curl rechaza espacios directos en el URL — usar --data-urlencode para que los codifique solo
curl -G "http://localhost:8000/contar-palabras" --data-urlencode "texto=hola mundo esto es una prueba magica"
# {"texto":"hola mundo esto es una prueba magica","palabras":7}

# alternativa: codificar los espacios manualmente con %20
curl "http://localhost:8000/contar-palabras?texto=hola%20mundo%20esto%20es%20una%20prueba%20magica"
# {"texto":"hola mundo esto es una prueba magica","palabras":7}

Endpoint que no existe:

curl "http://localhost:8000/ruta-inventada"
# {"detail":"Not Found"}

FastAPI responde 404 automáticamente para cualquier ruta que no esté definida en main.py.


Resumen — hasta aquí

Con los pasos 1 al 5 ya se tiene un script Python funcionando como API REST en local:

  1. Script base (contar_palabras.py) — contiene solo la lógica, sin saber nada de HTTP
  2. main.py — actúa como puente: importa la función y define los endpoints con FastAPI
  3. uvicorn — levanta el servidor y abre el canal en http://localhost:8000
  4. FastAPI — recibe cada palmadita y la dirige al endpoint correcto según la ruta

Los endpoints definidos y sus respuestas:

Llamada Respuesta Por qué
GET / {"status":"ok"} Health check — confirma que el servicio está vivo
GET /contar-palabras?texto=hola {"texto":"hola","palabras":1} Ejecuta la lógica
GET /contar-palabras (sin parámetro) 422 Unprocessable Entity FastAPI detecta que falta texto
GET /ruta-inventada 404 Not Found La ruta no está definida en main.py

Limitación actual: el servidor solo recibe palmaditas desde la misma Mac. Para que cualquiera en internet pueda llamarlo hay que empaquetarlo y subirlo a Cloud Run — eso es lo que hacen los pasos siguientes.


6. Empaquetarlo en Docker

El problema sin empaquetar es este: tu script funciona en tu Mac porque tienes Python instalado, las dependencias instaladas y el sistema operativo correcto. Pero Cloud Run no sabe nada de eso — es una máquina en blanco.

Empaquetar significa meter el script junto con todo lo que necesita para funcionar — Python, dependencias, archivos — en una "caja" estandarizada llamada contenedor Docker. Cloud Run recibe esa caja y la ejecuta sin necesidad de instalar nada.

El contenedor resuelve eso:

tu Mac                        contenedor Docker
──────────────────            ──────────────────────────
Python 3.12 instalado    →    Python 3.12 incluido adentro
fastapi instalado        →    fastapi incluido adentro
uvicorn instalado        →    uvicorn incluido adentro
contar_palabras.py       →    contar_palabras.py adentro
main.py                  →    main.py adentro

El Dockerfile es la receta que le dice a Docker cómo construir esa caja. Independientemente de cómo se suba el código a Cloud Run — por CLI o por interfaz — Google Cloud lee el Dockerfile y construye el contenedor en sus propios servidores. No hace falta tener Docker instalado en local.

sin Docker local:   escribes la receta → Google Cloud cocina → Cloud Run sirve
con Docker local:   escribes la receta → tú cocinas y pruebas → Google Cloud cocina → Cloud Run sirve

Docker local sirve para detectar errores en el contenedor antes de subirlo. Para aprendizaje, con dejar que Google Cloud cocine es suficiente.

El Dockerfile no reemplaza al requirements.txt — los dos cumplen roles distintos:

  • requirements.txt — lista qué dependencias instalar (fastapi, uvicorn)
  • Dockerfile — orquesta todo: qué sistema base usar, dónde meter los archivos, cómo instalar las dependencias y cómo arrancar el servidor

El Dockerfile en realidad usa el requirements.txt en uno de sus pasos:

RUN pip install -r requirements.txt  ← aquí lee el requirements.txt

Cloud Run no recibe los archivos .py directamente — necesita el contenedor completo. El Dockerfile le dice cómo construirlo.

El Dockerfile usa su propio lenguaje — no es Python ni bash. Sus instrucciones son específicas de Docker y cada una representa un paso en la construcción del contenedor:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Lo que hace cada línea:

  • FROM python:3.12-slim — parte de una caja base que ya tiene Python 3.12 instalado. Docker tiene una biblioteca de cajas base, no hay que instalar Python desde cero
  • WORKDIR /app — dentro de la caja, trabaja en la carpeta /app. Es como hacer cd /app — todos los comandos siguientes se ejecutan desde ahí
  • COPY requirements.txt . — copia el requirements.txt de tu Mac a la carpeta /app de la caja
  • RUN pip install -r requirements.txt — instala las dependencias dentro de la caja. Aquí es donde fastapi y uvicorn quedan instalados adentro del contenedor
  • COPY . . — copia todo lo demás de tu carpeta a la caja: contar_palabras.py, main.py, etc.
  • CMD [...] — cuando alguien encienda esta caja, ejecuta este comando. Es el equivalente a escribir uvicorn main:app en la terminal, pero se ejecuta automáticamente al arrancar el contenedor en Cloud Run

El CMD usa flags distintos a los del servidor local porque en Cloud Run las palmaditas vienen de afuera del contenedor: - --host 0.0.0.0 — en local uvicorn escucha solo en 127.0.0.1 (tu Mac). Con 0.0.0.0 escucha en todas las interfaces - --port 8080 — en local uvicorn usa el puerto 8000. Cloud Run espera que el contenedor escuche en el 8080

7. Evolución de archivos del proyecto

El proyecto fue creciendo en tres etapas según lo que necesitábamos:

Etapa 1 — script local puro (solo lógica, se ejecuta desde la terminal):

script-python-a-cloud-run/
├── contar_palabras.py    → la lógica
└── requirements.txt      → las dependencias

Etapa 2 — script que habla HTTP (funciona como API en local):

script-python-a-cloud-run/
├── contar_palabras.py    → la lógica
├── main.py               → el puente HTTP (endpoints, FastAPI)
└── requirements.txt      → las dependencias

Etapa 3 — listo para Cloud Run (empaquetado, deployable a la nube):

script-python-a-cloud-run/
├── contar_palabras.py    → la lógica
├── main.py               → el puente HTTP (endpoints, FastAPI)
├── requirements.txt      → las dependencias
└── Dockerfile            → la receta del contenedor

8. Subirlo a Cloud Run

gcloud run deploy mi-script \
  --source . \
  --region us-central1 \
  --allow-unauthenticated

--source . le dice a gcloud que construya la imagen Docker desde la carpeta actual y la suba directo. Al terminar, devuelve un URL.

Para poder ejecutar este comando hay que conocer primero los siguientes temas:

  1. Google Cloud CLI — LINK PENDIENTE
  2. Proyectos en Google Cloud — LINK PENDIENTE
  3. APIs en Google Cloud (Cloud Run y Cloud Build ) — LINK PENDIENTE

9. ¿Cómo quedaría en Cloud Run?

tú → GET https://mi-script-abc123-uc.a.run.app
        ↓
   Cloud Run despierta el contenedor
        ↓
   FastAPI recibe la petición
        ↓
   Se ejecuta tu función Python
        ↓
   Devuelve JSON como respuesta

Diferencia con local: El flujo es idéntico — cambia solo que ahora cualquier persona en internet puede hacer palmaditas, no solo tu Mac.

Riesgos:

  • Cualquiera puede hacer una palmadita a tu endpoint siempre — con o sin restricciones. Lo que cambia con --allow-unauthenticated es que cualquiera recibe la respuesta completa sin necesidad de identificarse
  • Si alguien encuentra el URL y lo llama miles de veces, tu script se ejecuta miles de veces — esto puede generar costos inesperados o saturar el servicio. Esto se controla con rate limiting (máximo N llamadas por minuto), autenticación (token requerido) o Cloudflare como intermediario. El rate limiting por IP solo no es suficiente — se puede evadir con proxies

Costos:

  • Cloud Run cobra por tiempo de ejecución — cada vez que llega una palmadita y el contenedor se activa
  • Tiene una capa gratuita generosa (2 millones de solicitudes al mes), suficiente para aprendizaje
  • Si nadie llama el URL, no hay costo — el contenedor está apagado

10. Hasta dónde puedes llegar con Cloud Run

Cloud Run no es solo para scripts simples. La palmadita (petición HTTP) puede desencadenar cosas cada vez más complejas:

Nivel Qué hace Método
1 Script simple — un URL, una función, una respuesta GET
2 Varios endpoints en un mismo Cloud Run GET
3 Recibe datos más complejos en el body POST
4 Conecta con otra API externa (OpenAI, Gemini, etc.) GET / POST
5 Lee o escribe en una base de datos GET / POST
6 Aplicación web completa — devuelve HTML en vez de JSON GET, POST, DELETE

Estamos en el nivel 1. Cada nivel siguiente agrega una capa de complejidad pero el principio es siempre el mismo: palmadita → Cloud Run despierta → hace algo → devuelve respuesta.

Gotchas

  • Cloud Function vs Cloud Run — las dos ejecutan código en respuesta a palmaditas HTTP, pero la diferencia es quién resuelve el HTTP. En Cloud Function lo resuelve Google con su propio framework (@functions_framework.http) — solo necesitas main.py y requirements.txt. En Cloud Run lo resuelves tú con FastAPI + uvicorn + Dockerfile. Más trabajo, más control. Para una función simple, Cloud Function es más rápido; Cloud Run vale cuando tienes varios endpoints o necesitas controlar el entorno.
  • Cloud Run apaga el contenedor cuando no hay tráfico — no es un servidor permanente, se "despierta" con cada petición
  • Los espacios en el URL se convierten automáticamente a %20 — es la forma estándar de representar espacios en HTTP. FastAPI los decodifica solo, la función recibe el texto normal:
  • Abuso de endpoints públicos: Si alguien encuentra el URL y lo llama miles de veces, el script se ejecuta miles de veces — generando costos o saturando el servicio. El rate limiting por IP no es suficiente porque se puede evadir con proxies. La primera línea de defensa real es la autenticación. Para protección adicional sin depender del proveedor de nube, Cloudflare actúa como intermediario (usuario → Cloudflare → Cloud Run) y filtra tráfico abusivo antes de que llegue al servicio.

Referencia oficial