Instalación en Kubernetes

Yes

Whatever ENV you specify when you finally do you docker run on the image … will take.

And doing those things at every boot of the container is reasonable?

Some people like this pattern, I do not, that is why rails introduces some super fancy locking around migrations to ensure they never run concurrently when a cluster of 20 app try to run migrations at the same time.

Some people just boot the same image and run the migrations in some out-of-band process.

But each container should still rake assets:precompile at least once?

Depends on how do you host those.

If you have a NFS share of some sort (like AWS EFS) or if you upload those to some object storage service that can only be done on bootstrap.

Gracias a todos por todas las respuestas útiles. Tengo algunas preguntas más sobre cómo hacer esto correctamente.

Estoy intentando preparar un app.yaml mínimo para la configuración inicial que solo contenga la información necesaria para la compilación del contenedor. Parte de la información que hay claramente está destinada al tiempo de ejecución del contenedor, como los montajes de volúmenes y las asignaciones de puertos. Pero no estoy seguro sobre las variables de entorno. Supongo que simplemente lo probaré, pero ¿se utilizan esas variables de entorno durante la compilación del contenedor (inyectadas en el Dockerfile de alguna manera) o solo en tiempo de ejecución del contenedor? Si es lo segundo, me aseguraré de que terminen en el archivo de configuración de k8s correspondiente.

En segundo lugar, algunas personas aquí han hablado de subir la imagen a un repositorio privado de contenedores. ¿Es eso obligatorio? Dicho de otra manera, ¿la imagen de compilación contiene información confidencial que no debería publicarse en un repositorio público como Docker Hub? (Aún no tenemos un repositorio privado de contenedores y me gustaría evitar configurarlo.)

Por último, ¿hay una configuración en app.yaml para controlar el nombre del contenedor creado? Es más bien un detalle de acabado, pero sería agradable :slight_smile:.

¡Gracias de antemano por la ayuda! (Perdón por revivir un hilo antiguo. Esta es la primera coincidencia en Google al buscar cómo instalar Discourse en Kubernetes.)

@Geoffrey_Challen Puedes crear una imagen con el repositorio de Discourse y los plugins, instalar las gemas de Ruby y otras dependencias, y luego subirla a un registro (como DockerHub). Este repositorio sería agnóstico al entorno y podría ser público (a menos que incluyas un plugin privado o algo similar). Esta imagen base podría utilizarse en entornos de staging y producción, e incluso en diferentes proyectos (si utilizan los mismos plugins).

Pasos como la precompilación de los activos, la migración de la base de datos y la generación del certificado SSL deben ejecutarse, sin embargo, en la máquina de destino para generar la imagen final.

No sé exactamente cómo incluir eso en un clúster de Kubernetes. Opté por un enfoque conservador y lo utilicé basándome en la guía oficial del equipo de Discourse, separándolo simplemente en dos pasos.

Esta parte no estoy seguro de entenderla. ¿No ocurrirán estas cosas automáticamente dentro del contenedor según sea necesario? Espero poder simplemente subir esto a nuestra nube y nunca necesitar acceder a la máquina mediante shell, similar a como rara vez (si es que alguna vez) necesito entrar al contenedor Docker de Discourse.

No es necesario que introduzcas el contenedor explícitamente. Lo que quiero decir es que no puedes generar una imagen precompilada con ellos (generada en un pipeline de CI, por ejemplo) y usarla tal cual, porque esto debe ejecutarse en la máquina de destino, donde se encuentra la base de datos (esto podría automatizarse, pero no lo he hecho en k8s, aunque sí lo hice con Ansible).

Ah, vale. Estoy usando las plantillas todo-en-uno para incluir la base de datos en el contenedor. Esto es adecuado para nuestro caso de uso, que implica apoyar clases compuestas por 10 a 1000 estudiantes, o al menos la configuración todo-en-uno ha funcionado bien en mi clase con esta configuración. Así que la base de datos está dentro del contenedor.

