Instalación en Kubernetes

Yes

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

4 Me gusta

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.

3 Me gusta

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.

6 Me gusta

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.

1 me gusta

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).

2 Me gusta

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.

1 me gusta

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.
3 Me gusta

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.

1 me gusta

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.)

6 Me gusta

OK… I managed to figure out one way of doing this. I’m not 100% happy with it, but it does work and may be appealing to others that are trying a simple single-container fat (includes postgres, redis, etc.) Discourse image deployment to Kubernetes.

My Approach

After examining the bootstrap process it became clear to me that unfortunately it mixes in two different kinds of operations—ones that only affect the underlying container, and others that poke out into the surrounding environment, mainly through the /shared volume mount where the postgres data files live. Rather than trying to tease these steps apart, it seems more sane to just run the bootstrapping steps in the environment where the container is actually going to be deployed.

Unfortunately, launcher bootstrap wants to create a container and use Docker. So running launcher inside another container (for example, in a container running on our cloud) means either tangling with a Docker-in-Docker setup (doable, but not considered best practices) or exposing the underlying Docker daemon. I’m not even sure that that second approach would work, since I think that it would interpret a volume mount against the node’s local filesystem, whereas in our scenario we want to volume mount /shared to a persistent Kubernetes volume. Maybe the Docker-in-Docker route would work, but then you’d also have a weird triple volume mount from inside the nested container into the outer container and from there to the persistent Kubernetes volume. That sounds… unwise.

However, essentially launcher bootstrap creates one large .yml file by processing the templates value in the app.yml and then passes that to the Discourse base image when finishes the bootstrap process. So if we can extract the configuration file we can generate the configuration on any machine and then we only need to figure out how to pass it to a container we start in the cloud.

So as an overview, here are the steps we are going to follow:

  1. Generate the bootstrap configuration using a modified launcher
  2. Pass that to a modified Discourse base image that will perform the bootstrapping (using pups) and then start Discourse

Generating the Bootstrapping Configuration

Here’s the required change to launcher to support a dump command that writes the merged configuration to STDOUT:

run_dump() {
  set_template_info
  echo "$input"
}

(Note that this command is available in our fork of discourse_docker.)

So the first step is to use the new launcher dump command added above to create our bootstrap configuration:

# Substitute whatever your container configuration is called for app
./launcher dump app > bootstrap.yml

Initial Container Creation

Next we need a container that knows to run pups to bootstrap the container before booting via /sbin/boot. I used the following Dockerfile to make a tiny change to the base discourse image:

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

Where scripts/bootstrap.sh contains:

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

I published this as geoffreychallen:discourse_base:2.0.20191219-2109. (Note that you could probably also accomplish the same thing by modifying the boot command of the base Discourse docker image, but I was having a hard time getting that to work with the shell redirection required to get pups to read the configuration file.)

Kubernetes Configuration

Now we need our Kubernetes configuration. Mine looks like this:

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

Yours will look different. Note that I’m terminating HTTPS upstream, hence the modifications to the Ingress configuration. I also like to put everything in one file, delete pieces that don’t work as I iterate, and then let Kubernetes skip duplicates on the next kubectl create -f. Also note that I set replicas: 0 so that the deployment doesn’t start as soon as its configured. That’s because we have one bit of additional configuration to finish.

I copied the list of environment variables from what I saw being passed to the container by launcher start. I don’t know if all of these are necessary and others may be missing depending on your configuration. YMMV.

Note that we have two volume maps pointing into the container: the first is for postgres, configured as a persistent volume that will survive pod restarts. The second is a configuration mapping created like this:

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

Where kotlin-forum-bootstrap needs to match your Kubernetes configuration and path/to/bootstrap.yml is the path to the bootstrap.yml file we created using launcher dump above.

Once your configmap is in place, you should be able to scale your deployment to one replica and see Discourse booting and running the same bootstrap process that launcher bootstrap would have performed. That takes a few minutes. When that is done, your Discourse installation will boot.

Others Bits of Configuration

A few other notes that I ran on the way to getting this (at least for now) fully configured:

  • Any upstream proxies must forward X-Forwarded headers, including both X-Fowarded-For, X-Forwarded-Proto, X-Forwarded-Port. Not doing so will result in strange authentication errors when trying to use Google login and probably other login providers.
  • Your nginx ingress controller must be configured to pass headers by setting use-forwarded-headers in the global config map. This took me a while to get right, since at least several times I edited the wrong configuration map, and then expected my ingress containers to restart when the configuration map changed. (They didn’t.)

Updating

To update the deployed installation, you regenerate the new bootstrap.yml file, update the config map, and then restart the container (easiest by scaling to 0 and then back to 1 replica).

This does incur a bit of downtime since the bootstrapping happens before the container is built. But this seems inevitable to me in cases where you need to update the configuration and/or change the base image. launcher rebuild is documented as stop; bootstrap; start, meaning that the bootstrap process will still cause downtime even if performed using the launcher script.

Comments

This fat container Discourse deployment pattern would be much easier to support if the launcher script would more cleanly separate (a) bootstrap steps that could be performed offline and only affect the files in the container and (b) bootstrap steps that modify or need access to the database or other state outside the container. The approach described above is a bit frustrating because you do see all kinds of JS uglification, asset minification, and other things that could be done with the previous deployment running… but they are just too mixed in with other things (like database migrations) that can’t be done without access to the database. I briefly thought about creating a container that would only perform the steps in templates/postgres.yml, but then noticed that database migrations were being done by the web template, and thought about plugins, and then just gave up :slight_smile:.

With better separation redeployment for fat containers could work something like this:

  • Build new fat container offline performing all steps internal to the container
  • Publish that container
  • When ready to upgrade, stop the previous container, start the new fat container, and let it finish any steps that need database access. Based on my experimentation those seem faster than some of the other bootstrap steps.

That would result in a bit less downtime. It’s probably not worth the effort for that reason alone, but I can imagine that this might also simplify more complex deployment scenarios involving shared databases or whatever.

7 Me gusta

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í.

8 Me gusta