Build Ruby on Rails app với Docker Compose

Trong team có anh em đang học Docker, gọi tôi vào thảo luận cho vui. Những câu hỏi như Docker là gì? Tại sao phải xài Docker khi chúng ta có thể chạy app ở máy local?… Rất nhiều anh em Junior hứng thú với chú đề này nhưng chưa có ai thực sự làm nghiêm túc đến nơi đến chốn cả. Sẵn chủ đề đang nóng hổi nên tôi viết 1 bài ngắn hướng dẫn tạo Rails app đơn giản với Docker Compose để anh em vào đọc nếu lười làm.

Giới thiệu sơ sơ về Docker

Đúng là develop app dưới máy local cũng được, không ai nói không được cả nhưng mà làm việc với Docker sẽ giúp workflow đơn giản hơn chút, deploy production cũng trở nên dễ dàng hơn. Làm việc với Container sẽ có 1 vài lợi ích sau. Nhưng mà Container là gì? Nói nôm na dễ hiểu Docker đóng gói tất cả những thứ cần thiết để chạy app như mã code, thư viện, file system,… thành 1 Container, giống giống như cái máy tính vậy.

Quay trở lại lợi ích khi làm việc với Container:

  • Có thể deploy và scale trên mọi loại môi trường, có nghĩa là tôi có thể chọn ngôn ngữ và dependencies tôi muốn cho app của mình mà không cần phải lo lắng Không biết qua máy bạn tôi có chạy được không ta? Lên server production không biết có tạch không?
  • Giúp tôi dễ nghiên cứu khoa học và khi có member mới sẽ có ngay app để học mà không cần phải tốn công ngồi cài đặt, install này kia.
  • Dễ dàng package và share code cho nhau khi cần.

Trong bài này tôi sẽ hướng dẫn các bạn dựng 1 app Rails đơn giản với PostgreSQL, RedisSidekiq bằng Docker Compose.

Yêu cầu cần thiết:

  • Máy tính đang chạy Mac hoặc Ubuntu 18.04+
  • Cài Docker sẵn (Này sẽ làm 1 bài update link vào đây)
  • Cài sẵn Docker Compose

Bước 1: Tạo Rails project và cài các Dependencies cần thiết

Trong bài này tôi sử dụng repo của Degital Ocean cho nhanh. Các bạn có thể tự tao project cho mình tùy ý thích. Trong repo này đã có sẵn Redis và Sidekiq rồi và đang sử dụng Rails 5, hơi cũ nhưng đây không phải là mục đích chính nên các bạn hãy làm ơn bỏ qua.

Đầu tiên tôi clone project bằng lệnh sau, các bạn chỉ cần copy là chạy nha:

git clone https://github.com/do-community/rails-sidekiq.git rails-docker

cd vào folder rails-docker

cd rails-docker

Trong repo này default đang sử dụng SQLite3 nên muốn sử dụng PostgreSQL tôi phải sửa 1 chút trong Gemfile, thêm gem pg vào thay cho sqlite3.

vi Gemfile
. . . 
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false
gem 'sidekiq', '~>6.0.0'
gem 'pg', '~>1.1.3'

group :development, :test do
. . .

Nhớ comment out gem sqlite3 lại nha

. . . 
# Use sqlite3 as the database for Active Record
# gem 'sqlite3'
. . .

Và thằng spring-watcher-listen luôn, lý do bạn đọc ở đây, lên Rails 6 thì hết bị:

. . . 
gem 'spring'
# gem 'spring-watcher-listen', '~> 2.0.0'
. . .

Bước 2: Cấu hình cho PostgreSQL và Redis

Đầu tiên là config database trong config/database.yml

vi config/database.yml

Ban đầu toàn code default trông như vầy:

default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

Tôi thay đổi 1 chút để chạy được với postgresql:

default: &default
  adapter: postgresql
  encoding: unicode
  database: <%= ENV['DATABASE_NAME'] %>
  username: <%= ENV['DATABASE_USER'] %>
  password: <%= ENV['DATABASE_PASSWORD'] %>
  port: <%= ENV['DATABASE_PORT'] || '5432' %>
  host: <%= ENV['DATABASE_HOST'] %>
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000
. . .

Bây giờ tôi config môi trường development, tôi chỉ sử dụng môi trường này trong bài này thôi nên tôi xóa các môi trường còn lại đi cho gọn và đẹp:

. . . 
development:
  <<: *default
. . .

Xóa mấy cái này đi:

. . . 
test:
  <<: *default
  
production:
  <<: *default
. . . 

Sửa đổi file database.yml như vầy giúp tôi gán database info từ file .env, sau này nếu có thay đổi thông tin tôi sẽ thay đổi trong file .env và file này thì không được commit nha các bạn.

