Prácticas recomendadas para trabajar con contenedores

En este artículo, se describe un conjunto de prácticas recomendadas para facilitar la operación de los contenedores. Estas prácticas abordan una amplia gama de temas, desde la seguridad hasta la supervisión y el registro. Su objetivo es facilitar la ejecución de las aplicaciones en Google Kubernetes Engine y en los contenedores en general. Varias de las prácticas que se analizan en esta sección se inspiraron en la Metodología de doce factores, que es un recurso óptimo para la compilación de aplicaciones nativas de la nube.

No todas estas prácticas recomendadas son igual de importantes. Por ejemplo, podrás ejecutar una carga de trabajo de producción de forma correcta aunque no sigas algunas de ellas, pero otras son indispensables. En particular, la importancia de las recomendaciones sobre seguridad es subjetiva. Su puesta en práctica dependerá de tu entorno y tus limitaciones.

Para sacarle el máximo provecho a este artículo, necesitas conocer un poco sobre Docker y Kubernetes. Algunas prácticas recomendadas que se tratan aquí también se aplican en los contenedores de Windows, pero la mayoría supone que trabajas con contenedores de Linux. A fin de recibir asesoramiento sobre cómo compilar contenedores, consulta Recomendaciones para compilar contenedores.

Usa los mecanismos de registro nativos de los contenedores.

Importancia: ALTA

Los registros contienen información valiosa sobre los eventos que suceden en la aplicación como parte integral de la administración de esta. Docker y Kubernetes se esfuerzan por facilitar la administración de los registros.

Es probable que, en un servidor clásico, necesites escribir los registros en un archivo específico y controlar la rotación del registro para evitar que se llenen los discos. Si tienes un sistema de registro avanzado, puedes reenviar los registros a un servidor remoto para centralizarlos.

Los contenedores ofrecen una manera sencilla y estandarizada de controlar los registros debido a que puedes escribirlos en stdout y stderr. Docker captura estas líneas de registro y te permite acceder a ellas mediante el comando docker logs. Como desarrollador de aplicaciones, no necesitas implementar mecanismos de registro avanzados. En lugar de esto, usa los mecanismos de registro nativos.

El operador de la plataforma debe proporcionar un sistema para centralizar los registros y hacer que se puedan buscar. En GKE, fluentd y Cloud Logging proporcionan este servicio. En otros métodos comunes, se incluye el uso de una pila EFK (Elasticsearch, Fluentd, Kibana).

Diagrama de un sistema de administración de registros clásico en Kubernetes
Figura 1. Diagrama de un sistema de administración de registros típico en Kubernetes

Registros JSON

La mayoría de los sistemas de administración de registros son bases de datos de serie temporal que almacenan documentos indexados en el tiempo. Por lo general, esos documentos se pueden proporcionar en formato JSON. En Cloud Logging y EFK, una sola línea de registro se almacena como un documento, junto con algunos metadatos (información sobre el pod, el contenedor o el nodo, entre otros).

Puedes aprovechar ese comportamiento mediante el registro directo en formato JSON con distintos campos. Luego, puedes buscar los registros de forma más eficiente con base en esos campos.

Por ejemplo, considera transformar el siguiente registro al formato JSON:

[2018-01-01 01:01:01] foo - WARNING - foo.bar - There is something wrong.

Este es el registro transformado:

{
  "date": "2018-01-01 01:01:01",
  "component": "foo",
  "subcomponent": "foo.bar",
  "level": "WARNING",
  "message": "There is something wrong."
}

Esta transformación te permite buscar en tus registros todos los registros en el nivel de WARNING o todos los registros del subcomponente foo.bar con facilidad.

Si decides escribir registros con formato JSON, ten en cuenta que debes escribir cada evento en una sola línea para que se analice de forma adecuada. En realidad, se verá como se muestra a continuación:

