Construyendo la imagen sin tocar la base de datos

Hola a todos.

Tengo una instancia de Discourse bastante pequeña en funcionamiento (de hecho, lleva años funcionando sin apenas problemas): https://discuss.cubeisland.de/.
He estado utilizando el proceso de despliegue estándar basado en launcher en una máquina virtual dedicada (en mi propio hardware en un centro de datos). Lo único que he cambiado a lo largo de los años ha sido migrar a una base de datos PostgreSQL externa compartida.

Recientemente empecé a migrar aplicaciones desde máquinas virtuales dedicadas a un clúster Docker Swarm como paso preparatorio para eventualmente migrar a un clúster de Kubernetes, principalmente para ahorrar recursos y hacer que partes de la infraestructura sean más “elásticas”.

Hoy fue el día en que me enfoqué en esta pequeña instancia de Discourse, una de las pocas máquinas virtuales de aplicación dedicadas que quedaban. “Ya se está ejecutando en Docker, ¿qué tan difícil puede ser desplegarla en un clúster?”, pensé. Y según lo que leí, en realidad podría serlo. Solo tendría que tomar la imagen de la instancia actualmente en ejecución, subirla a nuestro registro interno y ejecutarla en el clúster, y todo funcionaría perfectamente, lo cual es genial.

Revisé los archivos de launcher, especialmente las plantillas y ejemplos, y pensé que probablemente sería una buena idea tener Redis separado en este tipo de despliegue, y quizás podría configurar un trabajo de CI para construir nuevas imágenes cuando añada plugins o quiera actualizar. Así que descargué discourse_docker localmente, copié mi definición de contenedor app.yml existente al clon e intenté ejecutar ./launcher bootstrap app para construir una imagen que luego podría subir a mi registro, sin desplegarla inmediatamente.

Para mi sorpresa, el script intentó conectarse al servidor PostgreSQL de “producción” para migrar la base de datos, lo cual, afortunadamente, no pudo hacer desde mi estación de trabajo local.

Miré alrededor aquí y aparentemente así es como funciona esto, lo que me hace preguntarme:

  1. ¿Cómo se construiría un contenedor para una nueva instancia, donde aún no tengo base de datos? ¿Necesitaría configurar la base de datos de producción antes de poder construir la imagen?
  2. Asumo que db:migrate se ejecuta solo una vez, así que si tengo varias instancias similares (por ejemplo, producción y pruebas), tendría que actualizar una de las instancias para construir la nueva imagen y luego no podría usar la misma imagen para la segunda instancia, aunque la imagen sería idéntica.
  3. ¿Cómo procedería para construir imágenes para instancias donde el servidor de base de datos no es accesible desde el sistema que construye la imagen (lo cual no debería ser tan poco común)?

Después de leer varios posts (obviamente incluyendo este), soy perfectamente consciente de las razones del proceso de construcción tal como está ahora y veo el valor que tiene para el 99% de las personas que despliegan Discourse de manera casual en su máquina virtual estándar completa. Y estoy muy acostumbrado a los modelos de contenedores “todo en uno” y no me opongo a ello. Después de todo, el valor clave de Docker radica en el hecho de que el proveedor de software puede preconfigurar configuraciones altamente optimizadas y empaquetarlas en un entorno de ejecución reproducible, eliminando la necesidad de mucho conocimiento muy específico de la aplicación por parte del equipo de operaciones. Así que estoy totalmente de acuerdo con usar las herramientas que proporcionan, ¿por qué esperaría que alguien más construya contenedores mejores que el propio proveedor del software? ¿Por qué querría separar nginx y la aplicación Ruby cuando no hay ningún beneficio que obtener, solo para hacer el despliegue más “puro” (lo que quiera que eso signifique…)?

Sin embargo, es extraño ver un contenedor que modifica el estado en tiempo de ejecución cuando ni siquiera está cerca de ejecutarse. Ya ejecuto bastantes aplicaciones en contenedores y he contenedorizado varias yo mismo, algunas de las cuales nunca estuvieron pensadas para ejecutarse en contenedores.