Bây giờ tôi thể thêm thông tin database vào file .env

DATABASE_NAME=rails_development
DATABASE_USER=johny
DATABASE_PASSWORD=******
DATABASE_HOST=database
REDIS_HOST=redis

Chúng ta chú ý tới config DATABASE_HOST, giá trị database refer tới PostgreSQL được tạo bằng Docker Compose.

Để tạo ra database user tên johny, tôi viết 1 file init.sql để mount vào database container khi nó chạy:

vi init.sql

Các bạn copy đoạn này để tạo user johny với quyền administrative:

CREATE USER johny;
ALTER USER johny WITH SUPERUSER;

Sau đó set permission cho file init.sql

chmod +x init.sql

Tiếp theo tôi setup Sidekiq. Tôi sẽ add code init vào folder config/initializers, khi app chạy, Rails sẽ vào đây lấy các config ra chạy trước.

Tôi tao file sidekiq.rb để chứa sidekiq config:

vi config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.redis = {
    host: ENV['REDIS_HOST'],
    port: ENV['REDIS_PORT'] || '6379'
  }
end

Sidekiq.configure_client do |config|
  config.redis = {
    host: ENV['REDIS_HOST'],
    port: ENV['REDIS_PORT'] || '6379'
  }
end

Bây giờ để mấy thông tin nhạy cảm như database, server này kia không bị commit lên git, tôi bỏ file .env vào .gitignore:

yarn-debug.log*
.yarn-integrity
.env

Tiếp theo tôi sẽ tạo file .dockerignore để báo cho Docker biết những gì không được copy vào container:

.DS_Store
.bin
.git
.gitignore
.bundleignore
.bundle
.byebug_history
.rspec
tmp
log
test
config/deploy
public/packs
public/packs-test
node_modules
yarn-error.log
coverage/

Tôi cũng add file .env vào đây luôn:

. . .
yarn-error.log
coverage/
.env

Gần xong rồi, giờ tôi tạo 1 vài dummy data để có cái chạy chơi:

vi db/seeds.rb
# Adding demo sharks
sharks = Shark.create([{ name: 'Great White', facts: 'Scary' }, { name: 'Megalodon', facts: 'Ancient' }, { name: 'Hammerhead', facts: 'Hammer-like' }, { name: 'Speartooth', facts: 'Endangered' }])
Post.create(body: 'These sharks are misunderstood', shark: sharks.first)

Tới đây tôi đã chuẩn bị xong simple Rails app với PostgreSQL, các giá trị trong file env đủ để start app rồi. Bây giờ tôi qua viết Dockerfile.

Bước 3: Viết Dockerfile và Entrypoint Script

Dockerfile, nói cho dễ hiểu, là nới chứa tất cả những thứ application container cần khi được tạo. Sử dụng Dockerfile cho phép tôi define container environment và tránh được việc khác dependencies version mỗi khi install ở các môi trường khác nhau.

Docker image được tạo bằng cách kế thừa từ những image đã được build trước đó. Đầu tiên tôi cần add base image vào trong Dockerfile, trong trường hợp này là Ruby alpine image:

FROM ruby:3.1.2-alpine

Alpine image có nguồn gốc từ Alpine Linux project, nó giúp image size của tôi nhẹ hơn. Alpine image cực kỳ minimal, chúng ta cần package gì thì phải tự thêm vào chứ nó không có đâu nhé.

Tiếp theo tôi define Bundler version, để tránh trường hợp conflict Bundler version giữa environment và trong application Gemfile.lock.

. . .
ENV BUNDLER_VERSION=2.0.2

Tiếp theo tôi thêm mấy packages cần để chạy app vào Dockerfile:

. . . 
RUN apk add --update --no-cache \
      binutils-gold \
      build-base \
      curl \
      file \
      g++ \
      gcc \
      git \
      less \
      libstdc++ \
      libffi-dev \
      libc-dev \ 
      linux-headers \
      libxml2-dev \
      libxslt-dev \
      libgcrypt-dev \
      make \
      netcat-openbsd \
      nodejs \
      openssl \
      pkgconfig \
      postgresql-dev \
      python \
      tzdata \
      yarn 

Tiếp theo tôi sẽ install bundler version 2.0.2

. . . 
RUN gem install bundler -v 2.0.2

Bây giờ tôi sẽ define working directory trong Container và copy Gemfile, Gemfile.lock và config build gem nokogiri và install project gem

. . .
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config build.nokogiri --use-system-libraries
RUN bundle check || bundle install

bundle check sẽ những gem nào chưa được install trước rồi mới bắt đầu install chúng.

Tiếp theo tôi làm tương tự mấy bước này cho Javascript packages và dependencies. Đầu tiên tôi copy package metadata, sau đó install denpendencies và cuối cùng copy chúng tao trong container image.

