Patrón Singleton en Go: Implementación fácil con sync.Once

Implementar el patrón Singleton en Go es una técnica útil cuando necesitas garantizar que solo exista una única instancia de un objeto en toda tu aplicación. El lenguaje Go ha sido diseñado con una fuerte inclinación hacia la simplicidad y la concurrencia segura. Una de sus primitivas menos conocidas, pero sumamente útiles, es sync.Once.

¿Qué es sync.Once?

sync.Once es una estructura del paquete sync de la biblioteca estándar de Go. Expone un único método:

func (o *Once) Do(f func())

Este método ejecuta la función f solo una vez, sin importar cuántas veces se llame desde múltiples goroutines. Todas las llamadas posteriores a Do se bloquean hasta que la función se haya completado, pero no volverán a ejecutarla.

Patrón Singleton con sync.Once

El patrón Singleton garantiza que una clase tenga solo una instancia y proporciona un punto de acceso global a ella. En Go, con sync.Once, esta implementación es a prueba de race conditions y sin necesidad de bloqueos explícitos con sync.Mutex.

Ejemplo práctico:

package main

import (
    "fmt"
    "sync"
)

type Singleton struct {
    data string
}

var (
    instance *Singleton
    once sync.Once
)

func GetInstance() *Singleton {
    once.Do(func() {
        fmt.Println("Creating Singleton instance")
        instance = &Singleton{data: "I'm the only one!"}
    })
    return instance
}

func main() {
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Printf("%p\n", GetInstance())
        }()
    }

    fmt.Scanln()
}

Salida esperada:

Creating Singleton instance
0xc000096010
0xc000096010
0xc000096010
0xc000096010
0xc000096010

Como se observa, sin importar cuántas veces se invoque GetInstance, se crea una sola instancia y todas las goroutines acceden a ella.

Casos de uso prácticos

  1. Carga de configuración global: Ideal para archivos .env, variables de entorno o YAML que deben cargarse una sola vez.
  2. Conexiones compartidas: Evita múltiples conexiones a recursos costosos como bases de datos o servicios externos.
  3. Inicialización de estructuras complejas: Como caches en memoria (ej. sync.Map o estructuras personalizadas).
  4. Plugins o módulos registrados una sola vez: En sistemas modulares, evita registros repetidos de componentes.

Consideraciones importantes

  • La función pasada a Do() debe ser idempotente o segura ante fallos. Si entra en pánico o falla a mitad de ejecución, Do no volverá a intentar ejecutarla.
  • Si necesitas reintentos o reinicialización, sync.Once no es la herramienta adecuada. Considera usar un patrón con sync.Mutex o control manual.

Conclusión

Aunque sync.Once puede parecer trivial, su utilidad en sistemas concurrentes es invaluable. Su rol en patrones como Singleton demuestra cómo Go, sin depender de herencia o clases, puede ofrecer soluciones limpias y eficientes a problemas clásicos de diseño.

En el mundo real, usar sync.Once correctamente puede ayudarte a evitar errores difíciles de depurar, como inicializaciones múltiples o conflictos de acceso a recursos compartidos. Si aún no lo has incorporado en tus proyectos, este puede ser el momento perfecto para comenzar.

Entendiendo los Namespaces en Python de manera práctica

A menudo escuchamos el término namespaces en contextos como el kernel de Linux o Kubernetes, donde se utilizan principalmente para aislar o agrupar recursos de manera lógica. Pero, alguna vez te has preguntado ¿qué significa un namespace en el contexto de Python?

En este artículo quiero compartir lo que he aprendido sobre los namespaces en Python: qué son, para qué sirven y cuándo conviene utilizarlos.

¿Qué es un Namespace en Python?

Un namespace en Python es, en términos simples, un espacio donde se asignan nombres a objetos. Cada nombre (variable, función, clase, módulo, etc.) vive dentro de un namespace. Cuando se hace referencia a un identificador (por ejemplo, x), Python busca ese nombre en el namespace correspondiente para determinar a qué objeto se refiere.

Tras bambalinas, Python maneja los namespaces como diccionarios que mapean nombres a objetos.

Tipos de Namespaces

Existen distintos tipos de namespaces, y entender su jerarquía nos ayuda a comprender el alcance de los objetos en nuestro código. Los cuatro principales son:

  1. Local: Existe solo dentro de una función. Se crea al momento de invocar la función y se destruye al salir de ella.
  2. Enclosing (no local): Corresponde a las funciones anidadas, es decir, una función dentro de otra.
  3. Global: El namespace del módulo o script actual.
  4. Built-in: El nivel más alto, proporcionado por Python, donde viven funciones como len(), print(), etc.

Python utiliza una regla conocida como LEGB (Local → Enclosing → Global → Built-in) para resolver nombres en tiempo de ejecución.

globals() vs locals()

Python expone funciones integradas para inspeccionar y, en algunos casos, modificar los los namespaces en tiempo de ejecución:

  • globals() retorna una referencia al diccionario del namespace global.
  • locals() retorna una copia del namespace local (en el contexto actual).

Ejemplo:

x=10
def demo():
    y=20
    print("globals:", list(globals().keys()))
    print("locals:", locals())
demo()

Salida:

