Dockeriza Rails para producción

miguel savignano
The Cocktail Engineering
7 min readOct 31, 2018

--

Introducción

En este artículo vamos a ver qué técnicas podemos usar para preparar una aplicación Rails sobre un cluster de Kubernetes. Para esto tendremos que aprender a construir una imagen de Docker y en un futuro artículo veremos cómo subirlo al cluster de Kubernetes.

Dado que Ruby es un lenguaje interpretado, el uso de Docker multistage nos ayudará a incluir en la imagen del contenedor sólo lo necesario para que la aplicación se ejecute de manera óptima en el entorno de producción.

Toda aplicación Rails tiene tres entornos predefinidos: desarrollo, test y producción. Cada entorno tiene sus diferencias, en especial el entorno de producción trata de optimizar el rendimiento. Si deseamos desplegar nuestra aplicación Rails en un cluster de Kubernetes necesitaremos construir primero una imagen de Docker preparada para ejecutarse en el entorno de producción; es necesario seguir unos pasos y utilizar las técnicas adecuadas para construir una imagen optimizada.

En este enlace podremos encontrar un Proyecto Rails de ejemplo con las siguientes características principales que tendremos en cuenta a la hora de construir nuestro Dockerfile:

  • Rails 5.2
  • Cifrado de secretos
  • Uso de Webpacker para compilar los assets JS y CSS (Webpack & Node.js)
  • Conexión con base de datos PostgreSQL

¿Por qué una imagen especial para producción?

Recordemos que en un entorno de producción nuestra aplicación solo estará ejecutándose en el contenedor, por ello no es necesario que nuestra imagen incluya las librerías de compilación o módulos Npm para compilar Javascript.

Te estarás preguntando por qué hay que eliminar tantos archivos y tener cuidado con las librerías de más, y la respuesta es que buscamos tener builds y despliegues más rápidos. Cuanto menos pese nuestra imagen más rápido se podrán crear nuevos contenedores de Docker, subirlos a un registro de imágenes (en caso de que lo usemos), y además la aplicación podrá escalar más rápidamente.

Al construir una imagen para producción debemos tener en cuenta lo siguiente:

  • Aplicar entorno de producción en Rails
  • Compilar assets
  • No instalar gemas de desarrollo o test
  • No tener librerías de compilación
  • Eliminar node modules
  • No tener archivos innecesarios doc test etc…

Optimiza tu imagen con Docker multistage

Al instalar algunas gemas es necesario tener librerías de compilación o módulos Npm como Webpack; con Docker multistage separaremos nuestra imagen en dos pasos, lo necesario para construir las dependencias y lo necesario para su ejecución.

Como veremos, esto se verá reflejado en nuestro Dockerfile con varias líneas FROM, una por cada stage de nuestro build.

Además, para no enviar demasiados archivos al construir nuestra imagen no debemos olvidarnos del fichero .dockerignore. La recomendación es que este fichero contenga lo mismo que el fichero .gitignore y por supuesto evitar que el histórico de nuestro espacio de trabajo de git no se suba a la imagen del contenedor con la siguiente línea:

.git

Construir imagen de Rails para entorno de producción

Crearemos nuestro fichero Dockerfile teniendo en cuenta las particularidades que necesita una aplicación de Rails para funcionar en producción.

1. Compilación de gemas: algunas gemas de Rails necesitan construir binarios de C para ello hay que instalar librerías como gcc o make; agregaremos el paquete build-essential que contiene todo lo necesario:

RUN apt-get install -y build-essential

2. Instalar gemas: en el entorno de producción no instalaremos las gemas de desarrollo o de test y además guardaremos el código fuente de las gemas en la carpeta /vendors de nuestra aplicación.

RUN bundle install -j 4 --without development test --deployment

3. Optimizar el proceso de build de nuestra imagen: las aplicaciones Rails tienen dos posibles fuentes de dependencias que podemos necesitar resolver: las gemas de Ruby y los paquetes Npm necesarios para la precompilación de assets. Durante el proceso de desarrollo es posible que estas dependencias se actualicen o no, pero en todo caso sólo queremos resolverlas cuando los ficheros que declaran dependencias hayan cambiado.

Como Docker construye las imágenes por capas podemos aprovechar la caché de capas de Docker para resolver estas dependencias en capas diferentes y no volver a ejecutar bundle o yarn si no ha habido cambios:

COPY Gemfile Gemfile.lock /app/
RUN bundle install -j 4 -without development test -deployment
COPY package.json yarn.lock /app/
RUN yarn install --pure-lockfile --production
COPY . /app

Con esta técnica conseguimos crear una capa que contiene todas nuestras librerías. Recordemos que la idea es que si cambia nuestro Gemfile o package.json se invalidará la cache de las librerías e instalará las nuevas librerías.

4. Master key o secretos: para la compilación de assets necesitaremos la master key, para ello a la hora de construir la imagen lo enviaremos como un parámetro de compilación declarado en el Dockerfile:

