Twelve Factor con Python

Por @gnarvaja de http://radiocut.fm

RadioCut

RadioCut

  • "donde la radio se hace viral, on-demand y social"
  • 2M visitas por mes
  • 1.6M horas escuchadas por mes
  • Usamos: Django, Flask, Google Cloud, IBM Cloud
  • Sobre mí: pythonista desde 2004 / "empresario" o "co-founder"

The Twelve Factor App

  • https://12factor.net/es/
  • Guía para el desarrollo de web apps o (micro)servicios
  • Formatos declarativos para automatización de la configuración
  • Contrato claro con el sistema operativo, máxima portabilidad
  • Cloud enabled (Docker / Kubernetes)
  • Mínimizan diferencias entre entornos de desarrollo y producción
  • Escalan horizontalmente

I. Código base (Codebase)

Un código base sobre el que hacer el control de versiones y múltiples despliegues

Many Deploys

II. Dependencias

Declarar y aislar explícitamente las dependencias
  • pip para instalación de dependencias
  • virtualenv / venv para aislar varios proyectos en la misma máquina
  • Limitaciones de pip
  • Docker para mayor aislación y portabilidad

requirements.txt

Django==2.2
djangorestframework==3.10
gunicorn[gevent]
celery[redis]
mysqlclient
django-mysql
requests>=2.18.0,<3
json-logging-py
environs
django-ipware
geoip2
kombu==4.6.4
-e git+https://github.com/gnarvaja/python-redis-rate-limit#egg=python-redis-rate-limit-20191011
          

dev_requirements.txt

ipdb
ipython
rpdb
colorama
factory_boy
freezegun
coverage
responses

Dockerfile

FROM python:3-alpine

RUN apk add --no-cache jpeg-dev zlib-dev \
    && apk add --no-cache --virtual .build-deps build-base \
                              linux-headers \
    && pip install --no-cache-dir --upgrade pip \
    && pip install --no-cache-dir Pillow \
                Flask \
                gunicorn[gevent] \
                google-cloud-storage \
    && apk del .build-deps

III. Configuraciones

Guardar la configuración en el entorno
  • Qué es configuración y qué código?
  • Entorno vs. archivos
  • Granularidad
  • Dockerfile y .env
  • Librería recomendada: https://pypi.org/project/environs/

environs

from environs import Env
env = Env()

BASE_URL = env.str("BASE_URL", "https://radiocut.fm")
FREE_TRIAL_DAYS = env.int("FREE_TRIAL_DAYS", 7)
APP_ENV = env.str("APP_ENV", "production")
DEBUG = env.bool("DEBUG", APP_ENV != "production")
SECRET = env("SECRET")  # => raises error if not set
COMPLEX_CONFIG = env.json("COMPLEX_CONFIG")
DATABASES = {
    'default': env.dj_db("DB_DEFAULT")
}
TWITTER_AUTHS = env.ext_file("TWITTER_AUTHS", {})
DB_DEFAULT: postgres://${PGUSER:-gnarvaja}:${PGPASS}@radiocut-pg-cluster-pooler/radiocut
DICT_VARIABLE: max_error_count=100,timeout=600
SLAVE_DATABASES: default,replica,replica

IV. Backing services

Tratar a los “backing services” como recursos conectables
  • Backing services: DB, Rabbit, SMTP, etc.
  • Sin distinción entre servicios locales o externos, ni propios o ajenos
  • Todo conectado por configuración
  • docker-compose para entorno local

docker-compose.yml


version: "3"

services:
  django:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: userservice
    env_file: .env
    depends_on:
      - mysql
    expose:
      - "8000"
    ports:
      - 8000:8000
    networks:
      - composedevenv_ggnet
  celery:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: userservice-celery
    command: ["sh", "-c", "celery worker -A ggUserService.celery -Q userService -l info --concurrency=20"]
    env_file: .env
    depends_on:
      - mysql
    networks:
      - composedevenv_ggnet
  mysql:
    image: mariadb:10.2.12
    container_name: mysql
    environment:
      MYSQL_DATABASE: ${MYSQL_DBNAME}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_RANDOM_ROOT_PASSWORD: 1
    networks:
      - composedevenv_ggnet
networks:
  composedevenv_ggnet:
    external: true   # Uses external network with Redis cluster created by k8s-cluster/compose-dev-env/

V. Construir, desplegar, ejecutar

Separar completamente la etapa de construcción de la etapa de ejecución

Build release run

Docker/K8s y django