globals: ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'x', 'demo']
locals: {'y': 20}

Como se observa, globals() muestra todas las definiciones en el ámbito global, mientras que locals() muestra las variables locales disponibles dentro de la función demo().

Aunque es técnicamente posible modificar variables a través de estos diccionarios, no se recomienda hacerlo directamente. Es más claro y seguro utilizar las palabras reservadas global y nonlocal cuando se necesita alterar variables fuera del ámbito actual.

Usando global

La palabra reservada global permite modificar una variable definida en el namespace global desde dentro de una función.

Ejemplo:

counter = 0
def increase():
    global counter
    counter += 1

increase()
print(counter)

En este ejemplo, la variable counter vive en el espacio global, y gracias a la declaración global counter, la función increase() puede modificarla directamente. Sin esa declaración, Python interpretaría counter como una variable local, y lanzaría un UnboundLocalError.

Usando nonlocal

La palabra reservada nonlocal permite modicar variables de función desde una función anidada.

Ejemplo:

def counter_func():
    counter = 0

    def increase():
        nonlocal counter
        counter += 1
        return counter

    return increase

f = counter_func()
print("Fist call:", f())    # Fist call: 1
print("Second call:", f())  # Second call: 2

Aquí, nonlocal permite a la función interna increase() modificar la variable counter definida en su función envolvente counter_func(). Sin nonlocal, se crearía una nueva variable local dentro de increase().

Conclusión

Los namespaces en Python son una herramienta fundamental para organizar y controlar el alcance de las variables. Entender su funcionamiento te permitirá escribir código más claro, predecible y libre de errores sutiles relacionados con el scope. Tanto global como nonlocal deben usarse con precaución, y las funciones globals() y locals() pueden llegar a ser útiles durante la depuración de un programa.

Creando MCP tools dinámicamente con compile() y exec()

Recientemente he explorado un aspecto de Python que rara vez se utiliza en entornos convencionales: la generación dinámica de código utilizando compile() y exec(). Aunque estas funciones suelen estar asociadas a casos de uso avanzados —y a menudo evitadas por sus riesgos de seguridad—, en este proyecto fueron clave para lograr una solución práctica y flexible.

En este artículo comparto un caso práctico dentro del proyecto mcp-openapi-proxy, donde fue necesario generar MCP Tools de manera dinámica a partir de una especificación OpenAPI. Explico el problema, las limitaciones encontradas y cómo llegué a una solución utilizando estas potentes funcionalidades del lenguaje Python.

El problema

MCP (Model Context Protocol) es un protocolo diseñado para que aplicaciones que utilizan LLMs, como agentes AI, puedan consumir servicios externos de manera estructurada y segura. En términos prácticos, un servidor MCP define un conjunto de tools que describen las operaciones que un modelo puede invocar para interactuar con un servicio exterior.

El propósito principal del proyecto era crear un proxy que, a partir de una especificación OpenAPI, generara automáticamente todas las MCP tools necesarias para exponer los endpoints definidos. Este proceso debía cumplir con los siguientes requisitos:

  • Leer una especificación OpenAPI.
  • Identificar los endpoints, sus métodos y parámetros.
  • Generar funciones de Python correspondientes a cada operación.
  • Exponer estas funciones como MCP Tools.

Esta solución debía ser altamente adaptable y no depender de implementaciones estáticas. El objetivo era evitar la escritura manual de código cada vez que se agregara o modificara un servicio en la especificación de OpenAPI.

Usando compile() y exec()

Una vez obtenidas las operaciones definidas a través del parseo de la especificación OpenAPI, es necesario proceder con la generación dinámica de las MCP Tools.

Con compile() y exec(), Python permite compilar y ejecutar código definido dinámicamente, ya sea desde cadenas de texto o archivos externos. Aunque este enfoque debe usarse con precaución, en este caso ofrecía exactamente el nivel de flexibilidad necesario para cumplir con el propósito deseado.

¿Cómo funciona compile()?

La función compile() requiere de tres argumentos obligatorios:

  1. El código fuente, que puede ser una cadena de texto (str), una cadena de bytes (bytes) o un objeto AST.
  2. El nombre del archivo desde el cual se supone que proviene el código fuente. Si no existe un archivo real (como en este caso), se suele pasar un nombre ficticio como «<string>» por convención.
  3. El modo de compilación, que determina cómo se debe interpretar el código fuente. Existen tres valores válidos:
    • «exec»: cuando el código contiene una o más instrucciones (como def, import, print, etc.).
    • «eval»: cuando el código consiste en una sola expresión (por ejemplo, 3 + 5 o x * y + z).
    • «single»: cuando el código contiene una sola instrucción que debe ejecutarse de forma interactiva, útil en entornos tipo REPL.

¿Cómo funciona exec()?

La función exec() permite la ejecución de programas Python de forma dinámica. Esta función puede recibir como argumento una cadena de texto con código o, de forma más eficiente, un objeto de código previamente compilado mediante compile().

Además, exec() puede recibir dos parámetros opcionales: globals y locals. Estos permiten definir el alcance (scope) en el que se ejecuta el código, lo cual es útil para controlar el acceso a variables y funciones dentro de contextos controlados.