Pero, de todos modos, ¿no ejecutará Discourse las migraciones de la base de datos u otros pasos de configuración cuando el contenedor se inicie?

¿Estás seguro de que la base de datos está dentro del contenedor? ¿O se trata del SGBD (en este caso, PostgreSQL)? La instalación compatible utiliza la base de datos fuera del contenedor (lo cual es lo esperado), mapeando un volumen interno hacia el exterior (el host). Además, tras una reconstrucción del contenedor, este se vuelve a crear y perderías todos los datos.

Si realmente está dentro del contenedor, no sé exactamente cómo podrías realizar una actualización basándote en la instalación oficial, ya que el script launcher parece crear y destruir el contenedor varias veces durante la reconstrucción (y se ejecuta con --rm, lo que significa que perderías todos tus datos, incluida la base de datos, después de que el contenedor se detenga).

No he intentado cambiar la forma en que se realiza la reconstrucción, pero asumiendo que puedes modificarlo para ejecutar todo dentro del contenedor sin recrearlo, entonces deberías poder subir el contenedor a un registro (asegúrate de que sea privado, ya que ahí estarían las claves secretas). Dicho esto, no recomiendo este enfoque por varias razones (algunas de ellas mencionadas anteriormente).

La instalación estándar incluye nginx, Rails, PostgreSQL y Redis dentro del contenedor. Utiliza volúmenes externos para los datos de PostgreSQL y Redis. No se destruye al reconstruir o actualizar.

Sí, solo me pareció extraño que dijera que la base de datos está dentro del contenedor, a menos que haya cambiado la forma en que funciona la instalación estándar, o se refiera a PostgreSQL, no a la base de datos en sí.

No: las migraciones y los pasos de compilación de activos ocurren durante la fase ./launcher bootstrap, después de que se han resuelto los complementos. Una vez completado esto, el contenedor se puede reiniciar tantas veces como sea necesario, o los procesos web se pueden dividir entre varias máquinas, etc.


Imaginando la configuración, debería verse más o menos así:

  • las compilaciones oficiales ya proporcionan la imagen base de Discourse.
    • también necesitaremos un contenedor para discourse_docker que incluya su propio Docker.
  • establecer un registro privado dentro del clúster.
  • establecer un ConfigMap con el contenido de app.yml.
  • ejecutar un Job que ejecute ./launcher bootstrap utilizando Docker anidado en un nodo basado en VM (sin acceso al socket de Docker), y que renombre y envíe la imagen resultante al registro privado (con una etiqueta basada en marca de tiempo, no latest; local_discourse no es un buen nombre aquí) y actualice la implementación a la nueva etiqueta.
    • vaya, eso implica muchas permisos para el trabajo de actualización.

PostgreSQL se ejecuta dentro del contenedor. Guarda los datos fuera del contenedor, pero se ejecuta dentro de él si utilizas el conjunto estándar de plantillas de instalación. Lo mismo ocurre con Redis. Creo que la confusión surge cuando digo “la base de datos se ejecuta dentro del contenedor” me refiero al servidor de la base de datos, incluso si los archivos de la base de datos residen fuera del contenedor. (Pero los archivos de la base de datos no se “ejecutan”, por lo que considero que mi redacción es clara, aunque claramente no lo suficiente :slight_smile:.)

PD: en realidad, ni siquiera guarda los datos fuera del contenedor a menos que configures Docker para montar ese directorio como volumen vinculado. He podido omitir esto durante el proceso de arranque, aunque probablemente no sea una buena idea, ya que en ese caso el contenido de la base de datos no sobrevivirá a los reinicios del contenedor.

Creo que ahora esto tiene más sentido para mí, especialmente después de leer la larga conversación enlazada sobre docker-compose, el script de lanzamiento, etc.