{"date":"2018-01-01 01:01:01","component":"foo","subcomponent":"foo.bar","level": "WARNING","message": "There is something wrong."}

Como puedes ver, el resultado es mucho menos legible que una línea normal de un registro. Si decides usar este método, asegúrate de que tus equipos no se basen demasiado en la inspección manual de registros.

Patrón de archivo adicional del agregador de registros

Algunas aplicaciones (como Tomcat) no se pueden configurar con facilidad para que escriban registros en stdout y stderr. Debido a que tales aplicaciones se escriben en diferentes archivos de registro en el disco, la forma más adecuada de controlarlas en Kubernetes es usar el patrón de archivo adicional para el registro. Un archivo adicional es un contenedor pequeño que se ejecuta en el mismo pod que la aplicación. Para ver una descripción más detallada de los archivos adicionales, consulta la Documentación oficial de Kubernetes.

En esta solución, agregarás un agente de registro en el contenedor de un archivo adicional a la aplicación (en el mismo pod) y compartirás un volumen emptyDir entre los dos contenedores, como se muestra en este ejemplo de YAML en GitHub. Luego, configurarás la aplicación para que escriba sus registros en el volumen compartido y configurarás el agente de registro a fin de que los lea y reenvíe cuando sea necesario.

En este patrón, deberás tratar con la rotación del registro debido a que no usas los mecanismos de registro nativos de Docker y Kubernetes. Si el agente de registro no controla la rotación del registro, otro contenedor de archivo adicional en el mismo pod puede controlarla.

Patrón de archivo adicional para la administración de registros.
Figura 2. Patrón de archivo adicional para la administración de registros

Asegúrate de que tus contenedores se encuentren inmutables y sin estado

Importancia: ALTA

Si pruebas los contenedores por primera vez, no los trates como servidores tradicionales. Por ejemplo, es posible que te sientas tentado de actualizar la aplicación dentro de un contenedor en ejecución o de aplicar un parche en un contenedor en ejecución cuando surjan vulnerabilidades.

Los contenedores no están diseñados para funcionar de esta manera. Están diseñados para permanecer inmutables y sin estado.

Falta de estado

Que permanezca Sin estado significa que cualquier estado (datos persistentes de cualquier tipo) se almacena fuera de un contenedor. Este almacenamiento externo puede usar varios formularios según lo que necesites:

  • Para almacenar archivos, recomendamos el uso de un depósito de objetos como Cloud Storage.
  • Para almacenar información como las sesiones de usuario, recomendamos el uso de un almacén externo, de latencia baja y de clave-valor, como Redis o Memcached.
  • Si necesitas almacenamiento de nivel de bloqueo (para bases de datos, por ejemplo), puedes usar un disco externo adjunto al contenedor. En el caso de GKE, recomendamos el uso de Discos persistentes.

Mediante el uso de estas opciones, puedes quitar los datos del contenedor, lo que significa que puede cerrarse y destruirse a la perfección en cualquier momento sin temor a la pérdida de datos. Si creas un contenedor nuevo para reemplazar el anterior, solo tienes que conectar el contenedor nuevo al mismo almacén de datos o vincularlo al mismo disco.

Inmutabilidad

Que permanezca Inmutable significa que un contenedor no se modificará durante su vida útil: sin actualizaciones, parches ni cambios de configuración. Si debes actualizar el código de la aplicación o aplicar un parche, compila una imagen nueva y vuelve a implementarla. La inmutabilidad hace que las implementaciones sean más seguras y que se puedan repetir más. Si necesitas revertir el proceso, vuelve a implementar la imagen anterior. Este enfoque te permite implementar la misma imagen de contenedor en cada uno de los entornos, lo que los hace más idénticos.

Para usar la misma imagen de contenedor en diferentes entornos, recomendamos que externalices la configuración del contenedor (puerto de escucha, opciones del entorno de ejecución y demás). Los contenedores se suelen configurar con variables de entorno o archivos de configuración activados en una ruta específica. En Kubernetes, puedes usar secretos y ConfigMaps para incorporar configuraciones en contenedores como archivos o variables de entorno.