Ejemplo práctico

El siguiente ejemplo muestra cómo generar una MCP tool a partir de un diccionario simulando una operación extraída de una especificación OpenAPI


operation = {
    "name": "get_user",
    "method": "GET",
    "path": "/users/{user_id}"
}

code = f'''
@mcp.tool
def {operation["name"]}(user_id):
    import requests
    response = requests.get(f"http://api.example.com{operation["path"]}".format(user_id=user_id))
    return response.json()
'''

compiled = compile(code, "<string>", "exec")
exec(compiled)

Consideraciones importantes

Usar exec() y compile() no están exentas de desafíos:

  • Seguridad: Ejecutar código generado dinámicamente implica riesgos, especialmente si la entrada no es de confianza. En este proyecto se asume que las especificaciones provienen de fuentes conocidas.
  • Debugging: El código generado no existe en archivos fuente, por lo que los errores pueden ser más difíciles de rastrear. Se recomienda imprimir o registrar el código generado durante el desarrollo.
  • Mantenibilidad: Aunque esta técnica reduce el código manual, puede dificultar la comprensión para otros desarrolladores no familiarizados con metaprogramación en Python.

Conclusión

El uso de compile() y exec() en Python puede parecer extremo, pero en escenarios como la generación dinámica de MCP Tools basadas en OpenAPI, se convierte en una solución elegante y poderosa. Como siempre, con gran poder viene una gran responsabilidad: es fundamental validar las entradas, aislar contextos de ejecución y mantener el código generado lo más transparente posible.

El código fuente completo del proyecto está disponible en GitHub.

Python Type Hints: Qué son, para qué sirven y cómo usarlos

Recientemente he retomado el uso de Python para mis proyectos personales y profesionales. Una de las mejoras más relevantes del lenguaje en la última década ha sido la incorporación de type hints en Python, también conocidos como anotaciones de tipo. Esta característica transforma por completo la forma en que escribimos, entendemos y mantenemos el código.

¿Qué son los type hints en Python?

Los type hints en Python permiten indicar de manera explícita los tipos de datos esperados en funciones, variables y estructuras. Aunque Python sigue siendo un lenguaje de programación dinámico, las anotaciones de tipo facilitan el análisis estático del código y mejoran su legibilidad.

Historia

Los type hints se introdujeron oficialmente en Python 3.5, en 2015, mediante la PEP 484. Su objetivo principal fue:

  • Mejorar la legibilidad del código.
  • Permitir análisis estático con herramientas como mypy, pyright o los LSP de los editores modernos.
  • Facilitar la documentación automática de funciones y clases.

Desde entonces, esta funcionalidad ha evolucionado notablemente con cada nueva versión de Python:

  • PEP 563 (Python 3.7): evaluación diferida de las anotaciones, mejora el rendimiento.
  • PEP 585 (Python 3.9): uso de tipos genéricos modernos (list[str] en lugar de List[str]).
  • PEP 604 (Python 3.10): uso del operador | para tipos unión (int | str).
  • PEP 695 (Python 3.12): simplificación de la sintaxis para genéricos con TypeVar.

Ventajas

Incorporar type hints en tus proyectos tiene múltiples beneficios:

  • Mayor legibilidad – Los tipos hacen que el propósito de una función sea más fácil de entender a simple vista.
  • Reducción de errores en tiempo de ejecución – Herramientas como mypy detectan discrepancias antes de ejecutar el código.
  • Mejor experiencia en el editor – Los IDEs ofrecen autocompletado y validación más precisa cuando se utilizan anotaciones de tipo.
  • Código más mantenible – Las anotaciones ayudan a otros desarrolladores a comprender mejor el flujo de datos y las expectativas del código.

Ejemplo práctico

Veamos un ejemplo clásico: una función que calcula el promedio de una lista de números.

Python 3.4

def promedio(numeros):
    return sum(numeros) / len(numeros)

Python +3.10

from typing import Sequence

def promedio(numeros: Sequence[int | float]) -> float:
    if not numeros:
        raise ValueError("La lista no puede estar vacía")
    return sum(numeros) / len(numeros)

Este enfoque es mucho más robusto y explícito, lo cual ayuda a mejorar la calidad del código y facilita su análisis estático.

Conclusión

Aunque los type hints en Python no son obligatorios, su uso marca una diferencia significativa en proyectos de largo plazo. Aportan claridad al código, reducen errores, aceleran el onboarding de nuevos desarrolladores y mejoran la experiencia general de desarrollo en equipo.

Te recomiendo incorporarlos de forma gradual en tus proyectos. Comienza por las funciones más complejas o críticas y, a medida que el código evoluciona, extiende su uso al resto del proyecto. Con el tiempo, notarás una mejora sustancial en la calidad y mantenibilidad del código.

Gestión de proyectos de Python con `uv`

La gestión de entornos y dependencias en Python ha sido históricamente un punto de fricción para muchos desarrolladores. A lo largo del tiempo han surgido herramientas como pip, virtualenv, pip-tools o Poetry, cada una con su propio enfoque para resolver estos retos. Sin embargo, muchas veces implican compromisos en rendimiento, simplicidad o compatibilidad. En este artículo exploraremos cómo uv, una herramienta moderna escrita en Rust, ofrece una alternativa rápida y eficiente para gestionar entornos virtuales y resolver dependencias de forma más fluida y predecible.