...
ENV STATIC_ROOT /static
RUN ./manage.py collectstatic
RUN ./manage.py compilemessages
...

...
      initContainers:
      - name: run-db-migration
        image: gcr.io/my-service:0.1.54
        command: ['./manage.py migrate']
...

VI. Procesos

Ejecutar la aplicación como uno o más procesos sin estado
  • HTTP es stateless, esto es fácil no?
  • Share nothing
  • Sesiones: usar la BD o Redis, nunca archivos o memoria
  • ImageField y FileField en Django
  • Django Storages

VII. Port binding

Publicar servicios mediante asignación de puertos
  • Exponer los servicios vía un puerto (habitualmente HTTP)
  • Evitar mod_wsgi u otras opciones donde nuestro código corre dentro de Apache u otro web server
  • Evitar el modelo de PHP corriendo dentro de Apache o Java apps corriendo en Tomcat

VIII. Concurrencia

Escalar mediante el modelo de procesos
  • Evitar threads
  • Con Kubernetes, la recomendación es un proceso por container, múltiples réplicas
  • Celery para long-running background tasks (-P solo)
  • Considerar gevent

gunicorn[gevent]

  • Cómo funciona?
  • Escalar dentro de un proceso, sin los problemas de los threads
  • Sirve si la app es I/O bounded
  • psycogreen

IX. Desechabilidad

Hacer el sistema más robusto intentando conseguir inicios rápidos y finalizaciones seguras
  • Minimizar tiempo de startup
  • Compilación de assets u otras tareas de inicio, al build
  • Manejo de SIGTERM y muerte súbita

X. Paridad en desarrollo y producción

Mantener desarrollo, preproducción y producción tan parecidos como sea posible

Los tres gaps

  1. Gap de tiempo
  2. Gap de personal
  3. Gap de herramientas

Mi solución: Docker en desarrollo y Kubernetes en producción

Docker en desarrollo - docker-compose.override.dev.yml


version: "3"
services:
  django:
    command: ["sh", "-c", "while [ 0 ]; do date; sleep 30 ; done"]
    build:
      args:
        APP_ENV: "development"
    volumes:
      - ./ggUserService:/usr/local/app/
  celery:
    command: ["sh", "-c", "while [ 0 ]; do date; sleep 30 ; done"]
    build:
      args:
        APP_ENV: "development"
    volumes:
      - ./ggUserService:/usr/local/app/
    ports:
      - 4445:4444
  postgres:
    volumes:
      - /mnt/memdisk:/var/lib/postgresql/data

Docker en desarrollo


...
ARG APP_ENV="production"
ENV APP_ENV $APP_ENV

RUN if [ $APP_ENV != "production" ]; then pip install -r /testing_deps.txt; fi
ADD docker/run-manage-in-build.sh /run-manage-in-build.sh
...

Docker en desarrollo - Trabajar cómodo


pip install inv-py-docker-k8s-tasks
inv start-dev
inv django
inv pyshell
inv djshell
inv manage migrate

XI. Logs

Tratar los logs como un stream de eventos
  • import logging
  • No ocuparse del storage o ruteo de los logs
  • Emitir a salida standard
  • La captura de los logs es responsabilidad del entorno de ejecución
  • StackDriver / Elastic Stack (LogStash / Filebeat / Fluentd)
  • Logs estructurados con structlog

structlog

>>> from structlog import get_logger
>>> log = get_logger()
>>> log.info("key_value_logging", out_of_the_box=True, effort=0)
2016-04-20 16:20.13 key_value_logging              effort=0 out_of_the_box=True
>>> log = log.bind(user="anonymous", some_key=23)
>>> log = log.bind(user="hynek", another_key=42)
>>> log.info("user.logged_in", happy=True)
2016-04-20 16:20.13 user.logged_in                 another_key=42 happy=True some_key=23 user='hynek'

structlog

# structlog setup
# http://www.structlog.org/en/16.0.0/standard-library.html#suggested-configuration
import structlog   # noqa

structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.dev.ConsoleRenderer()
            if env.bool("DEV_ENV", False) else structlog.processors.JSONRenderer()
    ],
    context_class=dict,
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.stdlib.BoundLogger,
    cache_logger_on_first_use=True,
)

XII. Admin processes

Ejecutar las tareas de gestión/administración en la misma imagen, como procesos que empiezan y terminan
  • Ej: los comandos de Django que se incluyen en el código
  • En Kubernetes CronJobs sobre la misma imagen / configuración
  • inv update-templates -a

¿Preguntas?

Gracias!