Skip to content

Latest commit

 

History

History
415 lines (330 loc) · 11.3 KB

README.md

File metadata and controls

415 lines (330 loc) · 11.3 KB

Sample Ruby App

These notes take you through the process of spinning up a new ruby app with docker-compose. They've been cobbled together from various sources (mostly a post on semaphoreci and the ruby dockerhub) to suit my needs and should represent a number of "good practices", if not necessarily "best practices". The objective is to provice a useful development environment and a reliable and easy to deploy production environment.

Bootstrap the app

We'll put everything inside a single repository, including the supporting docker and docker-compose infrastructure as well as the applicaiton code.

$ mkdir example-app && cd example-app
$ git init .
$ vi .gitignore
.env
*.swp

rails can bootstap a new application, but doing so assumes we have rails installed somewhere. We'll create a throwaway container to do that for us. Create Dockerfile.rails

# Dockerfile.rails
FROM ruby:3.1.2 AS rails-toolbox

# Default directory
ENV INSTALL_PATH /opt/app
RUN mkdir -p $INSTALL_PATH

# Install rails
RUN gem install rails bundler
#RUN chown -R user:user /opt/app
WORKDIR /opt/app

# Run a shell
CMD ["/bin/sh"]

Build the rails-toolbox image with docker build -t rails-toolbox Dockerfile.rails. Then use it to bootstrap the app

$ docker run -it -v $PWD:/opt/app rails-toolbox rails new --skip-bundle myapp

That should create myapp inside the example-app directory and populate it with a rails app. It will also create a repository inside the app directory, which we don't want, so we will remove that.

$ rm -rf myapp/.git

Commit everything we have up to this point.

Basic App Configuration

We'll be using postgresql, redis and sidekiq, so add the necessary gems to myapp/Gemfile.

$ vi myapp/Gemfile
  ...
 +gem 'unicorn', '~> 6.1.0'
 +gem 'pg', '~> 1.3.5'
 +gem 'sidekiq', '~> 6.4.2'
 +gem 'redis-rails', '~> 5.0.2'

Add a DRY database config to myapp/config/database.yml

development:
  url: <%= (ENV.has_key?('DATABASE_URL') ? ENV['DATABASE_URL'] : '').gsub('?', '_development?') %>

test:
  url: <%= (ENV.has_key?('DATABASE_URL') ? ENV['DATABASE_URL'] : '').gsub('?', '_test?') %>

staging:
  url: <%= (ENV.has_key?('DATABASE_URL') ? ENV['DATABASE_URL'] : '').gsub('?', '_staging?') %>

production:
  url: <%= (ENV.has_key?('DATABASE_URL') ? ENV['DATABASE_URL'] : '').gsub('?', '_production?') %# SQLite. Versions 3.8.0 and up are supported.

Create a myapp/config/secrets.yml

development: &default
  secret_key_base: <%= ENV['SECRET_TOKEN'] %>

test:
  <<: *default

staging:
  <<: *default

production:
  <<: *default

Add a basic application config to myapp/config/application.rba

  ...
module Workshops
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.1

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w(assets tasks))

    config.log_level = :debug
    config.log_tags  = [:subdomain, :uuid]
    config.logger    = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))

    config.cache_store = :redis_store, ENV['CACHE_URL'],
                         { namespace: 'myapp:cache' }

    config.active_job.queue_adapter = :sidekiq
  end
end

Create a unicorn config myapp/config/unicorn.rb

# Heavily inspired by GitLab:
# https://github.com/gitlabhq/gitlabhq/blob/master/config/unicorn.rb.example

worker_processes ENV['WORKER_PROCESSES'].to_i
listen ENV['LISTEN_ON']
timeout 30
preload_app true
GC.respond_to?(:copy_on_write_friendly=) && GC.copy_on_write_friendly = true

check_client_connection false

