This content originally appeared on DEV Community and was authored by NDREAN
With deploying on Kubernetes in view, the production images aim to be small. It isn't so easy to find documentation on how to produce slim Rails production images, so we present a solution here as well as a development hot-reload image.
TL;TR
A development mode Debian based image "slim-buster" (using the apt
package manager) can be around 1000Mb. To go down to a slim size of approx 200 Mb image, you may:
- use a two-staged Dockerfile
- use a Linux Alpine base (with the
apk
package manager) - use only Webpack and remove Sprockets (plus much shorter building time)
- bundle the production gems locally (as opposed to the "global" host gems) to the folder 'vendor/bundle'.
- Make the container stateless by removing the logs and using a Redis cache store.
We present also the development Dockerfile and how to use images in a context of micro-services to run a Rails monolith app with docker-compose
.
Production mode
We have a two-step building process starting from a ruby:alpine image.
First stage
In the first stage, size is not of utmost importance: its main task is compile the gems and the static assets. We upload there the needed tools for:
- Bundler to compile the declared gems and save them in a local folder "vendor/bundle"
- Webpacker to compile the static assets to the "/public" folder.
Bundler
We want Bundler to compile the gems needed for the app (the "Gemfile.lock" file) into the app's code local subfolder "/vendor/bundle" to minimize the code. We thus set the environment variable:
BUNDLE_PATH='vendor/bundle'
This also indicates to Bundler where the gems are located, so it is repeated in the second stage.
We want to bundle only the production gems:
bundle config set --without 'development test'
Webpacker
We want to use a Webpack only version of Rails, without Sprockets.
> rails new <app-name> --skip-sprockets ...
In particular, this removes the
sass-rails
gem which besides saving space, reduces a lot of building time.
In this stage, we want Webpack to compile and minimize the static assets into the "/public" folder.
> RUN bundle exec rake assets:precompile
Stateless container
We also want to redirect the logs from the container to STDOUT and STDERR. They will be lost but can further be used by a log collector. Just declare the logger in the file "/config/application.rb" and set:
#.env
RAILS_LOG_TO_STDOUT=true
We also use the built-in Redis cache store. Redis will be setup as LRU with maxmemory 100mb
(set in "redis.conf" circa line 566) and a maxmemory policy maxmemory-policy allkeys-lru
. We will declare the gem hiredis
and set:
config.cache_store = :redis_cache_store, { url: ENV['SIDEKIQ_REDIS'] }
Serving the static assets
There are different strategies: use a CDN or a reverse-proxy eg Nginx, or serving them with the default configured app-server Puma.
Since the app will most probably be running on Kubernetes, behind an Ingres controller, we will most probably serve the static assets from a CDN, on a separate port. Therefor, we want to use the app server - Puma here - to serve the static file, before migrating to Kubernetes. Since Rails by default won't serve static files in production mode, we set:
#.env
RAILS_SERVE_STATIC_FILES=true
Second stage
The second building stage will set a user to remove root privileges.
This stage will simply copy the app code, the compiled static files and the bundled gems from the host into the container.
The mandatory tzdata
package is needed by the tzinfo-data
gem when running on Windows.
Since we use Postgres, we still need the libpq
package so Rails can communicate with Postgres.
The app Dockerfile
#alpine.prod.Dockerfile
ARG RUBY_VERSION
FROM ruby:${RUBY_VERSION:-3.0.1-alpine} AS builder
ARG BUNDLER_VERSION
ARG NODE_ENV
ARG RAILS_ENV
RUN apk -U upgrade && apk add --no-cache \
postgresql-dev nodejs yarn build-base tzdata
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
ENV LANG=C.UTF-8 \
BUNDLE_JOBS=4 \
BUNDLE_RETRY=3 \
BUNDLE_PATH='vendor/bundle'
RUN gem install bundler:${BUNDLER_VERSION} --no-document \
&& bundle config set --without 'development test' \
&& bundle install --quiet
RUN yarn --check-files --silent --production
COPY . ./
RUN bundle exec rake assets:precompile
############################################################
FROM ruby:${RUBY_VERSION}
ARG RAILS_ENV
ARG NODE_ENV
RUN apk -U upgrade && apk add libpq netcat-openbsd tzdata\
&& rm -rf /var/cache/apk/*
# -D --disabled-password, don't assign a pwd, so cannot login
RUN adduser -D app-user
USER app-user
COPY --from=builder --chown=app-user /app /app
ENV RAILS_ENV=$RAILS_ENV \
NODE_ENV=$NODE_ENV \
RAILS_LOG_TO_STDOUT=true \
RAILS_SERVE_STATIC_FILES=true \
BUNDLE_PATH='vendor/bundle'
WORKDIR /app
RUN rm -rf node_modules
To build the image to further push it to a registry, we would run:
docker build --build-arg RUBY-VERSION=3.0.1-alpine --build-arg NODE-ENV=production --build-arg RAILS_ENV=production . -f alpine.prod.Dockerfile
Dev mode
In the development stage, we do not need a multi-stage build since hot-reload is our priority, not size. We use a separate Webpack service run with webpacker-dev-server
and code bindings to accelerate the changes. Size is not the priority, but when using Alpine and Webpack only, the compilation runs faster. It is largely inspired by these guys but somehow simplified.
Alpine proposes at the time of writing Nodejs LTS 14, Postgres 13 and Yarn 1.22 packages by default, which is largely acceptable.
ARG RUBY_VERSION
FROM ruby:${RUBY_VERSION} AS builder
ARG BUNDLER_VERSION
ARG NODE_ENV
ARG RAILS_ENV
ENV RAILS_ENV=${RAILS_ENV} \
NODE_ENV=${NODE_ENV} \
BUNDLER_VERSION=${BUNDLER_VERSION}
RUN apk update && apk add --no-cache \
build-base postgresql-dev nodejs yarn \
tzdata netcat-openbsd \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
ENV LANG=C.UTF-8 \
BUNDLE_JOBS=4 \
BUNDLE_RETRY=3
# BUNDLE_PATH='vendor/bundle'
# <- to bundle only the gems needed from Gemfile into local folder /vendor/bundle
RUN gem install bundler:${BUNDLER_VERSION} --no-document \
# && bundle config set --without 'development test' \
&& bundle install --quiet \
&& rm -rf /usr/local/bundle/cache/*.gem \
&& find /usr/local/bundle/gems/ -name "*.c" -delete \
&& find /usr/local/bundle/gems/ -name "*.o" -delete
RUN yarn --check-files --silent
COPY . ./
How to use this with a code example
We built a toy Rails monolith app. This simple app increments on button click a counter whose value is saved to a Postgres database and to a Redis database, and the click triggers background jobs with Sidekiq/Redis and workers.
This Webpack-only has some React installed:
> bundle exec rails wepacker:install:react
Local dev mode [branch "master"]
Since we use Sidekiq, Redis and Postgres, you will need a running Redis server and Postgres service when running locally. You then launch a Sidekiq service (with it's Redis service), the Rails server and finally run Webpack is dev mode. We used the process manager Overmind. Once the Redis server and Postgres services are up, run overmind start
with the following "Procfile":
assets: ./bin/webpack-dev-server
web: bundle exec rails server
redis-server: redis-server redis/redis.conf
worker: bundle exec sidekiq -C config/sidekiq.yml
Containerized prod mode [branch "docker-prod"]
We will compose a production containerized mode, and launch a Sidekiq service (linked to a Redis server), a Postgres database service and the Rails app served with the app-server Puma, the latter serves the compiled static files as per our configuration.
In real life, the Redis service will most probably be a managed solution, a remote service, so you may not need to create a Redis service. Here, we maintain a custom Redis database. You can test a managed service by commenting off the Redis service and dependencies in the "docker-compose.yml" file and pass the remote URL from the managed service by specifying ENV['REDIS_URL']
in the code for both Sidekiq and Redis (see note at the end). We used for example redislabs.
The same remark applies to the Postgres service. We will most probably use a managed service rather than running your own server on bare metal, so you can comment off this service as well and it's dependencies (such as depends_on:
keys) and use the remote url. For example, we can use ENV['ELEPHANT_URL']
when pointing to the managed service ElephantSQL.
To run these four images, we will need to build and run them within an internal network. This is done with the help of "docker-compose".
- Rails image is based on the Dockerfile and launched with
bundle exec rails server
with an open port, - the background job processor Sidekiq uses the same image as Rails but launches with
bundle exec sidekiq
with a config file to link to the Redis session used. - the (optional) custom Redis database uses the official Redis image launched with
redis-server
and uses a config file (password, persistence)...). - the Postgres database uses the official Postgres image with an initialization script ("init-user.sql").
Note that this is not really made for production. It is a validation step before deploying with Kubernetes. With managed services, this app deployed on Kubernetse would only use one image, for the Rails app and Sidekiq.
Initializing, Volumes and bindings
In the compose file, we use two mount binds:
- an SQL initializer for Postgres
- a Redis config file
The Postgres initializer "init-user.sql" will create a user with password when the data directory is empty. This is done by pushing an file in the "/docker-entrypoint-initdb.d" folder (see "Initialization scripts"). It is run whenever the "data" folder is empty and the script is made idempotent (see code at the end). These credentials will be used as environment variables to configure Postgres in Rails and used in the "docker-compose.yml" file.
The two other named volumes are relative to the main data of the databases Postgres (PG_DATA=/var/lib/postgresql/data
) and Redis (/data
).
The Redis database can or not be used by Sidekiq for managing its queue. We can run two distinct Redis sessions (see section on remote services at the end).
the "sidekiq" service is initialized in the file "/config/initializers/sidekiq.rb" where we provide the link to a Redis session via
ENV['SIDEKIQ_REDIS']
.the "app" service initializes the Redis in-memory database in a ad-hoc "/config.redis.yml" file where we pass
ENV['REDIS_URL']
.
It is initialized with a "redis.conf" file that holds the passwordrequirepass secretpwd
(circa line 500).
Since "sidekiq" and "app" are built with the same image, the app code holds the environment variables
REDIS_URL
andSIDEKIQ_REDIS
in the ".env" file. This ".env" file is passed to the "app", "sidekiq" and "redis" services.
- migration. Once the Postgres database has been initialized with a "user|password", the "app" service has an entrypoint file "manage-db.sh" that performs the idempotent command
rake db:prepare
to create and migrate the database. Again, we need to pass the same credentials with the environment variablePOSTGRES_PASSWORD=<password>
in the docker-compose.yml file and save them in the ".env" file in the code (see note at the end).
The "docker-compose" production file
version: "3"
x-app: &common
env_file: .env
build:
context: .
dockerfile: alpine.prod.Dockerfile
args:
- RUBY_VERSION=3.0.1-alpine
- BUNDLER_VERSION=2.2.21
- NODE_ENV=production
- RAILS_ENV=production
services:
pg:
image: postgres:13.3-alpine
ports:
- 5432
environment:
- POSTGRES_PASSWORD=dockerpassword
volumes:
- pg_data:/var/lib/postgresql/data
- ./pg/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
redisdb:
build:
context: ./redis
ports:
- 6379
env_file: .env
command: ["redis-server", "/usr/local/etc/redis.conf"]
volumes:
- redis_data:/data
- ./redis/config:/usr/local/etc/redis:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
sidekiq:
<<: *common
entrypoint: ["./app-pid.sh"]
command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
depends_on:
redisdb:
condition: service_health
app:
<<: *common
depends_on:
pg:
condition: service_healthy
redisdb:
condition: service_healthy
ports:
- 4000:3000
entrypoint: [./manage-db.sh]
command: ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
tmpfs:
- /tmp
volumes:
redis_data: {}
pg_data: {}
We run:
docker-compose up --build
If we don't use the "manage-db.sh" entrypoint, we would need to run:
docker-compose run --rm app bundle exec rails db:prepare
to setup the database and get the app up and running.
We added the package
netcat
in the Dockerfile since we usenc
for health testing. This is for testing purposes and should be removed when using a managed service.
The "development" "docker-compose.yml" file
For the development mode, we define five processes:
- the Rails app in dev mode for hot-reload,
- the Sidekiq background process
- the Webpack static assets manager run with
webpacker-dev-server
for hot-reload, - the Redis database adapter,
- the Postgres database adapter
We use mount bindings for hot-reload and faster loading.
version: "3"
x-app: &common
env_file: .env
build:
context: .
args:
- RUBY_VERSION=3.0.1-alpine
- BUNDLER_VERSION=2.2.21
#- NODE_VERSION=14 <- for "slim-buster" based image
- NODE_ENV=development
- RAILS_ENV=development
services:
pg:
image: postgres:13.3-alpine
ports:
- 5432
environment:
- POSTGRES_PASSWORD=cyberdyne
- PG_DATA=/var/lib/postgresql/data
volumes:
- pg_data:/var/lib/postgresql/data
- ./pg/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redisdb:
build:
context: ./redis
ports:
- 6379
env_file: .env
command: ["redis-server", "/usr/local/etc/redis.conf"]
volumes:
- redis_data:/data
- ./redis/config:/usr/local/etc/redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
webpacker:
<<: *common
command: ["./bin/webpack-dev-server"]
ports:
- "3035:3035"
volumes:
- .:/app:cached
- packs:/app/public/packs
environment:
- WEBPACKER_DEV_SERVER_HOST=0.0.0.0
sidekiq:
<<: *common
entrypoint: ["./app-pid.sh"]
command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
depends_on:
redisdb:
condition: service_healthy
app:
<<: *common
depends_on:
pg:
condition: service_healthy
redisdb:
condition: service_healthy
environment:
YARN_CACHE_FOLDER: /app/node_modules/.yarn-cache
WEBPACKER_DEV_SERVER_HOST: webpacker
ports:
- 4000:3000
entrypoint: ["./manage-db.sh"]
command: ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
volumes:
- bundle_cache:/usr/local/bundle
- node_modules:/app/node_modules
- .:/app:cached
- packs:/app/public/packs
- rails_cache:/app/tmp/cache
tmpfs:
- /tmp
volumes:
redis_data: {}
pg_data: {}
node_modules:
packs:
bundle_cache:
rails_cache:
Misc.
The "slim-buster" Dockerfile version
ARG RUBY_VERSION
FROM ruby:${RUBY_VERSION} AS builder
ARG BUNDLER_VERSION \
NODE_VERSION \
NODE_ENV \
RAILS_ENV
ENV RAILS_ENV=${RAILS_ENV} \
NODE_ENV=${NODE_ENV} \
BUNDLER_VERSION=${BUNDLER_VERSION}
RUN apt-get update \
# && DEBIAN_FRONTEND=noninteractive
&& apt-get install -y --no-install-recommends \
# for gems to be compiled
build-essential \
# to get desired node version
curl \
&& apt-get clean \
&& rm -rf /var/cache/apt/archives/* \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& truncate -s 0 /var/log/*log
RUN curl -sL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo 'deb https://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list
RUN apt-get update \
# && DEBIAN_FRONTEND=noninteractive
&& apt-get install -y --no-install-recommends \
# comm with PG with gem 'pg'
libpq-dev \
# compile assets
nodejs \
yarn \
&& apt-get clean \
&& rm -rf /var/cache/apt/archives/* \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& truncate -s 0 /var/log/*log
WORKDIR /app
COPY Gemfile Gemfile.lock package.json yarn.lock ./
ENV LANG=C.UTF-8 \
BUNDLE_JOBS=4 \
BUNDLE_RETRY=3 \
BUNDLE_PATH='vendor/bundle'
RUN gem install bundler:${BUNDLER_VERSION} --no-document \
&& bundle config set --without 'development test' \
&& bundle install --quiet \
&& rm -rf /usr/local/bundle/cache/*.gem \
&& find /usr/local/bundle/gems/ -name "*.c" -delete \
&& find /usr/local/bundle/gems/ -name "*.o" -delete
RUN yarn --check-files --silent
COPY . ./
RUN bundle exec rake assets:precompile
# && rm -rf node_modules tmp/cache app/assets vendor/assets lib/assets spec
###########################################
ARG RUBY_VERSION
FROM ruby:${RUBY_VERSION}
ARG NODE_ENV
ARG RAILS_ENV
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
# detect when services inside containers are up and running
netcat-openbsd \
# communicate with PG with gem 'pg'
libpq-dev \
&& apt-get clean \
&& rm -rf /var/cache/apt/archives/* \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& truncate -s 0 /var/log/*log
#<- if didn't used the flag BUNBLER_PATH='vendor/bundle', we would copy the host bundle folder
# COPY --from=builder /usr/local/bundle/ /usr/local/bundle/
RUN adduser --disabled-password app-user
USER app-user
COPY --from=builder --chown=app-user /app /app
ENTRYPOINT ["./app-pid.sh"]
ENV RAILS_ENV=$RAILS_ENV\
NODE_ENV=$NODE_ENV \
RAILS_LOG_TO_STDOUT=true \
RAILS_SERVE_STATIC_FILES=true \
BUNDLE_PATH='vendor/bundle'
WORKDIR /app
RUN rm -rf node_modules tmp/cache lib/assets
EXPOSE 3000
Remote services
If you want to rely on a remote Postgres or Redis service, you need to configure your account and pass the provided URL to the app.
For example, with a remote (free) service ElephantSQL, pass the supplied ENV[ELEPHANT_URL]
into "/config/database.yml" and set the supplied POSTGRES_PASSWORD=xxxx
variable in the "docker-compose.yml" file.
We can also use:
a local containerized Redis server for Sidekiq's queue. Set
SIDEKIQ_REDIS=redis://user:password@redisdb:6379
in the file "/config/sidekiq.rb"a remote Redis database for the app. We used a (free) service supplied by Redis Labs. Set
url: <%= ENV.fetch('REDIS_URL','') %>
with the supplied URL from Redislabs in the file "/config/redis.yml". This can replace the Redis service.
".env" file
An example of the environment variables used:
#.env
export POSTGRES_URL=postgresql://docker:dockerpassword@pg:5432
export ELEPHANT_URL=postgres://ortkcbqt:fhSBQrF3Dzl9WWA1FfRIjQmU7u3pBtTd@batyr.db.elephantsql.com/ortkcbqt
export SIDEKIQ_REDIS=redis://user:secretpwd@redisdb:6379
export REDIS_URL=redis://user:tq4hBlYvIvq0uU7hYMOYS6ErQKsSA2N8@redis-13424.c258.us-east-1-4.ec2.cloud.redislabs.com:13424
export RAILS_MASTER_KEY=cce3c51968fc41dd85b3d8b5d54f43eb
export RAILS_SERVE_STATIC_FILES=true
export RAILS_LOG_TO_STDOUT=true
Postgres USER initializer
This code is run whenever the "data" directory is empty to declare a with (the "<" & ">" signs are just here to emphasis).
#init.sql
DO $$
BEGIN
CREATE ROLE <docker> WITH SUPERUSER CREATEDB LOGIN PASSWORD <'dockerpassword'>;
EXCEPTION WHEN DUPLICATE_OBJECT THEN
RAISE NOTICE 'not creating role my_role -- it already exists';
END
$$;
The environment variable POSTGRES_URL=postgresql://<user>:<password>@pg:5423
passed to the "database.yml" in the code must match the "|" used in the "init-user.sql" file and we must pass POSTGRES_PASSWORD=<pasword>
in the "docker-compose.yml" file.
In you compose with non matching credentials, then you need to run docker volume prune
and rebuild.
Entrypoints
The rake command is idempotent.
#manage-db.sh <-- "app" service
#!/bin/sh
set -e
if [ -f tmp/pids/server.pid ]; then
rm tmp/pids/server.pid
fi
echo "Waiting for Postgres to start..."
while ! nc -z pg 5432; do sleep 0.2; done
echo "Postgres is up"
bundle exec rake db:prepare
exec "$@"
This code is the entrypoint of the "sidekiq" service. It "cleans" the "pid" file.
#app-pid.sh <-- "sidekiq" service
#!/bin/sh
set -e
if [ -f tmp/pids/server.pid ]; then
rm tmp/pids/server.pid
fi
exec "$@"
Redis database initializer
We create a file "/config/initializers/redis.rb" so that Rails will load it on startup and instantiate a Redis session.
#config/initializers/redis.rb
REDIS = Redis.new(Rails.application.config_for(:redis))
where config_for
will parse and fetch the "/config/redis.yml" config so we can use for example REDIS.set("key", 10)
in the app.
We can test the protection of the Redis database with the command below once the project is up and running:details here:
docker-compose run --rm redisdb redis-cli -h redisdb -p 6379
The output should be:
redisdb:6379> get compteur
(error) NOAUTH Authentication required.
redisdb:6379> auth secretpwd
OK
redisdb:6379> get compteur
"3"
The Redis persistence is parametered by configuring the "redis.conf" file:
- RDB mode periodic:
dbfilename "dump.rdb"
(line 327) - AOF mode: every seconde with
appendonly yes
(line 699) andappendfsync everysec
(line 729) andappendfilename appendonly.aof
(line 703)
Hope this help!
This content originally appeared on DEV Community and was authored by NDREAN
NDREAN | Sciencx (2021-07-02T18:02:22+00:00) Make slim Rails images. Retrieved from https://www.scien.cx/2021/07/02/make-slim-rails-images/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.