Deploying with MRSK

Episode #391 by Teacher's Avatar David Kimura

Summary

Kamal (MRSK) deploys web apps anywhere from bare metal to cloud VMs using Docker with zero downtime. In this episode, we will set up a Digital Ocean infrastructure with a Load Balancer, Virtual Machines, and a PostgreSQL database. We'll use Kamal (MRSK) to provision and deploy our Rails application to the Virtual Machines.
rails deployment deploy 37:42

Chapters

  • Introduction (0:00)
  • Setting up infrastructure (3:41)
  • Creating a Rails 7.0.4.2 project (9:49)
  • Adding mrsk (12:13)
  • How mrsk works (13:15)
  • Updating load_defaults (13:43)
  • Adding Dockerfile for mrsk (14:24)
  • Updating the mrsk deploy.yml (16:15)
  • Updating database.yml (18:30)
  • Looking at mrsk cli (21:16)
  • Running mrsk server bootstrap (22:00)
  • Running mrsk deploy (22:24)
  • Allow Rails to serve public files (28:45)
  • Inspecting the logs (30:08)
  • Accessling via the VM's IP (31:40)
  • Accessing via the load balancer (32:27)
  • Final thoughts (33:45)

Resources

Kamal (MRSK) - https://github.com/mrsked/mrsk

This episode is sponsored by Honeybadger

(use the kamal command instead of mrsk)
Download Source Code

Summary

# Terminal
bundle update rails
bundle add mrsk
mrsk init
bundle lock --add-platform aarch64-linux
bundle lock --add-platform x86_64-linux
bin/rails credentials:edit
mrsk app help
mrsk server bootstrap
mrsk deploy
mrsk app logs
mrsk app logs --help
mrsk app logs -n 1000

# Gemfile
gem "rails", github: "rails/rails", branch: "main"
gem "mrsk", "~> 0.8.4"

# config/environments/production.rb

config.public_file_server.enabled = true

# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
config.assume_ssl = true

logger           = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger    = ActiveSupport::TaggedLogging.new(logger)

# config/application.rb
config.load_defaults 7.1

# .gitignore
.env

# .env
MRSK_REGISTRY_PASSWORD=change-this
RAILS_MASTER_KEY=another-env

# Dockerfile
# syntax = docker/dockerfile:1

# Make sure it matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.2.1
FROM ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"


# Throw-away build stage to reduce size of final image
FROM base as build

# Install packages need to build gems and node modules
RUN apt-get update -qq && \
    apt-get install -y build-essential curl default-libmysqlclient-dev git libpq-dev libvips node-gyp pkg-config python-is-python3

# Install JavaScript dependencies
ARG NODE_VERSION=19.7.0
ARG YARN_VERSION=1.22.19

ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
    /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
    npm install -g yarn@$YARN_VERSION && \
    rm -rf /tmp/node-build-master

# Install application gems
COPY --link Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile


# Install node modules
COPY --link package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Copy application code
COPY --link . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile


# Final stage for app image
FROM base

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y default-mysql-client libsqlite3-0 libvips postgresql-client && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Run and own the application files as a non-root user for security
RUN useradd rails
USER rails:rails

# Copy built artifacts: gems, application
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

# bin/docker-entrypoint
#!/bin/bash -e

# If running the rails server then create or migrate existing database
if [ "${*}" == "./bin/rails server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

# config/deploy.yml
# Name of your application. Used to uniquely configure containers.
service: drexample

# Name of the container image.
image: kobaltz/drexample

# Deploy to these servers.
servers:
  - 68.183.134.186
  - 68.183.134.85

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com / ghcr.io / ...
  username: kobaltz
  password:
    - MRSK_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
env:
  secret:
    - RAILS_MASTER_KEY

# Call a broadcast command on deploys.
# audit_broadcast_cmd:
#   bin/broadcast_to_bc

# Use a different ssh user than root
# ssh:
#   user: app

# Configure builder setup.
# builder:
#   args:
#     RUBY_VERSION: 3.2.0
#   secrets:
#     - GITHUB_TOKEN
#   remote:
#     arch: amd64
#     host: ssh://app@192.168.0.1

# Use accessory services (secrets come from .env).
# accessories:
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     port: 3306
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql.erb:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/mysql
#   redis:
#     image: redis:7.0
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

# Configure custom arguments for Traefik
# traefik:
#   args:
#     accesslog: true
#     accesslog.format: json

# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
#   path: /healthz
#   port: 4000

# config/database.yml
production:
  <<: *default
  url: <%= Rails.application.credentials.database_url %>

# credentials file
database_url: postgres://username:password@host:port/databasename