before_fork do |server, worker|
  defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.disconnect!

  old_pid = "#{server.config[:pid]}.oldbin"
  if old_pid != server.pid
    begin
      sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU
      Process.kill(sig, File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end
end

after_fork do |server, worker|
  defined?(ActiveRecord::Base) && ActiveRecord::Base.establish_connection
end

Create a sidekiq initializer myapp/config/initializers/sidekiq.rb

sidekiq_config = { url: ENV['JOB_WORKER_URL'] }

Sidekiq.configure_server do |config|
  config.redis = sidekiq_config
end

Sidekiq.configure_client do |config|
  config.redis = sidekiq_config
end

Make sure we allow connections from ourselves in myapp/config/environments/development.rb

  $ vi myapp/config/environments/development.rb
  ...
 +  config.hosts << myapp
  end

Create an example environment variable file dot-env.example

# Replace this value with the output of `rake secret`
# Production value MUST be protected.
SECRET_TOKEN=ASamplePasswordGoesHere

WORKER_PROCESSES=1
LISTEN_ON=0.0.0.0:3000
DATABASE_URL=postgresql://dbuser:dbpass@postgres:5432/myapp?encoding=utf8&pool=5&timeout=5000
CACHE_URL=redis://redis:6379/0
JOB_WORKER_URL=redis://redis:6379/0

Edit the myapp/Dockerfile. The main things of note here are that we're relying on build stages to separate out some of the garbage

# Dockerfile development version
ARG RUBY_VERSION=3.1.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION AS base

WORKDIR /rails

FROM base as build


# Install gem and yarn build dependencies
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg -o /root/yarn-pubkey.gpg && apt-key add /root/yarn-pubkey.gpg
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install -y --no-install-recommends libvips pkg-config nodejs yarn

# Install gems
COPY Gemfile Gemfile.lock ./

RUN bundle install && \
   rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .
RUN yarn install

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

FROM base

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

COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

CMD ["bundle", "exec", "unicorn", "-c", "config/unicorn.rb"]

Create a .dockerignore to avoid things we don't want cluttering the build.

.git
.dockerignore
.env
workshops/node_modules/
workshops/vendor/bundle/
workshops/tmp/

Create a simple reverse-proxy config for nginx, the host in hte proxypass stanza should match whatevern you use for the service name in compose.yml

# reverse-proxy.conf

server {
    listen 8080;
    server_name example.org;

    location / {
        proxy_pass http://app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Create a compose file to bring these pieces together. nginx and redis can run from images, and our app and sidekiq will run from the same image. At the top level of the directory structure, create compose.yml

services:

  postgres:
    image: postgres:14.2
    environment:
      POSTGRES_USER: wsuser
      POSTGRES_PASSWORD: wspass
    ports: 
      - '5432:5432'
    volumes:
      - myapp-postgres:/var/lib/postgresql/data

  redis:
    image: redis:7.0
    ports:
      - '6379:6379'
    volumes:
      - myapp-redis:/var/lib/redis/data

  app:
    build:
      context: myapp
    # For development, volume mount code
    volumes:
      - ./myapp:/rails
    links:
      - postgres
      - redis
    ports:
      - '3000:3000'
    env_file:
      - .env

  sidekiq:
    build:
      context: myapp
    command: bundle exec sidekiq 
    links:
      - postgres
      - redis
    env_file:
      - .env

  nginx:
    image: nginx:1.25
    volumes:
      - ./reverse-proxy.conf:/etc/nginx/conf.d/reverse-proxy.conf:ro
    links:
      - app
    ports:
      - '8080:8080'

volumes:
  myapp-postgres:
  myapp-redis:

Copy the sample environment to .env, ready for docker-compose. Without having rake available yet, we will need to generate aour own random value

$ NEW_SECRET=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 64; echo)
$ sed "s/SECRET_TOKEN=.*/SECRET_TOKEN=${NEW_SECRET}/g" dot-env.example > .env

Our app currently doesn't have a Gemfile.lock which is required for our build. We can generate one as so

$ docker run --rm -v "$PWD/myapp":/usr/src/app -w /usr/src/app ruby:3.1.2 bundle install

Now we should be ready to build the image

$ docker compose build

For a new app, we need to initialze the database

$ docker compose run app rake db:reset
$ docker compose run app rake db:migrate

That's it for now, commit

$ git add -A
$ git commit -m "Second stage complete app initialized"

Assuming you have some node components in the front end, we'll create a stub package.json and yarn.lock, by connecting to a development environment (which has our app volume mounted) and adding jquery.

$ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg -o /root/yarn-pubkey.gpg && apt-key add /root/yarn-pubkey.gpg
$ echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
$ apt-get update && apt-get install -y --no-install-recommends libvips pkg-config nodejs yarn

$ docker compose run --rm app bash
#> yarn init
question name (rails): myapp
...

#> yarn add jquery

Quitting the container, this should have created a package.json and a yarn.lock at the top level of myapp. Add those to your repository and commit them. We can now update the Dockerfile for the app.

# Dockerfile development version
ARG RUBY_VERSION=3.1.2
FROM registry.docker.com/library/ruby:$RUBY_VERSION AS base

WORKDIR /rails

FROM base as build

# Install gem and yarn build dependencies
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg -o /root/yarn-pubkey.gpg && apt-key add /root/yarn-pubkey.gpg
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install -y --no-install-recommends libvips pkg-config nodejs yarn

# Install gems
COPY Gemfile Gemfile.lock ./

RUN bundle install --jobs=3 --retry=3 && \
   rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile app/ lib/

# Copy application code
COPY . .

# Install npm packages
COPY package.json yarn.lock ./

RUN yarn install --frozen-lockfile

# Final target image
FROM base

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

COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

CMD ["bundle", "exec", "unicorn", "-c", "config/unicorn.rb"]

TODO

  • Formalize production and development image differences
  • Slim the image
  • Replace unicorn with puma
  • Tidy up environment variables - particularly the DB stuff