Esto es lo que me gustaría poder hacer:

  • Ejecutar ./launcher bootstrap localmente para crear una imagen “completa” de Discourse que incluya todas las dependencias: postgres, redis, etc.
  • Desplegar esa imagen en Kubernetes.
  • Más tarde, volver a ejecutar ./launcher bootstrap para actualizar la imagen y volver a desplegar sin destruir los datos (obvio).

Mi entendimiento es que la imagen completa de Discourse no debería requerir dependencias de servicios externos. Sin embargo, para que los datos sobrevivan a las actualizaciones de contenedores, los archivos de la base de datos de postgres deben residir fuera del contenedor. Eso está bien; puedo crear un volumen persistente de k8s para ellos.

Ahora, aquí está el único problema que anticipo. La mayoría de lo que ocurre durante ./launcher bootstrap solo afecta a archivos que viven dentro del contenedor. Por ejemplo, la precompilación de activos. Eso está bien, ya que los resultados viven dentro del contenedor y no necesitan sobrevivir a las actualizaciones.

La gran excepción aquí es la migración de la base de datos. Ese paso necesita tener acceso a la base de datos que se utilizará después de que se complete el proceso de arranque. Por lo tanto, para mí, esto parece ser el principal obstáculo para desplegar fácilmente imágenes completas de Discourse en la nube.

He notado que @sam ha mencionado varias veces que vuelven a desplegar Discourse para sus clientes utilizando un flujo de trabajo aproximadamente similar al que describo arriba. Pero sospecho que la razón por la que esto funciona es que sus imágenes de Discourse están configuradas para usar un servidor de base de datos (y probablemente también Redis) que se ejecuta en su clúster, lo cual tendría sentido para soportar múltiples despliegues, pero no es exactamente lo que quiero hacer. Esto significa que el proceso de arranque puede modificar la base de datos de producción, o quizás simplemente que el paso de migración de la base de datos se omite por completo, ya que las actualizaciones y migraciones de la base de datos se gestionan externamente. @sam: ¿podrías confirmarlo?

En cualquier caso, la conclusión para mí es que necesito encontrar una manera de ejecutar las migraciones de la base de datos cuando el contenedor se inicia, no durante ./launcher bootstrap. Supongo que en ese momento una forma de hacerlo sería:

  • Construir el contenedor completo de Discourse localmente usando ./launcher bootstrap, utilizando un montaje de volumen que apunte a una base de datos local vacía, ya que esa base de datos no se utilizará más tarde. Esto pondría todo en el contenedor correctamente, pero no terminaría el trabajo de postgres.
  • Encontrar una manera de ejecutar el paso de migración de la base de datos en la base de datos de producción real, quizás usando un contenedor de inicialización de k8s.
  • Reemplazar la imagen antigua de Discourse con la nueva.

Puede que te interese una configuración multisitio.

Hay dos grandes problemas con los que te estás encontrando: Discourse no está listo para Kubernetes, por lo que se requiere código personalizado. Y te estás acercando a lo que el equipo de Discourse hace para ganar dinero (alojar un gran número de foros), por lo que el nivel de soporte que recibes disminuirá.

¿Mi consejo? Realiza una configuración multisitio con programación estática en máquinas virtuales, completamente fuera de tu clúster. (O un Service Type=ExternalName que apunte a la VM para mantener el mismo Ingress.)

OK… He logrado encontrar una forma de hacer esto. No estoy 100% satisfecho con ella, pero funciona y puede resultar atractiva para otros que estén intentando una implementación sencilla de una imagen Discourse «gorda» (que incluye PostgreSQL, Redis, etc.) en un solo contenedor para Kubernetes.

Mi enfoque

Tras examinar el proceso de arranque, me quedó claro que, lamentablemente, mezcla dos tipos de operaciones: aquellas que solo afectan al contenedor subyacente y otras que se extienden al entorno circundante, principalmente a través del montaje del volumen /shared, donde residen los archivos de datos de PostgreSQL. En lugar de intentar separar estos pasos, parece más sensato ejecutar directamente los pasos de arranque en el entorno donde se desplegará realmente el contenedor.