En este artículo te presento uv, una herramienta moderna y ultrarrápida para la gestión de proyectos en Python. Escrita en Rust, uv combina la funcionalidad de múltiples herramientas tradicionales en una sola solución liviana, eficiente y fácil de usar. Desde la instalación de dependencias hasta la creación de entornos virtuales con versiones específicas de Python, uv se perfila como una excelente alternativa para mejorar tus flujos de desarrollo.

Ya sea que trabajes en proyectos personales, empresariales o colaborativos, adoptar uv puede ayudarte a reducir tiempos de configuración, evitar conflictos de versiones y simplificar el manejo de herramientas CLI dentro del ecosistema Python.

¿Qué es uv?

uv es una herramienta desarrollada por Astral que proporciona una forma ultrarrápida de manejar la instalación de dependencias, creación de entornos virtuales y resolución de versiones. Esta herramienta fue escrita en Rust, lo que le otorga una gran ventaja en velocidad frente a herramientas tradicionales como pip, virtualenv o Poetry.

Entre sus principales características destacan:

  • Instalación de dependencias extremadamente rápida.
  • Resolución de conflictos de versiones con una experiencia más predecible.
  • Compatibilidad con los archivos pyproject.toml y requirements.txt.
  • Sustitución directa de herramientas como pip, pip-tools, venv y Poetry.

Instalación

Puedes instalar uv de forma muy sencilla desde la terminal con el siguiente comando:

$ curl -LsSf https://astral.sh/uv/install.sh | sh

Esto descargará e instalará la versión más reciente de uv directamente desde su repositorio oficial.

Casos de uso

Creación de código portable

Uno de los casos de uso más interesantes de uv es la posibilidad de crear scripts de Python autocontenidos, es decir, que incluyan las dependencias necesarias declaradas directamente en el archivo fuente. Esto permite distribuir aplicaciones pequeñas de forma mucho más simple, sin necesidad de archivos adicionales como requirements.txt.

Veamos un ejemplo básico. Supongamos que queremos crear un script que imprima un emoji:

from emoji import emojize

print(emojize(":thumbs_up:"))

El código anterior requiere el módulo de emoji, y en lugar de declarar esta dependencia en un archivo externo, podemos usar uv para integrarla directamente al script:

$ uv add --script app.py 'emoji'

Este comando modifica el archivo app.py agregando la información de las dependencias como comentarios:

# /// script
# requires-python = ">=3.8"
# dependencies = [
#     "emoji",
# ]
# ///
from emoji import emojize

print(emojize(":thumbs_up:"))

A partir de aquí, cualquier desarrollador que tenga uv instalado puede ejecutar el script directamente con:

$ uv run app.py
Installed 2 packages in 6ms
👍

Este enfoque no solo simplifica la distribución, sino que también mejora la trazabilidad y portabilidad del código, algo especialmente útil para scripts, prototipos o herramientas CLI internas.

Administración de ambientes virtuales

Otro caso de uso interesante de uv es la flexibilidad que ofrece en la creación y manejo de entornos virtuales. A diferencia de venv o virtualenv, uv permite seleccionar explícitamente la versión de Python que se desea usar, lo cual es especialmente útil cuando se trabaja con múltiples versiones en el mismo sistema.

A continuación, un ejemplo práctico donde se crea un entorno virtual usando Python 3.9, aunque el intérprete activo sea una versión distinta:

$ python -V
Python 3.8.1
$ uv venv py39 --python 3.9
Using CPython 3.9.6 interpreter at: /Applications/Xcode.app/Contents/Developer/usr/bin/python3
Creating virtual environment at: py39
Activate with: source py39/bin/activate

Después de activarlo:

$ source py39/bin/activate
(py39) $ python -V
Python 3.9.6

Esto facilita la administración de proyectos que requieren versiones específicas de Python, sin necesidad de recurrir a herramientas externas como pyenv o manejar manualmente rutas de intérpretes. Basta con tener instaladas las versiones deseadas y uv se encarga del resto.

Uso global de herramientas de línea de comandos

Una funcionalidad destacada de uv es su capacidad para ejecutar herramientas de línea de comandos externas sin necesidad de instalarlas manualmente en el entorno actual. Esto se puede hacer de tres formas: de manera global, local dentro del proyecto, o incluso de forma temporal, sin dejar rastros.

Por ejemplo, si queremos formatear un archivo usando black, pero no lo tenemos instalado en el entorno, podemos simplemente ejecutar:

$ uvx black app.py
Installed 8 packages in 21ms
All done! ✨ 🍰 ✨
1 file left unchanged.

El subcomando uvx detecta que black no está disponible y se encarga de instalarlo automáticamente en un entorno temporal y ejecutarlo. Esto es ideal para tareas puntuales como:

  • Formateo de código con black
  • Análisis estático con ruff o flake8
  • Ejecutar scripts auxiliares de desarrollo sin contaminar el entorno actual

Esta funcionalidad ahorra tiempo y evita la proliferación de dependencias innecesarias en cada entorno de desarrollo.

