2018 年的 Rails 應用 Docker Image 包裝範例

前言

近年 Docker / Kubernetes 已經逐漸成為 DevOps 的主流,不管是開發任何網路服務應用都可能會有使用 Docker 做部署的需要,在此我以近期經手開發且有部署到 Kubernetes + Google Cloud Engine 的專案為例,示範並說明一般 Rails 專案的 Dockerfile 寫法。

專案環境說明

  • Rails 4.2
  • Ruby 2.3.5
  • Application Server: Passenger
  • Web Frontend: Nginx
  • MySQL / Redis / Memcached

完整檔案示例

# === Base Image ===
# 注意此處的 AS
FROM ruby:2.3.5 AS passenger_ruby

MAINTAINER ryudoawaru <ryudoawaru@gmail.com>

# Default Environment
# 在這邊設定 相關軟體的版本
ENV NGINX_VERSION 1.12.2
ENV PASSENGER_VERSION 5.1.12

# Requirement
RUN apt-get update && apt-get install -y libmagick++-dev libmariadb-client-lgpl-dev libpcap-dev libssl-dev

# 以指定的安裝 Passenger Gem
RUN gem install passenger -v $PASSENGER_VERSION
# 指定 Passenger 編譯 Nginx Extension
# 在這邊使用 passenger-config about root 取得 Passenger 的 root 目錄
RUN cd `passenger-config about root` && rake nginx
# 以前述變數指定版本 Checkout Nginx Source Code
RUN cd /tmp && wget https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz && tar -zxvf nginx-$NGINX_VERSION.tar.gz
# 編譯 Nginx 並加入 Passenger 與 upload module extension
RUN cd /tmp/nginx-$NGINX_VERSION && ./configure --with-http_v2_module --with-http_ssl_module --add-module="`passenger-config about root`/src/nginx_module" --with-http_gzip_static_module && make && make install

# === Rails Application ===
# 使用 multi-stage 方式
# From 剛才的 AS
FROM passenger_ruby

# 重要!
ENV LC_ALL C.UTF-8
# 指定時區,否則會用 GMT
ENV TZ Asia/Taipei
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 設定一個程式起始的目錄
ENV APP_HOME /usr/src/app
RUN mkdir -p $APP_HOME

ENV RAILS_ENV production

# 在這邊先加入 Gemfile 並 Bundle 
ADD Gemfile $APP_HOME/Gemfile
ADD Gemfile.lock $APP_HOME/Gemfile.lock
RUN cd $APP_HOME && bundle install --without development test --deployment

# 將現在(本地)所在專案目錄加入到 Docker Image 內
ADD . $APP_HOME

# 連結需要的設定檔
RUN cp $APP_HOME/config/sidekiq.yml.example $APP_HOME/config/sidekiq.yml
ADD vendor/configs/.env $APP_HOME/.env
ADD vendor/configs/database.yml.example $APP_HOME/config/database.yml

# 加入啟動用的 shell command
ADD vendor/entrypoint /usr/bin/entrypoint
# 設定 Docker 的工作目錄
WORKDIR $APP_HOME
# 設定 Server Port
EXPOSE 3000
# 告知 Docker 要用哪個指定啟動服務
ENTRYPOINT ["/usr/bin/entrypoint"]

entrypoint 內容

#!/usr/bin/env ruby
CMD = ARGV.first
case CMD
when 'web'
  exec 'passenger start -p 3000' \
    ' --nginx-bin /usr/local/nginx/sbin/nginx'
when 'sidekiq'
  exec 'bundle exec sidekiq --index 0 --pidfile /var/run/sidekiq.pid -v'
when 'rake'
  exec "bundle exec #{ARGV.join(' ')}"
else
  exec ARGV.join(' ')
end

重點說明

  • Docker 官方 Ruby Image 的語系問題 在上面有設定 ENV LC_ALL C.UTF-8 這段是因為官方版的 Ruby Image 預設是 ASCII,如果沒有更改的話,在 request data 有非 ASCII 字元時就會出現錯誤。
  • 使用 Multi-Stage 設定 我們之前在建立 Docker Image 時常常需要建立共用的基底 Image 方便管理和共用,但往往需要手動管理複數的 Dockerfile,用這個方式就只需要放在同一檔案即可。
  • 使用 Passenger Standalone 方式開啟 Server 個人經驗上偏好使用穩定的 Passenger + Nginx 做為 Application 與 Web Front-End Server 的選擇,Passenger 也提供了 command-line 指令可以直接用來啟動而不需要設定好 Nginx + Passenger 再起動 Nginx,啟動時需要指定 Nginx 的執行檔位置以避免 Passenger 重複下載與編譯 Nginx。
  • 先加入 Gemfile & bundle 由於 Docker 是用每一行指令為單位儲存 Image 檔的建構進度,而且會在任何上一步驟的內容變更後重新執行下面的所有步驟,如果在 ADD . $APP_HOME 之後才做 bundle install 的話,只要程式碼目錄有任何變動都會重來一次,為了避免這個情況故而將此段放在 ADD . $APP_HOME 的前面,這樣只要 Gemfile / Gemfile.lock 不變更,下次 build 時就不會再 bundle install
  • entrypoint 設計模式 由於需要使用同一 Docker Image 為範本來當作 Web Server / Worker(Sidekiq) / 執行 rake 指令的角色,所以設計成接收參數來決定執行模式。

在此要特別感謝同事的蒼時弦也協助完善這次的 Docker Image 設定。