Si necesitas actualizar una configuración, implementa un contenedor nuevo (basado en la misma imagen) con la configuración actualizada.

Ejemplo de cómo actualizar la configuración de una implementación con un ConfigMap activado como archivo de configuración en los pods.
Figura 3. Ejemplo de cómo actualizar la configuración de una implementación con un ConfigMap activado como archivo de configuración en los pods

La combinación de inmutabilidad y sin estado es una de las claves de venta de las infraestructuras basadas en contenedores. Esta combinación te permite automatizar las implementaciones y aumentar su frecuencia y confiabilidad.

Evita los contenedores privilegiados

Importancia: ALTA

En una máquina virtual o servidor físico, evita ejecutar las aplicaciones con un usuario raíz por una razón sencilla: si la aplicación está vulnerable, un atacante tendría acceso total al servidor. Por el mismo motivo, evita usar contenedores privilegiados. Un contenedor privilegiado es un contenedor que tiene acceso a todos los dispositivos de la máquina host y que pasa por alto casi todas las funciones de seguridad de los contenedores.

Si crees que necesitas usar contenedores privilegiados, considera las siguientes alternativas:

  • Proporciona funciones específicas al contenedor mediante la Opción securityContext de Kubernetes o la marca --cap-add de Docker. En la Documentación de Docker, se enumeran las funciones habilitadas según la configuración predeterminada y las que debes habilitar de manera explícita.
  • Modifica la configuración del host en un archivo adicional o en un Contenedor init si la aplicación debe modificarla para poder ejecutarse. A diferencia de la aplicación, esos contenedores no necesitan estar expuestos al tráfico interno o externo, lo que los aísla más.
  • Si necesitas modificar sysctls en Kubernetes, usa la Anotación específica.

Una Política de seguridad de pods específica puede prohibir los contenedores privilegiados en Kubernetes. Una política de seguridad de pods es un objeto de Kubernetes que el administrador del clúster configura y administra, y que aplica de forma forzosa los requisitos específicos de los pods. En el clúster de Kubernetes, no puedes crear pods que violen esos requisitos.

Facilita la supervisión de la aplicación

Importancia: ALTA

Del mismo modo que el registro, la supervisión es una parte integral de la administración de la aplicación. En muchos sentidos, la supervisión de aplicaciones en contenedores sigue los mismos principios que se aplican a la supervisión de aplicaciones que no están en contenedores. Sin embargo, debido a que las infraestructuras en contenedores suelen ser muy dinámicas con contenedores que se crean o se borran con frecuencia, no puedes permitirte configurar de nuevo el sistema de supervisión cada vez que esto suceda.

Puedes distinguir dos clases principales de supervisión: la supervisión de caja negra y la supervisión de caja blanca. La supervisión de caja negra hace referencia al análisis de la aplicación desde el exterior, como si fueras un usuario final. Es útil si el servicio final que deseas ofrecer está disponible y en funcionamiento. No difiere entre las infraestructuras tradicionales y las que están en contenedores debido a que es externa a la infraestructura.

La supervisión de caja blanca hace referencia al análisis de la aplicación con algún tipo de acceso privilegiado y a la recopilación de métricas sobre su comportamiento que un usuario final no puede ver. Difiere de manera significativa entre las infraestructuras tradicionales y las que están en contenedores debido a que debe analizar las capas más profundas de la infraestructura.

Una opción popular en la comunidad de Kubernetes para la supervisión de caja blanca es Prometheus, un sistema que descubre los pods que debe supervisar de forma automática. Prometheus extrae los pods en busca de métricas y espera un formato específico en ellos. Cloud Monitoring puede supervisar los clústeres de Kubernetes y las aplicaciones que ejecutan con su propia versión de Prometheus. Obtén más información sobre cómo habilitar Kubernetes Engine Monitoring.