Conclusión

uv representa una evolución natural en la gestión de entornos y dependencias en Python. Su enfoque en el rendimiento, la simplicidad y la compatibilidad lo convierten en una herramienta poderosa tanto para desarrolladores individuales como para equipos que buscan flujos de trabajo más eficientes y confiables.

A lo largo de este artículo exploramos algunos de sus casos de uso más interesantes:

  • La creación de scripts autocontenidos con dependencias embebidas
  • La gestión versátil de entornos virtuales con distintas versiones de Python
  • La ejecución temporal de herramientas CLI sin necesidad de instalaciones persistentes

En mi experiencia, uv ha reducido significativamente el tiempo que dedico a la configuración de entornos y ha hecho más predecible la resolución de dependencias. Aunque todavía está evolucionando, ya es una alternativa sólida frente a soluciones más tradicionales como Poetry o pip-tools.

Si estás buscando una herramienta moderna, rápida y más amigable para el desarrollo en Python, definitivamente vale la pena probar uv.

Container Networks – CNI (Kubernetes)

En el articulo anterior se mencionó como Docker realizo cambios en su arquitectura para delegar responsabilidades del motor de ejecución. Este articulo abordará la segunda propuesta de Container Networks que actualmente es utilizada por Kubernetes para la administración de controladores de red.

Container Network Interface (CNI)

CNI

Es una especificación neutral propuesta por CoreOS y que ha sido adoptada por proyectos como Apache Mesos, Cloud Foundry, Kubernetes y rkt.

Los plugins son implementaciones de las acciones definidas por la especificación del CNI y que son consumidas por el motor de ejecución. Los binarios de los plugins necesitan al menos el permiso de CAP_NET_ADMIN para ser ejecutados correctamente. La acción a ejecutar por el binario es especificada mediante la variable de entorno CNI_COMMAND y esta acción solo puede ser una alguna de las siguientes opciones:

  • ADD: La cual permite agregar una interfaz de red a un contenedor especifico. Esta acción puede ser ejecutada múltiples veces para proporcionar varias interfaces de red a un solo contenedor.
  • DEL: Remueve una interfaz de red especifica a un contenedor.
  • CHECK: Se utiliza por el motor de ejecución para validar que la configuración de red se mantiene según lo solicitado.
  • VERSION: Imprime la versión de CNI soportada por el plugin.

Flujo de asignación de red en Kubernetes

Kubernetes utiliza el concepto de Pod para referirse a un conjunto de uno o mas contenedores que comparten una misma dirección IP. Esta dirección IP permite la comunicación de los contenedores hacia el exterior lo cual resulta importante comprender el proceso de su asignación.

Kubelet es el componente responsable del flujo en la creación del Pod, a continuación tratare de explicar la serie de pasos realizados por el componente de Dockershim en la creacion del Pod y asignación de dirección IP. Cabe señalar que Dockershim sera eliminado en la version 1.23 de Kubernetes, pero debido a la simplicidad de su método RunPodSandbox nos permite conocer mejor el flujo.

  1. Se asegura de tener la imagen del pause container. Este contenedor nos sirve como contenedor padre de los contenedores definidos en el pod y tiene dos funciones primordiales:
    1. Como la base para compartir Linux namespaces dentro del pod.
    2. Como PID 1 para cada pod.
  2. Crea la configuración requerida por el pause container, llamado la API de Docker.
  3. Crea un checkpoint del pause container en /var/lib/dockershim/sandbox a traves del administrador de Checkpoints.
  4. Inicia el pause container, nuevamente consumiendo la API de Docker.
  5. Configura las interfaces red (loopback y default) para el pause container. Esto lo realiza a traves de una serie de llamadas a los binarios de CNI utilizando su acción ADD.

Como puede observarse, la configuración de red del pause container no se realiza a traves del API de Docker sino a traves del CNI. Estos CNIs utilizan archivos de configuración en formato JSON, para definir entradas y salidas de datos esperados. Múltiples CNI plugins pueden ser ejecutados por el motor de ejecución.

Container Networks – CNM

La arquitectura de Docker separó (a partir de la versión 1.6) la creación de Contenedores de la configuración de la red para beneficiar la movilidad de las aplicaciones. En este articulo analizaremos a detalle uno de los estándares propuestos para esta separación.

Container Network Model (CNM)

Es la especificación propuesta por Docker. La biblioteca de libnetwork ofrece una implementación escrita en lenguaje Go. Actualmente proyectos como Cisco Contiv, Kuryr y Weave Net soportan dicha implementación.

Este modelo define tres conceptos principales:

  • Sandbox: El cual almacena la configuración de la red. Esta configuración incluye la administración de las interfaces de rede de los contenedores, la lista de rutas de tráfico y los valores de configuración para el DNS. Una implementación de un Sandbox puede ser Linux Network Namespace.
  • Endpoint: Permite la conexión de un Sandbox con la red. Una implementación de un Endpoint puede ser veth pair.
  • Network: Es un conjunto de Endpoints que se pueden comunicar entre si. Una implementación de un Network puede ser Linux bridge.