Lamentablemente, launcher bootstrap quiere crear un contenedor y utilizar Docker. Por lo tanto, ejecutar launcher dentro de otro contenedor (por ejemplo, en un contenedor que se ejecute en nuestra nube) implica lidiar con una configuración de Docker-in-Docker (es viable, pero no se considera una buena práctica) o exponer el daemon Docker subyacente. Ni siquiera estoy seguro de que el segundo enfoque funcione, ya que creo que interpretaría un montaje de volumen contra el sistema de archivos local del nodo, mientras que en nuestro escenario queremos montar /shared en un volumen persistente de Kubernetes. Quizás la ruta de Docker-in-Docker funcionaría, pero entonces también tendrías un extraño montaje de volumen triple: desde el contenedor anidado hacia el contenedor externo y desde allí hacia el volumen persistente de Kubernetes. Eso suena… poco aconsejable.

Sin embargo, esencialmente launcher bootstrap crea un archivo .yml grande procesando el valor templates en app.yml y luego lo pasa a la imagen base de Discourse cuando finaliza el proceso de arranque. Así que, si podemos extraer el archivo de configuración, podemos generar la configuración en cualquier máquina y luego solo necesitamos averiguar cómo pasársela a un contenedor que iniciemos en la nube.

Por lo tanto, como visión general, estos son los pasos que vamos a seguir:

  1. Generar la configuración de arranque utilizando un launcher modificado.
  2. Pasar esa configuración a una imagen base de Discourse modificada que realizará el arranque (usando pups) y luego iniciará Discourse.

Generación de la configuración de arranque

Este es el cambio necesario en launcher para admitir un comando dump que escribe la configuración fusionada en la salida estándar (STDOUT):

run_dump() {
  set_template_info
  echo "$input"
}

(Ten en cuenta que este comando está disponible en nuestro bifurcado de discourse_docker.)

Así que el primer paso es utilizar el nuevo comando launcher dump añadido arriba para crear nuestra configuración de arranque:

# Sustituye «app» por el nombre de tu configuración de contenedor
./launcher dump app > bootstrap.yml

Creación inicial del contenedor

A continuación, necesitamos un contenedor que sepa ejecutar pups para realizar el arranque del contenedor antes de iniciar mediante /sbin/boot. Utilicé el siguiente Dockerfile para hacer un pequeño cambio a la imagen base de Discourse:

FROM discourse/base:2.0.20191219-2109
COPY scripts/bootstrap.sh /
CMD bash bootstrap.sh

Donde scripts/bootstrap.sh contiene:

cd /pups/ && /pups/bin/pups --stdin < /bootstrap/bootstrap.yml && /sbin/boot

Publicé esto como geoffreychallen:discourse_base:2.0.20191219-2109. (Ten en cuenta que probablemente también podrías lograr lo mismo modificando el comando de arranque de la imagen Docker base de Discourse, pero me costaba mucho hacerlo funcionar con la redirección de shell necesaria para que pups leyera el archivo de configuración.)

Configuración de Kubernetes

Ahora necesitamos nuestra configuración de Kubernetes. La mía se ve así:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: kotlin-forum-pvc
  namespace: ikp
spec:
  storageClassName: rook-ceph-block
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 64Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kotlin-forum-deployment
  namespace: ikp