Para beneficiarse de Prometheus o Kubernetes Engine Monitoring, las aplicaciones deben exponer las métricas en el formato de Prometheus. En los dos métodos siguientes, se muestra cómo hacerlo.

Extremo HTTP de métricas

El extremo HTTP de métricas funciona de manera similar a los extremos que se mencionan más adelante en Expón el estado de la aplicación. Expone las métricas internas de la aplicación, en general, en un URI /metrics. Una respuesta se ve como se muestra a continuación:

http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"}    3
http_requests_total{method="get",code="200"} 10892
http_requests_total{method="get",code="400"}    97

En este ejemplo, http_requests_total es la métrica, method y code son etiquetas y el número que se encuentra más hacia la derecha es el valor de esta métrica para esas etiquetas. En este punto, la aplicación ha respondido 97 veces a una petición HTTP GET, desde su inicio, con un código de error 400.

La generación de este extremo HTTP se facilita mediante las Bibliotecas cliente de Prometheus que existen en varios idiomas. OpenCensus también puede exportar métricas con este formato (entre otras funciones). No expongas este extremo a la Internet pública.

En la Documentación de Prometheus oficial, se profundiza en este tema. También puedes leer el Capítulo 6 de la Ingeniería de confiabilidad de sitios para obtener información sobre la supervisión de caja blanca (y de caja negra).

Patrón de archivo adicional para la supervisión

No todas las aplicaciones pueden instrumentarse con un extremo HTTP /metrics. Recomendamos el uso del patrón de archivo adicional para exportar las métricas en el formato correcto a fin de conservar la supervisión estandarizada.

En la sección Patrón de archivo adicional del agregador de registros, se explica cómo usar un contenedor de archivo adicional para administrar los registros de las aplicaciones. Puedes usar el mismo patrón para la supervisión: el contenedor de archivo adicional aloja un agente de supervisión que traduce las métricas a medida que la aplicación las expone en un formato y protocolo que el sistema de supervisión global procesa.

Considera un ejemplo concreto: las aplicaciones de Java y Java Management Extensions (JMX). Varias aplicaciones de Java exponen métricas con JMX. En lugar de escribir de nuevo una aplicación para que exponga métricas en el formato de Prometheus, puedes aprovechar el jmx_exporter. El jmx_exporter recopila métricas de una aplicación a través de JMX y las expone mediante un extremo /metrics que Prometheus puede leer. Este enfoque también tiene la ventaja de limitar la exposición del extremo JMX, que puede usarse para modificar la configuración de la aplicación.

Patrón de archivo adicional para la supervisión.
Figura 2. Patrón de archivo adicional para la supervisión

Expón el estado de la aplicación

Importancia: MEDIA

Para facilitar la administración en la producción, una aplicación debe comunicarle su estado al sistema general: ¿se ejecuta la aplicación? ¿Está en buen estado? ¿Está lista para recibir tráfico? ¿Cómo funciona?

Kubernetes cuenta con dos tipos de verificación de estado: el sondeo de estado en funcionamiento y el sondeo de estado de preparación. Cada uno tiene un uso específico, como se describe en esta sección. Puedes implementar ambos de varias maneras (incluidas la ejecución de un comando dentro del contenedor o la verificación de un puerto TCP), pero el método recomendado es el uso de los extremos HTTP descritos en estas prácticas recomendadas. Para obtener información sobre este tema, consulta la Documentación de Kubernetes.

Sondeo de estado en funcionamiento

La forma recomendada de implementar el sondeo de estado en funcionamiento es que la aplicación exponga un extremo HTTP /health. Luego de recibir una solicitud en este extremo, la aplicación debe enviar una respuesta “200 OK” si se considera que está en buen estado. En Kubernetes, buen estado significa que el contenedor no tiene que cerrarse o reiniciarse. Lo que constituye el buen estado varía de una aplicación a otra, pero, por lo general, significa lo siguiente:

  • La aplicación se ejecuta.
  • Se encuentran sus dependencias principales (por ejemplo, puede acceder a su base de datos).

