弦而時習之

Rails 部署實踐 - 容器進入點

進入點是一個很容易被忽略的處理,在大部分的容器處理中我們只是將應用(Application)封裝成一個容器鏡像,並且使用命令(Command)來告知容器啟動時該呼叫怎樣的命令運行應用。

然而,這樣的機制存在一些問題,因此我們需要實現進入點(Entrypoint)來做一些處理。

初始化與啟動

我們以 MySQL 或者 PostgreSQL 這兩套資料庫的容器為例子,如果是非容器化的安裝,我們會需要呼叫像是 initdb(PostgreSQL 初始化資料庫命令)這類命令來建立初始的資料庫。

到了容器中為什麼我們可以直接使用呢?這是因為「進入點」幫我們處理了這件事情,所以不論是 MySQL 還是 PostgreSQL 都可以找到 docker-entrypoint.sh 這個 Shell Script 在官方的原始碼中,並且使用 ENTRYPOINT 命令指定容器是以這個腳本作為入口。

簡單來說,當我們想要處理關於初始化(Initialize)或者啟動(Bootstrap)的情境時,就可以利用進入點來處理。

進入點與命令

在 Dockerfile 的撰寫中,我們可以用 ENTRYPOINT(進入點)以及 CMD 命令兩個選項,如果沒有特別設定,實際上是看起來差不多的。然而,當兩者一起使用的時候,容器會幫我們把他們串接起來。

1
2
ENTRYPOINT ["/usr/bin/zsh"]
CMD ["ls", "-al"]

實際運行的時候,我們會得到 /usr/bin/zsh ls -al 這樣的結果,從這個角度來看進入點用來設定使用這個容器的「可執行檔」而命令則用來設定運行容器時要執行的「動作」

從這個概念來看,預設我們會使用 Shell(跟 Linux 互動的軟體)來作為進入點是類似的,這也表示如果我們沒有設計進入點,就有點類似「請把我當作一個虛擬機使用」的感覺,跟我們平常使用 SSH 連線到遠端的伺服器概念相同。

然而,我們設計好應用後,我們預期的容器使用並不應該是這樣的情境。以 Rails 專案作為例子,最直接的方式就是將 rails 命令設定為進入點,我們所有的命令都是對 rails 這個應用來下達的,如果要啟動伺服器則會給予 server 的命令,更新資料庫結構則是 db:migrate 命令,並且以此類推。

使用 Ruby 設計進入點

既然我們是 Ruby 的專案,理所當然地使用 Ruby 來製作進入點也是非常合理的,在有 Gemfile 的目錄下,也能夠順利偵測到所需的 Ruby Gem 來引入。以 Rails 專案來說,我們需要確認的是與資料庫的連線是否正確來判斷是否可以「正確啟動」

加入 bin/entrypoint 檔案,並且確認使用 chmod +x bin/entrypoint 來賦予可執行權限。

1
2
3
4
5
6
7
8
9
#!/usr/bin/env ruby

# frozen_string_literal: true

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

# ...

我們會使用到 Ruby 標準函式庫中的 Timeout 來強制中斷等待太久的連線,除此之外為了要能夠使用到 Gemfile 所安裝的 Ruby Gem 因此還需要將 bundler/setup 引入,完成之後就能夠用 require 'pg' 來將連接資料庫必要的套件載入。

跟使用 Ruby on Rails 的情況不同,我們盡可能地減少載入相依套件以及處理,這樣才不會讓原本可以快速啟動的容器因為進入點啟動過久而拖慢啟動。

接下來我們要來處理「確認資料庫可以連線」的機制,在 Docker Compose 的設計中 depends_on 只能確保相依的容器「建立」卻無法確認是否可以使用,同樣的如果沒有設定 HEALTHCHECK 的檢查,我們也無法確認容器是否可以被連接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ...

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

在 Rails 5 之後都可以使用 DATABASE_URL 來取代 config/database.yml 的連線設定,對容器化的專案來說也是相對合理的,因此我們可以使用 PG.connect(ENV['DATABASE_URL']) 的方式來確認。

pg gem 的設計中,如果處於連線中就會是 Blocking(I/O 阻塞)的狀態,因此我們可以藉由捕捉 StandardError 的方式來對應連線失敗或者任何類型的意外狀況,當發生預期外的狀況時則持續重試直到 Timeout::Error 的觸發,也就是 Timeout 所設定的超時時間達到。

像這樣處理,我們就能確保容器必定在「資料庫已連線」的狀況下啟動 Rails 伺服器,而不會因為無法連上資料庫而無法啟動或者「不斷重啟」

接著加上啟動伺服器的實作。

1
2
3
4
# ...

ensure_connection
exec('bundle exec rails server')

特別需要注意的是,我們必須使用 exec 來呼叫命令,不能使用 system,這是因為 exec 會取代目前的執行緒(Process)進而成為容器中唯一的執行緒,這樣 Docker 才能感知到是否正在運行,如果使用 system 則有可能出現 Rails Server 死亡但是因為進入點還是持續運行的狀況,而出現類似殭屍程序(Zombie Process)的狀況。

最後我們只需要將進入點設定為這個檔案即可,如果有需要比較複雜的變化,可以自己透過 ARGV 來判斷不同的命令呼叫不同的行為。

1
2
3
# ...
ENTRYPOINT ["bin/entrypoint"]
CMD ["server"]

使用 Openbox

因為 Rails 專案的進入點處理基本上是類似的,所以我將他作為一個非常輕量的 Ruby Gem - Openbox 只需要安裝後呼叫 bundle binstub openbox 就會自動由 Bundler 幫我們生成一個 bin/openbox 的可執行腳本,最後將進入點設為這個腳本就能夠使用。

如果在使用上遇到任何問題都可以在 GitHub 上提出,讓我們一起將他改善為更容易使用的工具。


如果想在第一時間收到更新,歡迎訂閱弦而時習之在這系列文章更新時收到通知,如果有希望了解的知識,可以利用Rails 部署實踐回饋表單告訴我。

電子報

留言