Libnetwork utiliza los siguientes objetos en su implementación.

  • NetworkController provee un punto de entrada a la biblioteca a traves de una API simple para asignar y manejar redes.
  • Driver realiza la implementación de la red. Los drivers pueden ser de dos tipos inbuilt (como Bridge, Host, None y Overlay) o remote.
  • Network es la implementación del componente de Network definido anteriormente. El Driver es notificado cada vez que un Network es creado o actualizado.
  • Endpoint ofrece conectividad para los servicios. Un Endpoint solo puede ser adjuntado a un Network.
  • Sandbox representa la configuración de la red como la dirección IP, la dirección MAC, las rutas de tráfico de red y las entradas del DNS. Un Sandbox puede tener varios Endpoints conectados a diferentes Networks.

El siguiente código en Go muestra la creacion de la red network1 utilizando el driver bridge para mas tarde conectar el contenedor container1 a traves del endpoint Endpoint1.

import (
	"fmt"
	"log"

	"github.com/docker/docker/pkg/reexec"
	"github.com/docker/libnetwork"
	"github.com/docker/libnetwork/config"
	"github.com/docker/libnetwork/netlabel"
	"github.com/docker/libnetwork/options"
)

func main() {
	if reexec.Init() {
		return
	}

	// Select and configure the network driver
	networkType := "bridge"

	// Create a new controller instance
	driverOptions := options.Generic{}
	genericOption := make(map[string]interface{})
	genericOption[netlabel.GenericData] = driverOptions
	controller, err := libnetwork.New(config.OptionDriverConfig(networkType, genericOption))
	if err != nil {
		log.Fatalf("libnetwork.New: %s", err)
	}

	// Create a network for containers to join.
	// NewNetwork accepts Variadic optional arguments that libnetwork and Drivers can use.
	network, err := controller.NewNetwork(networkType, "network1", "")
	if err != nil {
		log.Fatalf("controller.NewNetwork: %s", err)
	}

	// For each new container: allocate IP and interfaces. The returned network
	// settings will be used for container infos (inspect and such), as well as
	// iptables rules for port publishing. This info is contained or accessible
	// from the returned endpoint.
	ep, err := network.CreateEndpoint("Endpoint1")
	if err != nil {
		log.Fatalf("network.CreateEndpoint: %s", err)
	}

	// Create the sandbox for the container.
	// NewSandbox accepts Variadic optional arguments which libnetwork can use.
	sbx, err := controller.NewSandbox("container1",
		libnetwork.OptionHostname("test"),
		libnetwork.OptionDomainname("docker.io"))
	if err != nil {
		log.Fatalf("controller.NewSandbox: %s", err)
	}

	// A sandbox can join the endpoint via the join api.
	err = ep.Join(sbx)
	if err != nil {
		log.Fatalf("ep.Join: %s", err)
	}

	// libnetwork client can check the endpoint's operational data via the Info() API
	epInfo, err := ep.DriverInfo()
	if err != nil {
		log.Fatalf("ep.DriverInfo: %s", err)
	}

	macAddress, ok := epInfo[netlabel.MacAddress]
	if !ok {
		log.Fatalf("failed to get mac address from endpoint info")
	}

	fmt.Printf("Joined endpoint %s (%s) to sandbox %s (%s)\n", ep.Name(), macAddress, sbx.ContainerID(), sbx.Key())
}

Esta propuesta ofrece una gran versatilidad para la creación y administración de las redes de los contenedores, sin embargo el grupo de desarrolladores de Kubernetes no escogió este modelo de red. En su sitio oficial se explica las razones técnicas de dicha selección.

El driver MAC-VLAN

En el articulo anterior revisamos las redes que son creadas por defecto por el servicio de Docker y como el driver bridge agrega un linux bridge entre el contenedor y la interfaz de red del servidor, además de utilizar un veth pair para conectar el contenedor con dicho bridge.

El siguiente bloque de código muestra la relación existente entre contenedor y el servidor donde se ejecuta, se puede observar que la interfaz de red del contenedor eth0@if23 hace referencia a la interfaz numero 23 del servidor que es vethe752504.

$ docker run --detach --name container ubuntu:18.04 sleep infinity
c719a954130f4769afeff28654800e8d7593b14442cfb36bcbd0ed0eade28b83
$ docker exec -ti container ip addr show eth0
22: eth0@if23: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:ac:50:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.80.0.2/24 brd 172.80.0.255 scope global eth0
       valid_lft forever preferred_lft forever
$ ip link show type veth
23: vethe752504@if22: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 
    link/ether f6:5d:14:36:58:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242f6f9457f       no              vethe752504

Aunque utilizar un linux bridge resulta útil para la mayoría de los casos, existen aplicaciones las cuales requieren ser conectadas directamente a la red física del servidor donde son ejecutadas.

Existen dos tipos de drivers (macvlan y ipvlan) los cuales ofrecen una conexión más directa. Estos drivers reducen la latencia de red debido a que la ruta de tráfico de los paquetes de red es más corta. En este artículo revisamos a detalle el driver de MACVLAN o MAC-VLAN.

Definición

El driver MAC-VLAN opera en la capa de enlace de datos del modelo OSI y permite conectar múltiples subinterfaces de contenedores o máquinas virtuales a una sola interfaz física. Cada subinterfaz posee una dirección MAC (generada aleatoriamente) y por consiguiente una dirección IP.

