---
title: "Ruby on Rails 容器化最佳指南（二）"
date: 2022-01-28T00:00:00+08:00
publishDate: 2022-01-28T11:32:18+08:00
lastmod: 2022-01-28T11:35:18+08:00
tags: ["Docker","容器","Ruby","Rails","經驗","DevOps"]
toc: true
permalink: "https://blog.aotoki.me/posts/2022/01/28/the-best-practice-of-containerize-ruby-on-rails-part-2/"
language: "zh-tw"
---


在[Ruby on Rails 容器化最佳指南（一）](https://blog.aotoki.me/posts/2021/10/09/the-best-practice-of-containerize-ruby-on-rails-part-1/)我們已經大致說明了製作容器的目的、用途，這篇文章會跟大家介紹如何去撰寫 Production-ready（正式環境）可用的容器鏡像。

如果不想花太多時間在了解細節的技巧上，可以參考[如何在幾分鐘內容器化 Rails 專案](https://blog.aotoki.me/posts/2021/12/19/containerize-ruby-on-rails-in-a-few-minutes/)這篇文章，裡面有有針對容器化所製作的 Ruby Gem 可以快速解決這方面的問題。

<!--more-->

## 基礎鏡像{#base-image}

要製作一個正式環境可用的容器鏡像，我們需要先選擇正確的基礎鏡像來開始製作。以 Ruby 專案來說，基本上就是 `alpine` 類型的為首選。以 `ruby:3.0` 的鏡像來說，大小是 `890MB` 左右，然而使用 `ruby:3.0-alpine` 則會是 `80MB` 左右，差了十倍的大小。

作為基礎的 `ubuntu` 本身也只有 `80MB` 左右，然而 Ruby 為了能夠順利編譯，會採取以 `ubuntu` 為基礎的 `buildpakc-deps` 作為基礎，進而讓大小增加到 `800MB` 以上。這也是我們為什麼會需要先界定是「開發」還是「部署」所使用的鏡像，使用 `alpine` 版本對於部署非常方便，然而在準備環境時就需要有更多的處理。

## 多階段建置{#multi-stage-builds}

當我們採用了 `alpine` 為基礎鏡像後，我們還是會遇到需要「編譯」的套件，因此就必須額外安裝一些用於編譯的工具，此時「多階段建置（Multi-Stage Builds）」的機制就會變得非常有用。

編譯相關的套件、過程產生的檔案都是我們在正式環境不需要的，因此可以將鏡像的建置區分為「套件」跟「打包」兩個階段，前者會加入編譯的環境協助我們安裝套件，後者則是將「必須的檔案」複製出來，讓我們最後只將專案包含必要的檔案。

依照我們的規劃，預計會產生如下的 `Dockerfile` 結構

```dockerfile
ARG RUBY_VERSION=3.0
FROM ruby:${RUBY_VERSION}-alpine AS gem

# 安裝套件
RUN bundle install \
    # 清理
    && ... \
	&& rm -rf /usr/local/bundle/cache/*.gem

FROM ruby:${RUBY_VERSION}-alpine

# 複製套件
COPY --from=gem /src/vendor/bundle /src/vendor/bundle
```

## 靜態資源{#assets-precompile}

因為 Rails 預設是一種單體（Monolithic）的方式來設計，因此我們會把前端、後端的程式碼混合在一起使用，在打包容器的時候就會遇到需要考慮「靜態資源」的編譯問題。

在 Rails 的設計中，我們預設是以「預先編譯」的方式來處理，也因此在大部分的部署方式中，都會是安裝到伺服器後，在進行「預先編譯」的處理，同時這個方法也能夠正確的使用正式環境的設定來編譯，才不會造成問題。

基於這樣的理由，網路上大部分的教學都是在容器鏡像中包含 Node.js 的執行環境，並且把相關的前端套件安裝到鏡像中，並且在 Rails 專案啟動前先呼叫預先編譯來處理這個情況。然而，這是考慮到在開發者電腦「製作鏡像」的情境，如果採取這個處理方式我們會遇到前面提到的過大的鏡像以及啟動時間過長的問題。

我們大致上有兩個方式可以處理，第一種是利用多階段建置（Multi-Stage Builds）的方式，在建置的過程中先跑過一次靜態資源的預先編譯處理，最後再加入到生成的鏡像中。另一個則是利用 CI（持續整合，Continuous Integration）的方式，在合併程式碼的時候直接預先編譯好，之後在打包時只需要利用 Artifacts（生成物）來製作即可。

根據我的經驗，選擇使用 CI 的方式處理，可以有效的加快建置的時間並且減少鏡像的大小，是最好的選擇。

## 安全性{#security}

當我們順利將 Ruby on Rails 專案容器化之後，還需要針對容器的安全性進行加強。開發環境下使用 `root` 的權限運行非常方便，然而在正式環境話我們應該盡可能的「唯讀」整個程式碼，避免被有心人士利用，以及盡可能的限制存取權限。

基於這樣的原則，我們需要調整複製程式碼的規則將正確的權限套用上去。

```dockerfile
# 預編譯的套件
COPY --from=gem /src/vendor/bundle /src/vendor/bundle
# 複製原始碼
COPY . /src
# ...

# 新增使用者
RUN adduser -h /src -D -s /bin/nologin ruby ruby && \
    chown ruby:ruby /src && \
	# log 和 tmp 可寫入
    chown -R ruby:ruby /src/log && \
    chown -R ruby:ruby /src/tmp && \
	# 程式碼全部唯獨
    chmod -R +r /src

# ...

# 以 ruby 使用者執行所有程式
USER ruby
```

除了容器本身之外，這幾年也有非常多開源的掃描工具，像是 [Trivy](https://github.com/aquasecurity/trivy)就能夠協助我們檢查容器的套件、專案的套件等等資安相關問題，用來加強安全性。

## 進入點{#entrypoint}

大多數情況下配置好容器後，會直接使用 `CMD ["bundle", "exec", "rails", "server"]` 這樣的指令處理，然而未來要執行 Rake Task 或者其他動作就會非常不容易，此時進入點就是一個很好的輔助。

進入點可以幫助我們初始化容器，同時也限制了可以存取的範圍。除此之外，還可以解決[# 如何在幾分鐘內容器化 Rails 專案](https://blog.aotoki.me/posts/2021/12/19/containerize-ruby-on-rails-in-a-few-minutes/#openbox-gem)文章中提到的資料庫連線檢查問題。

一般來說，我會推薦進入點至少做像這樣的處理。

```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'timeout'
require 'bundler/setup'
require 'pg'

def ensure_connection
  puts 'Check database connection...'
  Timeout.timeout(30) do
    PG.connect(ENV['DATABASE_URL'])
  rescue StandardError
    sleep 1
    retry
  end
rescue Timeout::Error
  puts 'Unable connect database'
  exit 1
end

command = ARGV.shift

case command
when 'server', 'console'
  # 資料庫連線檢查
  ensure_connection
  # 指令白名單
  exec("bundle exec #{command}")
end
```

因為是設計給 Ruby 專案所使用的，可以直接使用 Ruby 來撰寫。這樣還有另一個好處是我們不需要安裝資料庫的客戶端，可以直接利用編譯好的 Ruby Gem 來當作替代，也能間接地在減少一些不必要的套件安裝。

## 排除檔案{#ignores}

最後，在建置的時候難免會有一些多餘的檔案被生成，我們可以善用 `.dockerignore` 這個跟 `.gitignore` 相似的機制，將我們想要排除的檔案加入其中。

舉例來說，專案目錄下的 `log`、`tmp` 等等檔案，實際上都不是我們真正需要被放到專案中的，可以將這些檔案都排除。另一方面，我們可能會有一些像是開發相關的服務設定，也可以一起加入到裡面。除了可以盡量減少檔案大小之外，也可以減少在被攻擊時洩漏的情報，進而提高安全性。

## 範例程式碼{#redeem-example}

因為篇幅的關係沒辦法完整的將範例程式碼放上來，之後的文章都會改用[訂閱網誌](https://mailchi.mp/86c29af72810/containerize-rails-best-practice-example)的方式來發送附件，收集到的信箱只會用來發送網誌的日常更新通知、發送文章附件以及一些通知訊息。

如果想要拿到完整的範例程式碼，請使用下方的連結填寫表單領取：
https://mailchi.mp/86c29af72810/containerize-rails-best-practice-example

