preloader
blog post

Optimized Dockerfile: YJIT + jemalloc + bootsnap

Preface

As we are scaling our applications, we wanted to make sure that they are running as efficiently as possible. This week we wanted to share with you our optimized Dockerfile for building Ruby on Rails apps with YJIT, jemallocand bootsnap enabled. This Dockerfile will include libvips for ActiveStorage, postgresql-client and redis-tools for interacting with PostgreSQL and Redis.

Optimized Dockerfile

# Create base image
FROM ruby:3.2-slim-bookworm AS base

# Set ENV variables
ENV RAILS_ENV=production \
    RACK_ENV=production \
    NODE_ENV=production \
    APP_ENV=production \
    RAILS_LOG_TO_STDOUT=true \
    RAILS_MAX_THREADS=10 \
    RAILS_SERVE_STATIC_FILES=true
ENV GEM_HOME="/usr/local/bundle" \
    BUNDLE_WITHOUT="development:test" \
    BUNDLE_FROZEN="1" \
    BUNDLE_JOBS="32"
ENV PATH $GEM_HOME/bin:$GEM_HOME/gems/bin:$PATH

WORKDIR /app

RUN apt-get update -qq && \
    apt-get upgrade -y --no-install-recommends
RUN apt-get -y --no-install-recommends install zip gnupg tzdata curl wget libjemalloc2 libvips \
    apt-transport-https apt-utils ca-certificates postgresql-client redis-tools

# Update gems and bundler
RUN gem update --system --no-document

# Clean cache
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*;
RUN rm -rf /root/.local

# Create setup image
FROM base AS setup
RUN 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
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get update -qq
RUN apt-get install --no-install-recommends -y build-essential libpq-dev zlib1g-dev libssl-dev libreadline-dev \
    libyaml-dev libsqlite3-dev sqlite3 libxml2-dev libxslt-dev libcurl4-openssl-dev libffi-dev pkg-config dirmngr \
    git-core nodejs yarn libvips-dev python-is-python3

# Install application gems
COPY Gemfile* ./
RUN gem install bundler --no-document
RUN bundle install

# Install JS libs
COPY package.json *yarn* ./
RUN yarn install --frozen-lockfile

# Copy application files
COPY . .

# Build assets
RUN ./bin/rails assets:precompile