spec:
  replicas: 0
  selector:
    matchLabels:
      app: kotlin-forum
  template:
    metadata:
      labels:
        app: kotlin-forum
    spec:
      volumes:
      - name: kotlin-forum
        persistentVolumeClaim:
          claimName: kotlin-forum-pvc
      - name: bootstrap
        configMap:
          name: kotlin-forum-bootstrap
      containers:
      - name: kotlin-forum
        image: geoffreychallen/discourse_base:2.0.20191219-2109
        imagePullPolicy: Always
        volumeMounts:
        - name: kotlin-forum
          mountPath: /shared/
        - name: bootstrap
          mountPath: /bootstrap/
        ports:
        - containerPort: 80
        env:
        - name: TZ
          value: "America/Chicago"
        - name: LANG
          value: en_US.UTF-8
        - name: RAILS_ENV
          value: production
        - name: UNICORN_WORKERS
          value: "3"
        - name: UNICORN_SIDEKIQS
          value: "1"
        - name: RUBY_GLOBAL_METHOD_CACHE_SIZE
          value: "131072"
        - name: RUBY_GC_HEAP_GROWTH_MAX_SLOTS
          value: "40000"
        - name: RUBY_GC_HEAP_INIT_SLOTS
          value: "400000"
        - name: RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
          value: "1.5"
        - name: DISCOURSE_DB_SOCKET
          value: /var/run/postgresql
        - name: DISCOURSE_DEFAULT_LOCALE
          value: en
        - name: DISCOURSE_HOSTNAME
          value: kotlin-forum.cs.illinois.edu
        - name: DISCOURSE_DEVELOPER_EMAILS
          value: challen@illinois.edu
        - name: DISCOURSE_SMTP_ADDRESS
          value: outbound-relays.techservices.illinois.edu
        - name: DISCOURSE_SMTP_PORT
          value: "25"
---
apiVersion: v1
kind: Service
metadata:
  name: kotlin-forum
  namespace: ikp
spec:
  type: NodePort
  ports:
  - name: http
    port: 80
    targetPort: 80
  selector:
    app: kotlin-forum
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  namespace: ikp
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
  name: kotlin-forum-ingress
spec:
  rules:
  - host: kotlin-forum.cs.illinois.edu
    http:
      paths:
      - backend:
          serviceName: kotlin-forum
          servicePort: 80

La tuya se verá diferente. Ten en cuenta que estoy terminando HTTPS aguas arriba, de ahí las modificaciones en la configuración de Ingress. También me gusta poner todo en un solo archivo, eliminar las partes que no funcionan a medida que itero y luego dejar que Kubernetes omita los duplicados en el siguiente kubectl create -f. También ten en cuenta que establecí replicas: 0 para que el despliegue no se inicie tan pronto como se configure. Esto se debe a que tenemos un bit adicional de configuración que completar.

Copié la lista de variables de entorno de lo que vi que se pasaba al contenedor mediante launcher start. No sé si todas son necesarias y es posible que falten otras dependiendo de tu configuración. Tu experiencia puede variar.

Ten en cuenta que tenemos dos montajes de volumen apuntando al contenedor: el primero es para PostgreSQL, configurado como un volumen persistente que sobrevivirá a los reinicios de los pods. El segundo es un mapeo de configuración creado así:

kubectl create configmap kotlin-forum-bootstrap --from-file=bootstrap.yml=<path/to/bootstrap.yml>

Donde kotlin-forum-bootstrap debe coincidir con tu configuración de Kubernetes y path/to/bootstrap.yml es la ruta al archivo bootstrap.yml que creamos utilizando launcher dump anteriormente.

Una vez que tu configmap esté en su lugar, deberías poder escalar tu despliegue a una réplica y ver cómo Discourse arranca y ejecuta el mismo proceso de arranque que habría realizado launcher bootstrap. Eso tarda unos minutos. Cuando termine, tu instalación de Discourse se iniciará.

Otros detalles de configuración

Algunas otras notas que encontré en el camino para obtener esto (al menos por ahora) completamente configurado:

  • Cualquier proxy aguas arriba debe reenviar las cabeceras X-Forwarded, incluyendo tanto X-Forwarded-For, X-Forwarded-Proto como X-Forwarded-Port. No hacerlo dará lugar a errores extraños de autenticación al intentar usar el inicio de sesión con Google y probablemente otros proveedores de inicio de sesión.
  • Tu controlador de entrada nginx debe configurarse para pasar las cabeceras estableciendo use-forwarded-headers en el mapa de configuración global. Me llevó un tiempo hacerlo bien, ya que al menos en varias ocasiones edité el mapa de configuración incorrecto y luego esperé que mis contenedores de entrada se reiniciaran cuando el mapa de configuración cambiara. (No lo hicieron).