El ejemplo principal que me viene a la mente, de una aplicación que trata requisitos/problemas similares de manera parecida a Discourse, es GitLab. Aunque ahora ofrecen un elegante gráfico Helm para un despliegue completamente descompuesto de Kubernetes “cómo debería ser”, supongo (sin mirar ningún número) que un 99% similar de sus despliegues de tamaño pequeño-mediano están utilizando la imagen Docker omnibus de GitLab (o el paquete OS, que es prácticamente lo mismo). Tienen un proceso de arranque similar, pero basado en Chef dentro del contenedor, que se ejecuta en cada inicio y realiza las tareas habituales como migraciones de base de datos y compilación de activos.

Sí, el inicio de GitLab puede tardar varios minutos debido a esto, pero nunca ha sido un problema en los despliegues que he visto (algunos en empresas más grandes). Especialmente con sistemas modernos de orquestación como Docker Swarm y Kubernetes y lo que sea, que pueden ejecutar actualizaciones rodantes por ti, donde la instancia antigua se apaga solo si la nueva instancia está en ejecución y ha pasado con éxito las verificaciones de salud y lista, un proceso de despliegue largo podría no ser realmente un problema. Pero incluso sin actualizaciones rodantes sofisticadas, que pueden o no funcionar, también puedes tolerar bastante tiempo de inactividad en muchas situaciones.

Entonces: ¿Es posible configurar launcher para omitir las operaciones dependientes de la base de datos durante la construcción de la imagen y realizar estas operaciones en su lugar durante el inicio del contenedor?

Estoy definitivamente dispuesto a invertir algo de tiempo yo mismo, pero mi tiempo por la noche es limitado, así que cualquier orientación sería muy bienvenida.

También estoy abierto a procesos completamente diferentes si crees que esto es estúpido o ni siquiera posible, etc.

¡Gracias por cualquier comentario!

Quería hacer lo mismo que tú: ejecutamos Discourse en Amazon ECS, por lo que necesitábamos poder construir solo la imagen web y enviarla a un registro. No me apetecía modificar el proceso de compilación de Discourse porque queremos mantenernos lo más cerca posible de la instalación oficial.

En su lugar, usamos el script launcher normal para construir una configuración de dos contenedores en una máquina local, pero ignoramos el contenedor de datos y enviamos el contenedor web al registro. En tiempo de ejecución, sobrescribimos los detalles de conexión de Postgres y Redis mediante variables de entorno.

Desplegar la nueva imagen es un proceso de 3 pasos:

  1. Ejecutar las migraciones previas seguras. Hacer que ECS ejecute este comando (con la nueva imagen):

     SKIP_POST_DEPLOYMENT_MIGRATIONS=1 rake db:migrate
    
  2. Desplegar la nueva imagen. Actualizar el servicio de ECS.

  3. Ejecutar las migraciones posteriores. Hacer que ECS ejecute este comando:

     SKIP_POST_DEPLOYMENT_MIGRATIONS=0 rake db:migrate
    

Tener un contenedor de datos local ejecutándose mientras construimos la imagen probablemente sea un desperdicio, pero nos permite usar el archivo web.template.yml estándar sin preocuparnos por qué partes intentan comunicarse con la base de datos o Redis.

¡Gracias por eso! También pensé que podría simplemente levantar una instancia de PostgreSQL durante la construcción de la imagen y descartarla una vez finalizada la construcción real.

¡Por fin me tomé el tiempo para implementar esto!

Implementé la compilación de la imagen utilizando una tubería de GitLab CI que ejecuta PostgreSQL y Redis como servicios durante la compilación y los descarta después:

Ahora solo tengo que automatizar la implementación con las migraciones de la base de datos.

Esto ha estado funcionando durante más de un año sin tocarlo nunca, ni siquiera para la versión 2.8.

He movido la compilación de la imagen a GitHub: GitHub - pschichtel/discourse-docker: A reusable Discourse container built using the launcher tool.

La imagen se publica en pschichtel/discourse:stable-web_only

parece que esto finalmente se rompió. al actualizar de 3.0.6 a 3.1.0, no se realizaron migraciones de la base de datos. Sin embargo, ejecutar el bundle exec rake db:migrate final dentro del contenedor en ejecución funcionó, aunque solo después de otro reinicio del contenedor.

Tienes que migrar de nuevo cuando la nueva imagen se haya iniciado sin esa variable de entorno configurada. Hay una tarea de rake que hará eso, pero no la recuerdo ni la encuentro desde mi teléfono. Algo como ensure_post_migrations.

