---
title: "Rails 部署實踐 - 部署到 Docker Swarm"
date: 2022-07-08T00:00:00+08:00
publishDate: 2022-07-08T00:00:00Z
lastmod: 2023-09-03T17:41:34+08:00
tags: ["Rails","教學","部署","實作","Rails 部署實踐","Docker Swarm"]
series: "rails-deployment-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2022/07/08/rails-deployment-in-practice-deploy-to-docker-swarm/"
language: "zh-tw"
---


部署的環境已經準備好後，我們就可以來將之前所撰寫的 Docker Compose 設定檔轉換為 Docker Swarm 支援的格式，以及調整我們的架構來讓專案可以被部署到 Docker Swarm 上面。

<!--more-->

## 搭建入口{#setup-ingress}

想要讓使用者可以連上我們部署的網站就需要入口，最簡單的方式就是直接連上我們在 [Rails 部署實踐 - Docker Swarm 安裝與設定](https://blog.aotoki.me/posts/2022/07/01/rails-deployment-in-practice-setup-docker-swarm/)中所建立好的 Manager Node。

然而，我們在大多數的應用情境中，如果一次需要被一個專案佔用 HTTP（80）和 HTTPS（443）這兩個埠號（Port），就很難部署其他應用，或者將服務拆分出來應用，為了對應這樣的情境，在 Kubernetes 就設計了 Ingress（入口）的機制，我們可以選則直接佔用某個埠號，或者利用雲端服務的 Load Balancer（負載平衡）來以 Host（主幾名稱）的方式區分，將流量引導到對應的服務上。

使用 Docker Swarm 的時候我們可以善用在 [Rails 部署實踐 - 使用 HTTPS 協定加密連線](http://localhost:1313/posts/2022/03/25/rails-deployment-in-practice-enable-https/)我們所採用的 Traefik Proxy 來擔任這個角色，因此我們可以先部署一個 Stack（Docker Swarm 一組服務的單位）來設定 Traefik 負責處理流量的轉發。

首先，我們需要在任意一台 Manager Node 上建立一個「共用」的網路，讓其他服務可以被 Traefik 透過這個網路存取到。

```bash
$ docker network create --driver=overlay traefik-public
```

接下來撰寫 Traefik 的 Docker Compose 設定，將每一個 Manager Node 都部署一個 Traefik Proxy 來處理轉發。

```yaml
# traefik.yml
version: '3.8'

services:
  traefik:
    image: traefik:v2.6
    ports:
      - target: 80
        published: 80
        mode: host
      - target: 443
        published: 443
        mode: host
    deploy:
      mode: global # 在所有節點部署
      placement:
        constraints:
          - node.role == manager # 限定 Manager 節點
      labels:
        - traefik.enable=true
        - traefik.docker.network=traefik-public
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-public-certificates:/certificates
    environment:
      - CLOUDFLARE_DNS_API_TOKEN=[YOUR_TOKEN]
    command:
      - --providers.docker
      - --providers.docker.exposedbydefault=false
      - --providers.docker.swarmmode # 啟用 Swarm 模式
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.email=admin@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/certificates/acme.json
      - --certificatesresolvers.letsencrypt.acme.dnsChallenge.provider=cloudflare
      - --accesslog
      - --log
    networks:
      - traefik-public

volumes:
  traefik-public-certificates:

networks:
  traefik-public:
    external: true
```

新的 Traefik Stack 設定跟之前基本上是差不多的，然而有幾點不太一樣的地方需要注意。

* 增加 `deploy` 設定，限定在所有 Manager Node 部署，確保所有節點都能連上
* 改為使用 DNS Challenge 模式來取得 Let's Encrypt 的 SSL 憑證，這是因為 Traefik Enterprise 版本才有跨節點共用憑證的機制，如果使用 HTTP 驗證模式我們可能會遇到部分節點沒有部署到的狀況
* 網路設定增加了 `external: true` 表示這不需要由這個 Stack 建立，是已經存在的網路

完成設定後，我們可以複製檔案到任何一台 Manager Node 部署，或者在自己的電腦上利用設定 `DOCKER_HOST` 環境變數暫時切換到遠端的 Docker 節點。

```bash
$ export DOCKER_HOST=ssh://user@remotehost
$ docker stack deploy -c traefik.yml ingress
```

成功部署後，我們在每個 Manager Node 直接使用 IP 開啟，就會變成 `404 Not Found` 的訊息。

## 部署服務{#deploy-services}

解決了入口問題後，我們就可以開始著手將服務部署到 Docker Swarm 上。我們可以繼續修改之前用來部署的 Docker Compose 留下服務的部分，來撰寫用於部署的設定。

```yaml
# my-app.yml

services:
  # ...
  rails:
    image: "registry.example.com/myapp:${VERSION:-latest}"
    restart: unless-stopped
    environment:
      - DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres/$POSTGRES_DB
      - RAILS_MASTER_KEY
	deploy:
      placement:
        constraints:
          - node.role != manager
      update_config:
        parallelism: 2
        delay: 10s
        order: start-first
    labels:
    - "traefik.enable=true"
    # Hosts
    - "traefik.http.services.rails.loadbalancer.server.port=3000"
    - "traefik.http.services.rails.loadbalancer.sticky.cookie=true"
    - "traefik.http.routers.rails-http.rule=Host(`demo.aotoki.dev`)"
    - "traefik.http.routers.rails-http.entrypoints=web"
    - "traefik.http.routers.rails-http.middlewares=https-redirect"
    - "traefik.http.routers.rails-https.rule=Host(`demo.aotoki.dev`)"
    - "traefik.http.routers.rails-https.entrypoints=websecure"
    - "traefik.http.routers.rails-https.tls=true"
    - "traefik.http.routers.rails-https.tls.certresolver=letsencrypt"
    networks:
      - traefik-public

networks:
  traefik-public:
    external: true
```


在這邊我們同樣的加入了 `deploy` 相關的設定，雖然 Manager Node 也能夠運行服務，然而如果服務的負擔太大的會可能會造成卡住，資源足夠的狀況下會設定為不要部署在 Manager Node 上來確保入口是順暢的。

在 Docker Swarm 也很容易去設定 Rolling Upgrade（滾動更新）也就是將舊版的節點跟新版的節點同時並存，因此我們可以設定同時能存在 2 個服務實體，並且先將新版本啟動等待 10 秒後再關閉舊版，這樣就不會出現更新中服務有一瞬間中斷的狀況。

因為 Rails Assets Precompile 的機制，這個做法會有一個缺點是 CSS 和 JS 會因為使用者有幾秒是能同時連到不同服務實體的，造成無法讀取到正確的 CSS 和 JS 而出現跑版的狀況，因此我們額外加上了 `sticky` 的設定，讓 Traefik 幫我們保持同一個使用者會連接到同一個實體，來避免這個問題。

> 不要忘記使用 `docker login` 讓 Docker Swarm 可以順利下載到容器鏡像

## 持久化資料難題{#problem-of-persistent-data}

當我們從單一節點轉變為叢集（Cluster）的時候，必定會面臨儲存資料的問題。舉例來說，當我們的資料庫從 A 節點轉移到 B 節點的時候，我們該如何在 B 節點恢復資料庫呢？

在 Docker Swarm 最簡單的做法是使用 `constriants`（限制）的機制，將有需要持續保存資料的服務固定在單一一台伺服器上，這樣就能暫時性的解決這類問題，然而我們還是會遭遇單一伺服器故障造成所有服務停擺的狀況。

這也不難看出為什麼在 [12 Factor](https://12factor.net/) 的設計中，會將資料庫等等這類服務單獨拆分出來，這樣我們就可以考慮在部署服務時，以獨立的 Stack 方式搭建 PostgreSQL 或 MySQL 的叢集來確保可用性，並利用 Docker Swarm 的 Overlay 網路串聯起來。

或者我們可以利用像是 AWS RDS 這類雲端服務，減少我們自行搭建服務的困難，至於如何取捨就完全基於我們怎樣規劃，大多數時候能使用 RDS 這類服務維護成本會相對的降低更多。

---

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