Existen cuatro tipos de MAC-VLAN:

  • Privada: Donde todas las tramas de red son reenviadas por medio de la interfaz de red principal. No es posible una comunicación entre subinterfaces en el mismo servidor.
  • Virtual Ethernet Port Aggregator: Requiere un switch que tenga soporte IEEE 802.1Qbg el cual permite una comunicación entre subinterfaces en el mismo servidor. Las tramas de red son enviadas a través de la interfaz de red principal lo cual permite que puedan ser aplicadas políticas de red externas.
  • Bridge: Conecta todas las subinterfaces con un bridge lo cual permite que las tramas de red sean enviadas directamente sin salir del servidor.
  • Passthru: Permite conectar una sola subinterfaz a la interfaz de red principal.

Laboratorio

Para el caso de Docker, este servicio utiliza el tipo Bridge en el driver MAC-VLAN, lo que permite una comunicación externa e intercomunicación entre contenedores. El siguiente ejemplo muestra las instrucciones para crear una red de tipo MAC-VLAN en Docker y la creación de dos contenedores dentro de esa misma red.

$ ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 08:00:27:b0:b7:a2 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute eth0
       valid_lft 85773sec preferred_lft 85773sec
$ docker network create --driver macvlan --subnet=10.0.2.0/24  --gateway=10.0.2.2 --opt parent=eth0 macvlan0
d92d3cf366628e962a0bbd4b922d6ca7b2c50e9dc69c5219a2dbd398ae32d923
$ docker run --detach --name container --network macvlan0 busybox sleep infinity
aa915e2015629361306c80b2e508f94b6855857b14cfe6ea397c151250f12cb2
$ docker run --detach --name container2 --network macvlan0 busybox sleep infinity
dff856cb2457b27bfd2f8e5bef44eada3f63bdc4aa8b4e3e8e3dd2ed52cd53d4

Las siguientes instrucciones muestran las direcciones IP asignadas a los contenedores que fueron creados recientemente.

$ docker exec -ti container ip addr show eth0
13: eth0@if2: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:0a:00:02:01 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.1/24 brd 10.0.2.255 scope global eth0
       valid_lft forever preferred_lft forever
$ docker exec -ti container2 ip addr show eth0
14: eth0@if2: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:0a:00:02:03 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.3/24 brd 10.0.2.255 scope global eth0
       valid_lft forever preferred_lft forever

Una vez obtenidas las direcciones IP de los dos contenedores, es posible validar distintos tipos de comunicación.

Las siguientes instrucciones muestran la comunicación de este a oeste, donde se observa que los contenedores pueden comunicarse entre si.

$ docker exec -ti container ping -c 3 10.0.2.3
PING 10.0.2.3 (10.0.2.3): 56 data bytes
64 bytes from 10.0.2.3: seq=0 ttl=64 time=0.081 ms
64 bytes from 10.0.2.3: seq=1 ttl=64 time=0.083 ms
64 bytes from 10.0.2.3: seq=2 ttl=64 time=0.059 ms

--- 10.0.2.3 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max = 0.059/0.074/0.083 ms

La comunicación de sur a norte tiene algunas limitantes. Podemos observar que no es posible la comunicación directa con la dirección IP de la interfaz de red principal.

$ docker exec -ti container ping -c 3 10.0.2.15
PING 10.0.2.15 (10.0.2.15): 56 data bytes

--- 10.0.2.15 ping statistics ---
3 packets transmitted, 0 packets received, 100% packet loss

Sin embargo, es posible la comunicación directa con la dirección IP de la interfaz del Gateway.

$ docker exec -ti container ping -c 3 10.0.2.2
PING 10.0.2.2 (10.0.2.2): 56 data bytes
64 bytes from 10.0.2.2: seq=1 ttl=64 time=0.285 ms
64 bytes from 10.0.2.2: seq=2 ttl=64 time=0.233 ms

--- 10.0.2.2 ping statistics ---
3 packets transmitted, 2 packets received, 33% packet loss
round-trip min/avg/max = 0.233/0.259/0.285 ms

Tipos de red en Docker

Últimamente se habla mucho sobre contenedores, subrayando los beneficios de su uso para el desarrollo y despliegue de aplicaciones, y en ocasiones se hacen comparaciones erróneas con las Máquinas Virtuales. Este articulo pretende cubrir las opciones de red locales que ofrece Docker al crear contenedores.

Cuando el servicio de Docker inicia este crea localmente tres redes que ofrecen distintas capacidades.

$ docker network list
NETWORK ID          NAME                DRIVER              SCOPE
d1cd6bdefdb7        bridge              bridge              local
e14adf7f1918        host                host                local
9c7b12187b80        none                null                local

Se puede observar los distintos tipos de red local en la columna de DRIVER.

  • Bridge: Valor por defecto. Se utiliza cuando las aplicaciones son ejecutadas en contenedores independientes que necesitan comunicarse entre ellos.
  • Host: Valor usado en contenedores independientes donde se busca compartir los servicios de red ofrecidos por el servidor donde el contenedor fue creado.
  • Null: Deshabilita los servicios de red para dicho contenedor.