Por lo que vale, no he notado ninguna interrupción. Sigo principalmente la rama de lanzamiento beta y, hasta donde puedo decir, las migraciones se han ejecutado correctamente en cada paso de la serie 3.1.0.beta…

Encontré db:ensure_post_migrations a través de rake -AT.

¿Cuál es la diferencia entre db:migrate con SKIP_POST_DEPLOYMENT_MIGRATIONS=0 y db:ensure_post_migrations?

Ok, después de revisar el código, entiendo lo que hace db:ensure_post_migrations. Se supone que debe usarse en la misma ejecución de rake antes de db:migrate para asegurar que SKIP_POST_DEPLOYMENT_MIGRATIONS se establezca en 0. Mi script ya se encarga de eso:

el .gitlab-ci.yml:

./migrate.sh pre || echo "Redis no se está ejecutando durante las pre-migraciones, omitiendo..."
docker stack deploy --prune --resolve-image always -c "$STACK.yml" "$STACK"
./docker-stack-wait.sh -t 180 "$STACK"
./migrate.sh post

el migrate.sh:

#!/usr/bin/env sh

if [ "$(docker ps -q --filter "label=com.docker.stack.namespace=${STACK}" --filter "label=com.docker.swarm.service.name=${STACK}_${DISCOURSE_REDIS_HOST}" | wc -l)" = "0" ]
then
    echo "No se encontró el contenedor de redis, ¡imposible ejecutar las migraciones!"
    exit 1
fi

if [ "$1" = "pre" ]
then
    skip_post=1
else
    skip_post=0
fi


docker run \
    --rm \
    --name "discourse-migration-${DISCOURSE_DB_HOST}-${DISCOURSE_DB_NAME}" \
    --network "${STACK}_discourse" \
    --workdir /var/www/discourse \
    -u discourse \
    -e SKIP_POST_DEPLOYMENT_MIGRATIONS="$skip_post" \
    -e LANG="${LANG}" \
    -e DISCOURSE_DEFAULT_LOCALE="${DISCOURSE_DEFAULT_LOCALE}" \
    -e DISCOURSE_HOSTNAME="${DISCOURSE_HOSTNAME}" \
    -e DISCOURSE_DEVELOPER_EMAILS="${DISCOURSE_DEVELOPER_EMAILS}" \
    -e DISCOURSE_SMTP_ADDRESS="${DISCOURSE_SMTP_ADDRESS}" \
    -e DISCOURSE_SMTP_PORT="${DISCOURSE_SMTP_PORT}" \
    -e DISCOURSE_DB_USERNAME="${DISCOURSE_DB_USERNAME}" \
    -e DISCOURSE_DB_PASSWORD="${DISCOURSE_DB_PASSWORD}" \
    -e DISCOURSE_DB_HOST="${DISCOURSE_DB_HOST}" \
    -e DISCOURSE_DB_NAME="${DISCOURSE_DB_NAME}" \
    -e DISCOURSE_REDIS_HOST="${DISCOURSE_REDIS_HOST}" \
    "$DISCOURSE_IMAGE" \
    bundle exec rake db:migrate

Ejecuta db:migrate con SKIP_POST_DEPLOYMENT_MIGRATIONS=1 en la nueva imagen en el docker swarm mientras discourse todavía está ejecutando la versión antigua. Luego, implementa la nueva imagen en el swarm y espera a que converja. Al final, ejecuta db:migrate de nuevo, pero con SKIP_POST_DEPLOYMENT_MIGRATIONS=0.

Esto ha funcionado de manera confiable para todas las versiones durante más de 2 años. Dado que funcionó para ti, @simonk, ¿hiciste algo fundamentalmente diferente en comparación con mi script?

No, todavía sigo el mismo proceso que describí aquí arriba, que hasta donde sé es muy parecido al tuyo. Yo uso un rake db:migrate simple en lugar de bundle exec rake db:migrate, pero no creo que eso marque una gran diferencia.

Nunca he usado docker stack o swarm. ¿Existe alguna posibilidad de que haya un error en alguno de tus scripts que pueda hacer que el script migrate.sh use la imagen antigua en lugar de la nueva?

No lo he comprobado explícitamente, lo investigaré. El enjambre definitivamente usará la última imagen, pero tal vez el script de CI por alguna razón no usó la última.

Investigué esto ahora con la actualización 3.1.1. De hecho, el script de CI estaba utilizando una versión anterior del contenedor.