ARG RAILS_MASTER_KEY

Con lo cual tendremos que pasar este argumento cuando lancemos nuestro build en Docker:

docker build -t rails-production --build-arg RAILS_MASTER_KEY=secret-key .

5. Compilación de assets: necesitamos definir las variables de entorno NODE_ENV y RAILS_ENV para que nuestros assets se compilen con las optimizaciones pertinentes (minify, hashing files, bundle files, etc).

Bola extra: con estas líneas, además de compilar los assets estarías comprobando que no hay errores de sintaxis en el código fuente de Ruby, ya que carga tu módulo principal de Rails para ello.

ENV NODE_ENV=production RAILS_ENV=productionRUN bundle exec rails assets:precompile

6. Rails logs: nuestra aplicación podría estar ejecutándose en diferentes contenedores de Docker por lo tanto es mejor mostrar los logs por STDOUT y utilizar la facilidad de logs que utilice nuestro cluster.

ENV RAILS_LOG_TO_STDOUT=true

7. Peso de imagen final: generamos una nueva imagen donde copiaremos solo lo necesario de nuestra aplicación Rails así eliminaremos node modules y todos los archivos que no necesitemos cuando se esté ejecutando nuestro contenedor.

Dockerfile para el entorno de producción:

FROM ruby:2.5.1-slim as builder
ENV NODE_ENV=production RAILS_ENV=production
ARG RAILS_MASTER_KEY
RUN apt-get update -qq && apt-get install -y build-essential \
libpq-dev curl
RUN curl -sL https://deb.nodesource.com/setup_8.x | bin/bash -\
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | \
apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | \
tee /etc/apt/sources.list.d/yarn.list \
&& apt-get update -qq && apt-get install -y nodejs yarn
RUN mkdir /app
WORKDIR /app
COPY Gemfile Gemfile.lock /app/
RUN bundle install -j 4 -without development test -deployment
COPY package.json yarn.lock /app/
RUN yarn install --pure-lockfile --production
COPY . /app
RUN bundle exec rails assets:precompile
# ************ Multi Stage **************
FROM ruby:2.5.1-slim
ENV NODE_ENV=production RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true
RUN apt-get update -qq && apt-get install -y libpq-dev nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir /app
WORKDIR /app
# ***Copy only necessary files (.dockerignore don’t work in multistage)***
COPY --from=builder /app/app /app/app
COPY --from=builder /app/config /app/config
COPY --from=builder /app/bin /app/bin
COPY --from=builder /app/db /app/db
COPY --from=builder /app/lib /app/lib
COPY --from=builder /app/storage /app/storage
COPY --from=builder /app/vendor /app/vendor
COPY --from=builder /app/public /app/public
COPY --from=builder /app/Gemfile /app/Gemfile.lock /app/
CMD [ "sh", "-c", "bundle exec rake db:create db:migrate && bundle exec rails server -b 0.0.0.0" ]

Construir imagen de Nginx

Aunque Rails puede incorporar su propio servidor de aplicación (Unicorn, Puma…) es muy recomendable utilizar un servidor web para servir los assets generados por Webpack. Por ejemplo, es muy común utilizar Nginx, que es un servidor web que puede hacer las veces de como proxy inverso, cache de HTTP, y balanceador de carga.

Podemos usar una configuración básica de Nginx donde nos servirá los assets generados por la aplicación Rails y el resto de peticiones las redirigirá a la aplicación Rails.

server {
listen 80;
server_name localhost;
root /app/public;
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
expires max;
log_not_found off;
}
location @proxy {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

Para que nuestra imagen de Nginx pueda servir los assets de la aplicación Rails utilizaremos una vez más Docker multistage, copiando los assets de Rails dentro del contenedor de Nginx

FROM rails-production as rails
FROM nginx:1.13-alpine
COPY docker/production/nginx/default.conf docker/production/nginx/default.conf
COPY -- from=rails /app/public /app/public

Construiremos la imagen de nuestro contenedor con Nginx con el siguiente comando:

docker build -t nginx-rails .

Conclusión

Podemos utilizar este Dockerfile básico como punto de partida para construir una imagen de Rails:

FROM ruby:2.5.1-slim
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
RUN mkdir /myapp
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
RUN bundle install
COPY . /myapp

Agregando los módulos de Javascript la imagen pesaría unos 674MB aproximadamente y estaría configurada para ejecutarse en desarrollo.

Nuestra imagen de Rails tiene las optimizaciones necesarias para ejecutarse en producción y pesaría aproximadamente 355MB.

Como hemos visto, al preparar contenedores de nuestra aplicación Rails entran en juego nuevos conceptos y técnicas diferentes a los métodos tradicionales de despliegue como Capistrano, por ello debemos construir una imagen especial para nuestro entorno de producción y sacarle todo el provecho que tienen los contenedores.

Enlaces de utilidad

--

--