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, Redis và Sidekiq 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.
- .:/app: mount application code trên host vào
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!
![](https://i0.wp.com/omatsuri.blog/wp-content/uploads/2022/08/Screenshot-from-2022-08-09-16-33-41.png?resize=640%2C351&ssl=1)
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 🙂