Actualización

Para actualizar la instalación desplegada, regeneras el nuevo archivo bootstrap.yml, actualizas el mapa de configuración y luego reinicias el contenedor (lo más fácil es escalar a 0 y luego de nuevo a 1 réplica).

Esto conlleva un poco de tiempo de inactividad, ya que el arranque ocurre antes de que se construya el contenedor. Pero esto me parece inevitable en casos donde necesitas actualizar la configuración y/o cambiar la imagen base. launcher rebuild está documentado como stop; bootstrap; start, lo que significa que el proceso de arranque seguirá causando tiempo de inactividad incluso si se realiza mediante el script de launcher.

Comentarios

Este patrón de despliegue de Discourse en contenedor «gordo» sería mucho más fácil de soportar si el script launcher separara más limpiamente (a) los pasos de arranque que podrían realizarse sin conexión y solo afectaran a los archivos del contenedor y (b) los pasos de arranque que modifican o necesitan acceso a la base de datos u otro estado fuera del contenedor. El enfoque descrito arriba es un poco frustrante porque ves todo tipo de ofuscación de JS, minificación de activos y otras cosas que podrían hacerse con el despliegue anterior en ejecución… pero están demasiado mezcladas con otras cosas (como las migraciones de base de datos) que no se pueden hacer sin acceso a la base de datos. Pensé brevemente en crear un contenedor que solo realizara los pasos en templates/postgres.yml, pero luego noté que las migraciones de base de datos se estaban realizando mediante la plantilla web, pensé en los complementos y simplemente me rendí :slight_smile:.

Con una mejor separación, el redepliegue para contenedores «gordos» podría funcionar más o menos así:

  • Construir un nuevo contenedor «gordo» sin conexión realizando todos los pasos internos al contenedor.
  • Publicar ese contenedor.
  • Cuando estés listo para actualizar, detén el contenedor anterior, inicia el nuevo contenedor «gordo» y déjalo finalizar cualquier paso que necesite acceso a la base de datos. Según mis experimentos, esos parecen ser más rápidos que algunos de los otros pasos de arranque.

Eso resultaría en un poco menos de tiempo de inactividad. Probablemente no valga la pena el esfuerzo solo por esa razón, pero puedo imaginar que esto también podría simplificar escenarios de despliegue más complejos que involucren bases de datos compartidas o lo que sea.

Eso tiene más sentido. Eso es lo que hice al separar en dos pasos la fase de arranque. El primero puede ejecutarse en un entorno aislado (como una tubería de CI) generando una imagen base con el repositorio de Discourse, las gemas y los complementos instalados, y el segundo paso necesita ejecutarse en la máquina de destino (o al menos tener acceso a la base de datos de producción) para realizar la migración de la base de datos y generar los activos (esto se hace en el proceso de arranque, aunque no al iniciar el contenedor).

Sí, eso sería genial. Ya lo solicité, pero no sé si y cuándo se hará.

Eso sería difícil de implementar completamente en un entorno separado porque la tarea de precompilación de activos necesita acceso a la base de datos (para cosas como CSS personalizado), pero sería genial si solo lo que depende de la base de datos pudiera hacerse en un paso separado (y todos los demás activos, que no dependen de la base de datos, pudieran precompilarse por separado, pero no sé qué tan viable sería implementarlo técnicamente).

Eso es más o menos lo que hago en las instalaciones de Kubernetes que he realizado. No puedo imaginar cómo ni por qué usar k8s sin contenedores separados para datos y web (o algún otro tipo de PostgreSQL y Redis externos; las instalaciones que he realizado para clientes utilizan recursos de GCP para ello).

Además, existe una variable de entorno llamada skip_post_migration_updates que debes comprender para lograr actualizaciones con cero tiempo de inactividad. Se describe aquí.