. . . 
COPY package.json yarn.lock ./
RUN yarn install --check-files
COPY . ./ 

ENTRYPOINT ["./entrypoints/docker-entrypoint.sh"]

Cuối cùng tôi có Dockerfile trông giống như vầy:

FROM ruby:3.1.2-alpine

ENV BUNDLER_VERSION=2.0.2

RUN apk add --update --no-cache \
      binutils-gold \
      build-base \
      curl \
      file \
      g++ \
      gcc \
      git \
      less \
      libstdc++ \
      libffi-dev \
      libc-dev \ 
      linux-headers \
      libxml2-dev \
      libxslt-dev \
      libgcrypt-dev \
      make \
      netcat-openbsd \
      nodejs \
      openssl \
      pkgconfig \
      postgresql-dev \
      python \
      tzdata \
      yarn 

RUN gem install bundler -v 2.0.2

WORKDIR /app

COPY Gemfile Gemfile.lock ./

RUN bundle config build.nokogiri --use-system-libraries

RUN bundle check || bundle install 

COPY package.json yarn.lock ./

RUN yarn install --check-files

COPY . ./ 

ENTRYPOINT ["./entrypoints/docker-entrypoint.sh"]

Tiếp theo tôi tạo thư mục tên entrypoints chứa các entrypoints script:

mkdir entrypoints
vi entrypoints/docker-entrypoint.sh

Copy đoạn code này vào:

#!/bin/sh

set -e

if [ -f tmp/pids/server.pid ]; then
  rm tmp/pids/server.pid
fi

bundle exec rails s -b 0.0.0.0

Các bạn chú ý mấy dòng sau đây:

  • set -e: nếu có bất kỳ problem sau đó nào trong đoạn script sẽ nghỉ chạy luôn
  • Tiếp theo tôi check coi server.pid đã có chưa, nếu có rồi thì tôi xóa nó đi để tránh conflict khi start application

Sau đó tôi set permission cho file này để server có quyền chạy:

chmod +x entrypoints/docker-entrypoint.sh

Tiếp theo tôi tạo file script để config Sidekiq:

vi entrypoints/sidekiq-entrypoint.sh
#!/bin/sh

set -e

if [ -f tmp/pids/server.pid ]; then
  rm tmp/pids/server.pid
fi
 
bundle exec sidekiq
chmod +x entrypoints/sidekiq-entrypoint.sh

Bước 4: Define service với Docker Compose

Với Docker Compose, tôi có thể chạy nhiều container cần thiết cho application của tôi, và define các container đó sẽ chạy như thế nào.

Tôi tạo 1 file tên docker-compose.yml

vi docker-compose.yml

Sau đó copy đoạn này vào, tôi sẽ giải thích sau:

version: '3.4'

services:
  app: 
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - database
      - redis
    ports: 
      - "3000:3000"
    volumes:
      - .:/app
      - gem_cache:/usr/local/bundle/gems
      - node_modules:/app/node_modules
    env_file: .env
    environment:
      RAILS_ENV: development

Các bạn chú ý tới mấy dòng quan trọng sau:

  • build: xác định configuration option. Nó sẽ được applied khi Compose build application image.
  • context: xác định build context – trong trường hợp này là project directory.
  • dockerfile: chỉ cho Compose biết coi Dockerfile đang ở đâu
  • depends_on: trước khi app chạy thì sẽ setup database và redis trước.
  • volumes:
    • .:/app: mount application code trên host vào /app trong container. Bất cứ khi nào tôi thay đổi code trên host sẽ lập tức sync qua container.
    • gem_cache: nếu tôi tạo lại container thì Docker sẽ biết không cần install gems nữa nếu Gemfile không thay đổi mà sẽ mount thẳng vào container mới.
    • node_modules: tương tự như ở trên.

Tiếp theo tôi define database service:

. . .
  database:
    image: postgres:12.1
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

Không giống như app service, database service sẽ pull postgresql trực tiếp từ Docker hub.

Tiếp theo nữa là redis và sidekiq:

. . .
  redis:
    image: redis:5.0.7

  sidekiq:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - app      
      - database
      - redis
    volumes:
      - .:/app
      - gem_cache:/usr/local/bundle/gems
      - node_modules:/app/node_modules
    env_file: .env
    environment:
      RAILS_ENV: development
    entrypoint: ./entrypoints/sidekiq-entrypoint.sh
volumes:
  gem_cache:
  db_data:
  node_modules:

Cuối cùng tôi được file docker-compose.yml như sau, các bạn có thể copy lại cho nhanh:

version: '3.4'