Host network

Este tipo de red comparte los servicios de red del equipo donde se ejecuta.  Además de ofrecer las siguientes capacidades:

  1. Las interfaces de red del contenedor son idénticas a las de la máquina donde se ejecuta.
  2. Solo existe una red de tipo host por máquina.
  3. Es necesario especificar el tipo de red como argumento «–net=host».
  4. Ni container linking ni port mapping son soportados.
$ ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:92:e3:8c brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0
       valid_lft forever preferred_lft forever
$ docker run --net=host bash:5.0.17 ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 08:00:27:92:e3:8c brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0
       valid_lft forever preferred_lft forever

None network

Es posible crear un contenedor sin algún tipo de red asignada a el. Este contenedor es solo accesible a traves del shell del servidor donde fue creado.

Estos contenedores pueden ser útiles para la ejecución de tareas por lotes que no requieren conectividad hacia o desde ellos.

$ docker run --net=none bash:5.0.17 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever

Bridge network

Permite la creación de múltiples redes en el mismo servidor ofreciendo una separación lógica de la red, es decir, solo contenedores conectados a la misma red se pueden comunicar entre si.

$ docker run --net=bridge bash:5.0.17 ip addr show eth0
11: eth0@if12: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP 
    link/ether 02:42:ac:50:00:03 brd ff:ff:ff:ff:ff:ff
    inet 172.80.0.3/24 brd 172.80.0.255 scope global eth0
       valid_lft forever preferred_lft forever

La red predeterminada creada por el servicio Docker al momento de iniciar es llamada bridge y crea un bridge llamado docker0 con los valores de una subnet en el rango de 172.80.0.0/24.

$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242eaa3ec7c       no

Todos los contenedores creados en este servidor donde no se especifique su red serán conectados a esta red.

OPNFV y CNTT

Network Function Virtualization (NFV) representa el cambio mas significante en las redes de comunicación global en los últimos 30 años.  Es por ello que resulta esencial adoptar una estrategia donde se maximice la flexibilidad, agilidad e interoperabilidad de la red, aprovechando la rápida innovación del ecosistema.

OPNFV

La Plataforma Abierta para NFV o por sus siglas en ingles OPNFV,  es la comunidad de la Linux Foundation que provee herramientas para realizar una validación de proyectos upstream a traves de un proceso de integración continua. Cuenta con un programa de verificación de equipos que permite acelerar la transformación de las redes de proveedores de servicios y empresas.

OPNFV facilita la implementación y desarrollo de la tecnología de NFV permitiendo su amplia adopción.

Para entender el alcance de los proyectos upstream que OPNFV valida e integra, es necesario conocer las distintas partes que componen el modelo que el Instituto Europeo de Normas de Telecomunicaciones (ETSI) publicó en su especificación ETSI GS NFV-INF 001 V1.1.1 (2015-01) donde se representa la arquitectura de su infraestructura de la siguiente manera:


A continuación se definen algunos conceptos:

  • Network Function (NF): Es un bloque funcional dentro de una infraestructura de red en el cual tiene bien definido sus interfaces externas y su comportamiento funcional.
  • Physical Network Function (PNF): Refiere a un dispositivo de hardware que ofrece un propósito de red especifico o un Network Function.
  • Virtualised Network Function (VNF): Es una implementación de una Network Function en una o varias Máquinas Virtuales.
  • Cloud-Native Network Function (CNF): Es considerado como la siguiente generación de VNFs donde se utiliza paradigmas de desarrollo para la Nube en la implementación de Network Function.
  • Network Functions Virtualisation Infrastructure (NFVI): El conjunto de los componentes de hardware y software en el cual se crea un ambiente para desplegar VNFs y/o CNFs.
  • Virtualised Infrastructure Manager (VIM): Es responsable de controlar y administrar la infraestructura para NFV (NFVI), manteniendo un inventario de los recursos virtuales asignados a los recursos físicos.

CNTT

El desarrollo y despliegue de aplicaciones VNFs y/o CNFs requiere retos técnicos significantes que sumado a la proliferación de nuevas tecnologías afectan drásticamente las estrategias operacionales de cualquier negocio.

Es por eso que a principios del 2019 eso fue creado el comité de Cloud iNfrastructure Telco Taskforce (CNTT) el cual es responsable de la creación y documentación de un marco de trabajo común para la Infraestructura de la Nube.

El CNTT ofrece cuatro niveles de documentación necesarios para la descripción de componentes y su correcta aplicación:

  1. Modelos de Referencia: Enfocado en la abstracción de la Infraestructura y como los recursos y servicios son expuestos a las VNFs y/o CNFs.
  2. Arquitectura de Referencia: Define los componentes y propiedades de la Infraestructura en los cuales VNFs y/o CNFs son ejecutados, desplegados y diseñados.
  3. Implementación de Referencia: Es construido con base en los requerimientos y especificaciones de los Modelos y Arquitectura de Referencia agregando detalles sobre su implementacion.
  4. Consenso de Referencia: Verifica, prueba y certifica los requerimientos y especificaciones desarrolladas en otros documentos.

A continuación se muestra los diferentes tipos de elementos de una plataforma típica de la Nube.

CNTT Scope