Ruby Docker
GitHub Actions
Section titled “GitHub Actions”# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: CIon: push
# When an engineer pushes changes to a branch, any current build on that same branch is cancelled in place of the new one.concurrency: group: ${{ github.ref }} cancel-in-progress: true
permissions: contents: read id-token: write checks: write
jobs: test: runs-on: ubuntu-latest strategy: fail-fast: true matrix: # Set N number of parallel jobs you want to run tests on. # Use higher number if you have slow tests to split them on more parallel jobs. # Remember to update shardTotal below to 0..N-1 shardTotal: [4] # set N-1 indexes for parallel jobs # When you run 2 parallel jobs then first job will have index 0, the second job will have index 1 etc shardIndex: [0, 1, 2, 3]
steps: - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1 with: bundler-cache: true
- name: Run RSpec tests run: ./bin/ci env: CI_SHARD_TOTAL: ${{ matrix.shardTotal }} CI_SHARD_INDEX: ${{ matrix.shardIndex }}
- name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.shardIndex }}-${{ matrix.shardTotal }} path: coverage-${{ matrix.shardIndex }}-${{ matrix.shardTotal }} include-hidden-files: true if-no-files-found: error
merge-coverage: name: Merge Test Coverage runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1 with: bundler-cache: true
- name: Download all coverage artifacts from test jobs uses: actions/download-artifact@v4 with: pattern: coverage-*
- name: Merge coverage results run: | mkdir -p coverage bundle exec ruby -e " require 'simplecov'; require 'simplecov_json_formatter'; SimpleCov.collate Dir['coverage-*/.resultset.json'], 'rails' do enable_coverage :branch formatter SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter ]) end "
- name: Upload merged coverage uses: actions/upload-artifact@v4 with: name: coverage-merged path: coverage include-hidden-files: true if-no-files-found: error
build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1 with: bundler-cache: true
- uses: actions/setup-node@v4 with: node-version-file: ".nvmrc"
- run: npm ci
- run: RAILS_ENV=production bundle exec rake assets:precompile
- name: Cache Docker layers uses: actions/cache@v4 with: path: /tmp/.buildx-cache-app key: ${{ runner.os }}-buildx-app-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx-app
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Build production Docker image run: | docker buildx build \ --file Dockerfile \ --tag $APPLICATION_IMAGE_BUILD \ --build-arg CODE_VERSION="${{github.sha}}" \ --compress \ --target prod \ --load \ --cache-from type=local,src=/tmp/.buildx-cache-app \ --cache-to type=local,dest=/tmp/.buildx-cache-app-new,mode=max \ .
- name: APP::Move cache run: | rm -rf /tmp/.buildx-cache-app # Remove the old cache. mv /tmp/.buildx-cache-app-new /tmp/.buildx-cache-app # Move the new cache to replace the old cache.
- name: Push docker image if: github.actor != 'dependabot[bot]' run: | docker push $APPLICATION_IMAGE_BUILD docker logout#!/usr/bin/env ruby
tests = Dir["spec/**/*_spec.rb"]tests = tests. sort. # Add randomization seed based on SHA of each commit shuffle(random: Random.new(ENV["GITHUB_SHA"].to_i(16))). select. with_index do |el, i| i % ENV["CI_SHARD_TOTAL"].to_i == ENV["CI_SHARD_INDEX"].to_i end
exec "bundle exec rspec #{tests.join(" ")}".dockerignore
Section titled “.dockerignore”.git/tmp/log/node_modules/spec/.env*coverage/Dockerfile
Section titled “Dockerfile”# STAGE: runtime## Shares common runtime environment for all other stages. It should be used# to define common environment variables and system packages.ARG ruby_version="..."ARG operating_system="alpine..."
FROM ruby:${ruby_version}-${operating_system} AS runtime
ENV LANG="C.UTF-8" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_FROZEN="1" \ RAILS_LOG_TO_STDOUT="1" \ LD_PRELOAD="/usr/lib/libjemalloc.so.2"
# Runtime dependencies:RUN apk add --update --no-cache \ curl \ tzdata \ bash \ libffi \ yaml \ jemalloc
WORKDIR "/app"
# STAGE: runtime-bundler## Provides build-time environment to install Ruby gemsFROM runtime AS runtime-bundler
RUN apk add --update --no-cache \ curl-dev \ build-base \ libffi-dev \ yaml-dev
# STAGE: build-prod## Provides Ruby gems used on productionFROM runtime-bundler AS build-prod
COPY Gemfile* .ruby-version ./
# Pin bundler to the exact version in Gemfile.lock to avoid mismatches# RUN gem install bundler -v "$(grep -A 1 'BUNDLED WITH' Gemfile.lock | tail -1 | tr -d ' ')" && \# gem cleanup bundler
# Install Ruby gems used in production environment.RUN bundle config without development test && \ bundle config jobs $(nproc) && \ bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile -j 0
# STAGE: prod## Provides runtime for the application in production mode.FROM runtime AS prod
ARG USERNAME=...RUN adduser -D -H ${USERNAME} ${USERNAME}USER ${USERNAME}:${USERNAME}
ARG CODE_VERSIONENV CODE_VERSION=$CODE_VERSION
COPY --chown=${USERNAME}:${USERNAME} --from=build-prod ${BUNDLE_PATH} ${BUNDLE_PATH}COPY --chown=${USERNAME}:${USERNAME} . .
RUN bundle exec bootsnap precompile -j 0 app/ lib/
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \ CMD curl -f http://0.0.0.0/up || exit 1
ENTRYPOINT ["bundle", "exec"]CMD ["puma", "-C", "config/puma.rb"]# frozen_string_literal: true
source 'https://rubygems.org'ruby file: '.ruby-version'
gem 'bootsnap', require: falseOn the first run (e.g. in entrypoint or init container), you can dump the schema cache to avoid querying the database for schema on every boot:
bin/rails db:schema:cache:dumpThis generates db/schema_cache.yml, which Rails loads automatically, skipping schema introspection queries at startup. Make sure schema cache loading is enabled in your database config:
production: schema_cache_path: db/schema_cache.yml use_schema_cache_dump: trueSee also
Section titled “See also”- Dockerfile generator for Rails ↗
- Kamal ↗ - deploy web apps with Docker, from Basecamp
- Thruster ↗ - HTTP/2 proxy for Puma, handles SSL, caching, compression
Memory
Section titled “Memory”- jemalloc ↗ - reduces memory fragmentation and RSS bloat
- autotuner ↗ - auto-tunes GC settings based on runtime profiling
- mini_mime ↗ - lightweight MIME type lookup (~42 KB vs ~8.7 MB for
mime-types)
Boot time
Section titled “Boot time”- bootsnap ↗ - caches
requirepaths and compiled ISeq/YAML
Serialization / Parsing
Section titled “Serialization / Parsing”-
oj ↗ - fast JSON parser/serializer, drop-in replacement. Add
gem "oj"to Gemfile, then create an initializer:config/initializers/oj.rb Oj.optimize_railsThis monkey-patches
JSON.parse,JSON.generate,to_json, and Active Support’s JSON encoder to use Oj -
fast_blank ↗ - C extension for
String#blank? -
Unchecked:
- blueprinter ↗ - fast, declarative JSON serializer
- oj_serializers ↗ - low-allocation serializers built on
oj, drop-in migration from Active Model Serializers - jsonapi-serializer ↗ - JSON:API-compliant serializer, community fork of Netflix’s
fast_jsonapi
Database
Section titled “Database”- trilogy ↗ - MySQL client from GitHub, lighter than mysql2
- scenic ↗ - materialized views for expensive queries
- identity_cache ↗ - read-through cache for Active Record
Profiling / Monitoring
Section titled “Profiling / Monitoring”- vernier ↗ - next-generation sampling profiler with GVL, thread, and GC visibility
- stackprof ↗ - sampling call-stack profiler for CPU-bound code
- memory_profiler ↗ - detailed memory allocation reports
- derailed_benchmarks ↗ - measures memory at boot and per-request
- rack-mini-profiler ↗ - per-request profiling in development/staging
- bullet ↗ - detects N+1 queries and unused eager loading
Ruby runtime settings
Section titled “Ruby runtime settings”RUBY_YJIT_ENABLE=1(Ruby 3.2+) — enables YJIT JIT compiler. Note: Rails 7.2+ enables YJIT by default at boot if available, so this env var is unnecessary for recent Rails apps- GC tuning via
RUBY_GC_*env vars (or letautotunerhandle it) MALLOC_ARENA_MAX=2if not using jemalloc — limitsglibcmemory arenas
- It is possible to do warm-up via healthcheck. One can add DB warm-up as well, but there are downsides to a “heavy” healthcheck