Sondeo de estado de preparación

La forma recomendada de implementar el sondeo de estado de preparación es que la aplicación exponga un extremo HTTP /ready. Cuando recibe una solicitud en este extremo, la aplicación debe enviar una respuesta “200 OK” si está lista para recibir tráfico. Que está lista para recibir tráfico significa lo siguiente:

  • La aplicación está en buen estado.
  • Se completan los pasos de inicialización posibles.
  • Cualquier solicitud válida que se envía a la aplicación no genera un error.

Kubernetes usa el sondeo de estado de preparación para organizar la implementación de la aplicación. Si actualizas una Implementación, Kubernetes hará una actualización progresiva de los pods que pertenezcan a esa implementación. La política de actualización predeterminada establece la actualización de un pod a la vez: Kubernetes espera a que el nuevo pod esté listo (como lo indica el sondeo de estado de preparación) antes de actualizar el siguiente.

Evita la ejecución como usuario raíz

Importancia: MEDIA

Los contenedores proporcionan aislamiento: con la configuración predeterminada, un proceso dentro de un contenedor de Docker no puede acceder a la información desde la máquina host o desde los demás contenedores implantados. Sin embargo, el aislamiento no es tan completo como lo es con las máquinas virtuales debido a que los contenedores comparten el kernel de la máquina host, como se explica en esta entrada de blog. Un atacante podría encontrar vulnerabilidades aún desconocidas (ya sea en Docker o en el kernel de Linux), que le permitirían escapar de un contenedor. Si el proceso se ejecuta como usuario raíz dentro del contenedor y el atacante encuentra una vulnerabilidad, este obtendrá acceso como usuario raíz a la máquina host.

Izquierda: las máquinas virtuales usan hardware virtualizado.
Derecha: las aplicaciones en contenedores usan el kernel del host.
Figura 5. En la imagen izquierda, las máquinas virtuales usan hardware virtualizado. En la imagen derecha, las aplicaciones en contenedores usan el kernel del host.

Para evitar esta posibilidad, se recomienda no ejecutar procesos como usuario raíz dentro de los contenedores. Puedes aplicar este comportamiento en Kubernetes mediante una PodSecurityPolicy. Cuando creas un pod en Kubernetes, usa la opción runAsUser para especificar el usuario de Linux que ejecuta el proceso. Este enfoque anula la instrucción de USER del Dockerfile.

En realidad, hay desafíos. El proceso principal de varios paquetes de software conocidos se ejecuta como usuario raíz. Si deseas evitar la ejecución como usuario raíz, diseña el contenedor de modo que pueda ejecutarse con un usuario desconocido y sin privilegios. Esta práctica a menudo significa que tienes que modificar los permisos en varias carpetas. En un contenedor, si sigues la práctica recomendada de Una aplicación por contenedor y ejecutas una aplicación con un usuario, preferentemente sin permisos de administrador, puedes otorgar a todos los usuarios permisos de escritura en las carpetas y archivos en los que se deba escribir y hacer que en los demás archivos y carpetas solo se pueda escribir como usuario raíz.

Una forma sencilla de verificar si el contenedor cumple con esta práctica recomendada es ejecutarlo de manera local con un usuario aleatorio y probar si funciona de manera correcta. Reemplaza [YOUR_CONTAINER] con el nombre del contenedor.

docker run --user $((RANDOM+1)) [YOUR_CONTAINER]

Si el contenedor necesita un volumen externo, puedes configurar la Opción fsGroup de Kubernetes para otorgarle la propiedad de este volumen a un grupo específico de Linux. Con esta configuración, se resuelve el problema de la propiedad de los archivos externos.