services:
  app: 
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:     
      - database
      - redis
    ports: 
      - "3000:3000"
    volumes:
      - .:/app
      - gem_cache:/usr/local/bundle/gems
      - node_modules:/app/node_modules
    env_file: .env
    environment:
      RAILS_ENV: development

  database:
    image: postgres:12.1
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  redis:
    image: redis:5.0.7

  sidekiq:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - app      
      - database
      - redis
    volumes:
      - .:/app
      - gem_cache:/usr/local/bundle/gems
      - node_modules:/app/node_modules
    env_file: .env
    environment:
      RAILS_ENV: development
    entrypoint: ./entrypoints/sidekiq-entrypoint.sh

volumes:
  gem_cache:
  db_data:
  node_modules:  

Bước 5: Chạy thử Application

Tôi sẽ chạy câu này để build container image, với flag -d container sẽ chạy dưới background:

docker-compose up -d

Tôi được output như sau:

Output
Creating rails-docker_database_1 ... done
Creating rails-docker_redis_1    ... done
Creating rails-docker_app_1      ... done
Creating rails-docker_sidekiq_1  ... done

Tôi cũng có thể coi log bằng câu này:

docker-compose logs 
Output
sidekiq_1   | 2019-12-19T15:05:26.365Z pid=6 tid=grk7r6xly INFO: Booting Sidekiq 6.0.3 with redis options {:host=>"redis", :port=>"6379", :id=>"Sidekiq-server-PID-6", :url=>nil}
sidekiq_1   | 2019-12-19T15:05:31.097Z pid=6 tid=grk7r6xly INFO: Running in ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux-musl]
sidekiq_1   | 2019-12-19T15:05:31.097Z pid=6 tid=grk7r6xly INFO: See LICENSE and the LGPL-3.0 for licensing details.
sidekiq_1   | 2019-12-19T15:05:31.097Z pid=6 tid=grk7r6xly INFO: Upgrade to Sidekiq Pro for more features and support: http://sidekiq.org
app_1       | => Booting Puma
app_1       | => Rails 5.2.3 application starting in development 
app_1       | => Run `rails server -h` for more startup options
app_1       | Puma starting in single mode...
app_1       | * Version 3.12.1 (ruby 2.5.1-p57), codename: Llamas in Pajamas
app_1       | * Min threads: 5, max threads: 5
app_1       | * Environment: development
app_1       | * Listening on tcp://0.0.0.0:3000
app_1       | Use Ctrl-C to stop
. . .
database_1  | PostgreSQL init process complete; ready for start up.
database_1  | 
database_1  | 2019-12-19 15:05:20.160 UTC [1] LOG:  starting PostgreSQL 12.1 (Debian 12.1-1.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit
database_1  | 2019-12-19 15:05:20.160 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
database_1  | 2019-12-19 15:05:20.160 UTC [1] LOG:  listening on IPv6 address "::", port 5432
database_1  | 2019-12-19 15:05:20.163 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
database_1  | 2019-12-19 15:05:20.182 UTC [63] LOG:  database system was shut down at 2019-12-19 15:05:20 UTC
database_1  | 2019-12-19 15:05:20.187 UTC [1] LOG:  database system is ready to accept connections
. . . 
redis_1     | 1:M 19 Dec 2019 15:05:18.822 * Ready to accept connections

Check status của container bằng ps:

docker-compose ps
Output
         Name                        Command                                  State           Ports         
-------------------------------------------------------------------------------------------------------------------------
rails-docker_app_1            ./entrypoints/docker-resta ...       Up             0.0.0.0:3000->3000/tcp
rails-docker_database_1   docker-entrypoint.sh postgres    Up              5432/tcp              
rails-docker_redis_1           docker-entrypoint.sh redis ...     Up              6379/tcp              
rails-docker_sidekiq_1       ./entrypoints/sidekiq-entr ...        Up                  

Sau đó tôi tạo và seed database, chạy migration này kia bằng câu docker-compose exec:

docker-compose exec app bundle exec rake db:setup db:migrate
Output
Created database 'rails_development'
Database 'rails_development' already exists
-- enable_extension("plpgsql")
   -> 0.0140s
-- create_table("endangereds", {:force=>:cascade})
   -> 0.0097s
-- create_table("posts", {:force=>:cascade})
   -> 0.0108s
-- create_table("sharks", {:force=>:cascade})
   -> 0.0050s
-- enable_extension("plpgsql")
   -> 0.0173s
-- create_table("endangereds", {:force=>:cascade})
   -> 0.0088s
-- create_table("posts", {:force=>:cascade})
   -> 0.0128s
-- create_table("sharks", {:force=>:cascade})
   -> 0.0072s

Sau khi các service đã chạy thành công, tôi truy cập vào localhost:3000 để coi thử web chạy không!? Ố Web chạy thiệc rồi!

Tôi đã thử và thành công, chúc các bạn may mắn và học hỏi được chút kiến thức cơ bản.

Happy hacking 🙂