---
title: "Rails 部署實踐 - 滾動更新"
date: 2022-04-08T00:00:00+08:00
publishDate: 2022-04-08T00:00:00+08:00
lastmod: 2023-09-03T17:41:34+08:00
tags: ["Rails","教學","部署","實作","Rails 部署實踐","Traefik","Docker","滾動更新"]
series: "rails-deployment-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2022/04/08/rails-deployment-in-practice-rolling-upgrade/"
language: "zh-tw"
---


完成網站部署後，我們還需要持續的更新網站。然而如果只有一個節點的話，網站是會有一瞬間服務暫停的狀況，為了避免這樣的問題可以採取滾動更新（Rolling Upgrade）的方式處理。

過去我們需要仰賴人工切換，然而到了虛擬機、容器的階段就能夠透過預先安裝好環境來做到半自動、自動的處理。

<!--more-->

## 滾動更新的原理{#concept-of-rolling-upgrade}

滾動更新簡單來說就是我們「依序」的將舊的伺服器替換掉的過程，所以在伺服器的前端通常會有負載平衡（Load Balancer）來提供切換的動作，步驟大致上如下。

1. 運行新的服務（Service B）
2. 將新的服務登記到負載平衡上（Service A 和 Service B 都可以被連上）
3. 將舊的服務移除（Service A 無法連上）

這個做法就能夠讓使用者不會注意到我們正在更新服務，因為體驗上是不會有「中斷」的狀況，然而還是要注意以下幾種狀況。

1. 資料庫的更新，像是 Migration（遷移）是否會影響服務
2. 應用程式的更新，像是程式碼的變動造成行為不一致

這些都會需要應用程式的實作上進行對應的處理，像是使用 Feature Flag（功能旗標）來切換新版的功能，確保滾動更新的過程中行為一致等等。

## Docker Compose 的滾動更新{#use-docker-compose-rolling-upgrade}

要使用 Docker Compose 來實現滾動更新，可以參考 Apple Boy 大大的[教學文章](https://blog.wu-boy.com/2020/02/graceful-shutdown-using-docker-compose-with-rolling-update/)然而這篇文章的適用範圍比較偏向 Golang 所撰寫的服務，如果是針對 Ruby on Rails 的話，我們還是需要一些處理才能夠順利將「中斷」的情況避免掉。

因為我們已經透過 Traefik 來實現了負載平衡以及服務發現（Service Discovery）的機制，因此可以很輕鬆地利用 `docker-compose up -d --scale rails=2 --no-recreate` 這樣的方式直接在原本的伺服器上將 Rails 的容器從一個增加到兩個。

當我們利用 `--scale` 產生出新的版本後，如果接著運行 `docker-compose up -d --scale rails=1` 的指令，會發現我們的 Rails 可能還沒準備好，反而會讓服務中斷一段時間。

比較標準的做法是使用 `docker ps` 指令搭配 `-f` (Filter，篩選) 功能，先確認我們的 Rails 已經是健康（Healthy）的狀態後再繼續，這時候就會需要在我們的 Dockerfile 裡面定義 `HEALTHCHECK` 命令，而簡單的方法就是單純等待一定秒數即可。

大多數時候，我們都只需要簡單的版本，然而每次操作都需要不少指令。我們可以將指令撰寫成 Makefile 來自動處理，不使用 Rake 的原因是大部分 Linux 伺服器上都會有 make 指令，而 Rake 需要先安裝 Ruby 在這個情境下比較適合使用 Make 來處理。

我們在 `docker-compose.yml` 相同的目錄加入 `Makefile` 並且寫下以下內容

```ruby
.PHONY: stats

stats:
        @docker-compose ps

update: refresh scale-up rollout scale-down

refresh:
        @docker-compose pull

rollout:
        @echo "Stop old version"
        @docker stop -t 30 \
          $$(docker ps -a --format "table {{.ID}} {{.Names}} {{.CreatedAt}}" | \
          grep -E 'rails' | \
          awk -F  " " '{print $$1 " " $$3 "T" $$4}' | \
          sort -k2 | \
          awk -F  " " '{print $$1}' | head -1) # remove 1 oldest container
        @docker container prune -f

scale-up:
        @echo "Scaling up"
        @docker-compose up -d --scale rails=2 --no-recreate
        @sleep 30

scale-down:
        @echo "Scaling down"
        @docker-compose up -d --scale rails=1 --no-recreate
```

有了這個腳本後，我們就可以用 `make update` 這個命令自動的進行滾動更新，而不需要去記複雜的指令。

## 優雅關閉{#graceful-shutdown}

我們在 Ruby on Rails 部署經常使用的 Puma 以及 Sidekiq 這類工具，已經實踐好了優雅關閉（Graceful Shutdown）的機制，因此我們不需要特別處理。然而如果在專案中有自己撰寫的服務，就需要考慮到這個情況。

在 Linux 中，嘗試「停止」一個進程（Process）的方式有好幾中，一般來說會發送一個訊號（Signal）來處理，通常會用 `SIGINT`（Interupt，中斷）來通知，因此我們需要在我們撰寫的程式中「捕捉」這個訊號，並且進行停止前的處理，再停止執行。

> 關於信號機制，可參考[我在 Ruby 埋了一個陷阱 - Signal 的應用](https://blog.aotoki.me/posts/2019/03/12/I-make-a-trap-in-the-Ruby-the-usage-of-Signal/)這篇文章。

以 Sidekiq 為例子，我們使用 Sidekiq 是為了執行一些「處理時間比較長」的任務，因此很高的機率我們是正在執行一些任務的，如果直接的中斷 Sidekiq 的話，就可能會造成處理到一半而資料不正確。

也因此，當 Sidekiq 收到中斷的訊號時，會先停止處理「待處理」的任務並且把「處理中」的任務全部執行完畢，最後再停止自己的運行，這也是為什麼有時候我們使用 `Ctrl + C` 嘗試中斷一些程式時，會需要等待一段時間的關係，很有可能是在將任務收尾或者把資料保存到硬碟等等「確保運作正確」的手段。

---

如果想在第一時間收到更新，歡迎[訂閱弦而時習之](https://mailchi.mp/aotoki/rails-deployment-in-practice)在這系列文章更新時收到通知，如果有希望了解的知識，可以利用[Rails 部署實踐回饋表單](https://us4.list-manage.com/survey?u=dd3d68032c0510041f1302539&id=f25e0dc43e&attribution=false)告訴我。