Si el proceso lo ejecuta un usuario sin privilegios, no podrá vincularse a puertos por debajo de 1024. Por lo general, esto no es un problema, ya que puedes configurar los servicios de Kubernetes para que enruten el tráfico de un puerto a otro. Por ejemplo, puedes configurar un servidor HTTP para que se vincule al puerto 8080 y redireccione el tráfico desde el puerto 80 con un servicio de Kubernetes.

Elige con cuidado la versión de la imagen

Importancia: MEDIA

Cuando usas una imagen de Docker como una imagen base en un Dockerfile o como una imagen implementada en Kubernetes, debes elegir la etiqueta de la imagen que usas.

La mayoría de las imágenes públicas y privadas siguen un sistema de etiquetado similar al que se describe en Recomendaciones para compilar contenedores. Si la imagen usa un sistema similar al Control de versiones semántico, debes tener en cuenta algunos datos específicos del etiquetado.

Lo más importante es que la etiqueta “más reciente” se puede mover con frecuencia de una imagen a otra. La consecuencia es que no puedes basarte en esta etiqueta para realizar compilaciones predecibles o que se pueden reproducir. Por ejemplo, observa este Dockerfile:

FROM debian:latest
RUN apt-get -y update && \ apt-get -y install nginx

Si compilas una imagen de este Dockerfile dos veces y en momentos diferentes, puedes terminar con dos versiones diferentes de Debian y NGINX. En su lugar, considera usar esta versión revisada:

FROM debian:9.4
RUN apt-get -y update && \ apt-get -y install nginx

Mediante el uso de una etiqueta más precisa, te aseguras de que la imagen resultante siempre se basará en una versión secundaria específica de Debian. Debido a que una versión específica de Debian también incluye una versión específica de NGINX, tienes mucho más control sobre la imagen que se compila.

Este resultado no solo es verdadero en el tiempo de compilación, sino también en el tiempo de ejecución. Si haces referencia a la etiqueta “más reciente” en un manifiesto de Kubernetes, no tienes garantía de la versión que Kubernetes usará. Es posible que varios nodos del clúster extraigan la misma etiqueta “más reciente” en momentos diferentes. Si la etiqueta se actualizó en un punto entre las extracciones, puedes terminar con nodos distintos que ejecutan imágenes distintas (que se etiquetaron como “más reciente” en algún punto).

Lo ideal es que siempre uses una etiqueta inmutable en la línea FROM. Esta etiqueta te permite tener compilaciones que se pueden reproducir. Sin embargo, hay algunas ventajas y desventajas de seguridad: cuanto más fijes la versión que deseas usar, menos automatizados estarán los parches de seguridad en las imágenes. Si la imagen que usas emplea el control de versiones semántico adecuado, la versión del parche (es decir, la “Z” en “X.Y.Z”) no debe tener cambios que generen incompatibilidad con versiones anteriores: puedes usar la etiqueta “X.Y” y obtener correcciones de errores de forma automática.

Piensa en una pieza de software denominada “SuperSoft”. Supongamos que el proceso de seguridad de SuperSoft consiste en corregir las vulnerabilidades a través de una versión nueva del parche. Si deseas personalizar SuperSoft y escribiste este Dockerfile, ocurrirá lo siguiente:

FROM supersoft:1.2.3
RUN a-command

Después de un tiempo, el proveedor descubre una vulnerabilidad y lanza la versión 1.2.4 de SuperSoft para solucionar el problema. En este caso, depende de ti mantenerte informado sobre los parches de SuperSoft y actualizar el Dockerfile correspondiente. En cambio, si usas FROM supersoft:1.2 en el Dockerfile, la versión nueva se extraerá de forma automática.

Al final, debes examinar con cuidado el sistema de etiquetado de cada imagen externa que uses, decidir qué tanto confías en las personas que compilan esas imágenes y decidir qué etiqueta usarás.

Próximos pasos

Revisa nuestros instructivos y prueba otras funciones de Google Cloud.