# Remove extra files
RUN rm -rf node_modules
RUN rm -rf /usr/local/bundle/cache/*

# Create runtime image
FROM base AS runtime
ENV LD_PRELOAD="libjemalloc.so.2" \
    MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:5000,muzzy_decay_ms:5000,narenas:2" \
    RUBY_YJIT_ENABLE="1"

# Copy built artifacts: gems, application
COPY --from=setup /usr/local/bundle /usr/local/bundle
COPY --from=setup /app /app

# Precompile bootsnap code for faster boot times
RUN rm -rf /app/tmp/cache
RUN bundle exec bootsnap precompile --gemfile app/ lib/

# Initialize entrypoint
EXPOSE 3000
CMD ["./bin/rails", "server", "-p", "3000"]

Breaking it down

This Dockerfile sets up a container for a Ruby on Rails application with specific environment variables and settings. Let’s break it down:

Base Stage

This stage is used to install all the dependencies required when running the application. It is also used as a base image for the setup stage.

1. Base Image:

  • Use ruby:3.2-slim-bookworm image as the base image. This means your application will run on top of the Ruby 3.2 version with a slim variant that includes fewer pre-installed packages, making the image smaller and more efficient.

2. Environment Variables:

  • The ENV commands set various environment variables that affect how your Ruby on Rails application behaves when running inside the container.
  • RAILS_ENV, RACK_ENV, NODE_ENV, and APP_ENV are all set to production. This ensures that the application runs in a production environment, which typically involves optimizations and different settings compared to development.
  • RAILS_LOG_TO_STDOUT is set to true, indicating that Rails should direct its log output to the standard output ( stdout) of the container. This is often useful for logging purposes when working with containers.
  • RAILS_MAX_THREADS is set to 10, limiting the number of concurrent threads the Rails application can handle.
  • RAILS_SERVE_STATIC_FILES is set to true, which enables the Rails application to serve static files directly.
  • GEM_HOME is set to /usr/local/bundle, specifying where Ruby gems will be installed within the container.
  • BUNDLE_WITHOUT is set to development:test, indicating that development and test dependencies should not be installed by Bundler.
  • BUNDLE_FROZEN is set to 1, which means that the bundle will not be modified after it is initially installed.
  • BUNDLE_JOBS is set to 32, specifying the maximum number of parallel jobs that Bundler can use during installation.
  • The PATH environment variable is modified to include the bin directories of the Ruby gems and bundle. This ensures that the commands provided by the gems and Bundler are accessible from the command line.

3. Working Directory:

  • WORKDIR /app sets the working directory within the container to /app. This is where subsequent commands will be executed.

4. Package Installation:

  • apt-get update -qq updates the package list on the system.
  • apt-get upgrade -y --no-install-recommends upgrades existing packages without installing recommended additional packages.
  • apt-get -y --no-install-recommends install installs various packages:
    • zip: Utility for creating and managing ZIP archives.
    • gnupg: GNU Privacy Guard, used for encryption and digital signatures.
    • tzdata: Time zone data for system configuration.
    • curl and wget: Tools for making HTTP requests.
    • libjemalloc2: Memory allocator.
    • libvips: Image processing library.
    • apt-transport-https and apt-utils: Utilities for handling HTTPS-based APT repositories.
    • ca-certificates: Certificate authorities for secure communication.
    • postgresql-client: PostgreSQL client tools.
    • redis-tools: Redis client tools.

5. Rubygem Update:

  • gem update --system --no-document updates the RubyGems system to the latest version without generating documentation.

6. Cleanup:

  • apt-get clean && rm -f /var/lib/apt/lists/*_*; cleans the package cache and removes specific files to reduce the image size.
  • rm -rf /root/.local removes a directory related to local user settings used by gem install.
Setup Stage

This stage is used to install all the dependencies required when building the application. It will also install all the gems and JS libraries required by the application, and precompile the assets.

1. New Build Stage:

  • FROM base AS setup creates a new build stage named setup based on the previously defined base stage. This helps to separate the setup-specific steps from the base image configuration.

2. Adding Yarn Repository:

  • curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - imports the Yarn package manager’s GPG key for secure installation.
  • echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list adds the Yarn repository to the package sources.

3. Adding Node.js Repository:

  • curl -sL https://deb.nodesource.com/setup_18.x | bash - fetches the Node.js repository setup script and runs it, adding the Node.js repository to the package sources.

4. Updating and Installing Dependencies:

  • apt-get update -qq updates the package list.
  • apt-get install --no-install-recommends -y installs various development libraries and tools, including:
    • build-essential: Essential build tools like compilers and make.
    • libpq-dev: Development files for PostgreSQL.
    • zlib1g-dev, libssl-dev, libreadline-dev, libyaml-dev, libsqlite3-dev: Libraries needed for various aspects of application development.
    • libxml2-dev, libxslt-dev, libcurl4-openssl-dev, libffi-dev, pkg-config: Additional libraries for XML, XSLT, curl, and FFI.
    • dirmngr: DNS resolver for GnuPG.
    • git-core: Version control system.
    • nodejs: Node.js runtime.
    • yarn: Yarn package manager.
    • libvips-dev: Development files for the libvips image processing library.
    • python-is-python3: Ensures python command refers to Python 3.

5. Copying Gemfile and Installing Gems:

  • COPY Gemfile* ./ copies the Gemfile and Gemfile.lock from the host to the current directory in the container.
  • gem install bundler --no-document installs Bundler without generating documentation.
  • bundle install installs the application’s Ruby gems based on the Gemfile.

6. Copying package.json and Installing JavaScript Dependencies:

  • COPY package.json yarn ./ copies the package.json file and any files with “yarn” in their names from the host to the current directory in the container.
  • RUN yarn install –frozen-lockfile installs JavaScript dependencies using Yarn based on the package.json file. The –frozen-lockfile flag ensures that the dependencies are installed exactly as specified in the lockfile, preventing any unexpected changes.

7. Copying Application Files:

  • COPY . . copies all files and directories from the host to the current directory in the container. This includes the entire application codebase.

8. Building Assets:

  • ./bin/rails assets:precompile runs the Rails task to precompile assets. This process involves transforming and bundling CSS, JavaScript, and other assets for production use.

9. Removing Extra Files:

  • rm -rf node_modules removes the node_modules directory, which contains JavaScript dependencies installed by Yarn. These are no longer needed after assets are precompiled.
  • rm -rf /usr/local/bundle/cache/* cleans up the cache directory used by Bundler to store gem cache files.
Runtime Stage

This final part of the Dockerfile sets up the runtime environment for the application and configures the container to run the application:

1. New Build Stage - Runtime:

  • FROM base AS runtime creates a new build stage named runtime based on the base stage. This stage is focused on preparing the runtime environment for the application.

2. Environment Variables:

  • ENV LD_PRELOAD="libjemalloc.so.2" sets the LD_PRELOAD environment variable to preload the libjemalloc memory allocator library.
  • MALLOC_CONF configures various options for the memory allocator.
  • RUBY_YJIT_ENABLE enables the YJIT Just-In-Time compiler for Ruby.

3. Copying Built Artifacts:

  • COPY --from=setup /usr/local/bundle /usr/local/bundle copies the installed Ruby gems from the setup stage to the same location in the runtime stage.
  • COPY --from=setup /app /app copies the application files from the setup stage to the same location in the runtime stage.

4. Precompiling Bootsnap Code:

  • rm -rf /app/tmp/cache removes the temporary cache directory.
  • bundle exec bootsnap precompile –gemfile app/ lib/ precompiles code using Bootsnap to improve boot times. Bootsnap is a gem that speeds up booting of Ruby applications.

5. Initializing Entrypoint:

  • EXPOSE 3000 specifies that the application inside the container will be accessible on port 3000.
  • CMD ["./bin/rails", "server", "-p", "3000"] defines the default command that will be executed when the container starts. It starts the Rails server on port 3000.

Final Result

In the end we have a docker image that is ready to be deployed to production. We tested if YJIT and jemalloc are enabled by running:

$ docker run -it --rm -e "MALLOC_CONF=stats_print:true" <image_name> ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) +YJIT [aarch64-linux]
___ Begin jemalloc statistics ___

We verified that +YJIT is present in the output as well as jemalloc stats. Hope this helps you to get started with running YJIT and jemalloc in production.

Until next time amigos 🙌

Related Articles

Ready to get started?

No credit card required to sign up. All paid plans come with 30 day cancellation and full refund.

Get Started for Free