iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0
Odoo

Odoo 部署策略系列 第 28

certbot container:Let's Encrypt 憑證申請和自動更新

  • 分享至 

  • xImage
  •  

這一章花了我不少時間,主要是因為我們的 reverse-proxy 無法自動切換憑證,讓我頗為煩惱:申請憑證需要 reverse-proxy 已經運行,但如果 nginx 無法找到我們指定的憑證檔案,服務就無法啟動,也還要兼顧本地測試的情況。原本我嘗試去找了一些別人的解決方案,例如 LinuxServer.io 的 SWAG,但發現它原生不支援多個網域,也沒有自動切換自簽和正式憑證的機制。繞了一圈,最後乾脆自己寫了個腳本,解法還算優雅,我蠻喜歡的。


Let's Encrypt 的驗證方式

Let's Encrypt 提供自動化的憑證簽發服務,主要透過 ACME 協議進行驗證。其中常用的驗證方式是 HTTP-01 挑戰。這種方式要求在網域下的特定路徑(如 /.well-known/acme-challenge/)放置一個驗證檔案,Let's Encrypt 會嘗試透過 HTTP 存取該檔案,以確認我們對該網域的控制權。


架設 Certbot 進行憑證申請和自動更新

首先,我們來確定能夠成功取得憑證。

新增 Certbot 服務

我們在 docker-compose.yml 中加入 certbot 服務,這個服務是使用官方的 certbot/certbot 映像,用於申請和自動更新 Let's Encrypt 的憑證。

certbot:
  image: certbot/certbot
  restart: unless-stopped
  volumes:
    - ./config/certs/letsencrypt:/etc/letsencrypt
    - certbot-challenge:/var/www/certbot
  entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h; done" # 一個無限循環的腳本,定期每 12 小時執行一次 `certbot renew`,使用 `webroot` 方法更新憑證。

設定跟 reverse-proxy 共享檔案

我們需要讓 reverse-proxycertbot 共用一些檔案,主要是憑證存放的位置和挑戰檔案的位置,這樣 certbot 生成的憑證和挑戰檔案才能被 reverse-proxy 存取。

reverse-proxy 的設定中,我們加入以下的卷掛載:

# SSL/TLS 憑證,包括 Let's Encrypt 和自簽憑證
- ./config/certs/letsencrypt:/etc/nginx/certs/letsencrypt:ro
- ./config/certs/self-signed:/etc/nginx/certs/self-signed:ro
# Certbot 挑戰檔案的暫存目錄
- certbot-challenge:/var/www/certbot

這樣一來,reverse-proxy 就能夠讀取到 certbot 生成的憑證,以及挑戰檔案。

設定 Nginx 處理挑戰請求

為了讓 Let's Encrypt 能夠驗證我們的網域,我們需要在 Nginx 中設定一個路徑,讓它能夠存取到 certbot 生成的挑戰檔案。讓 Let's Encrypt 在 HTTP-01 挑戰中,嘗試連線到我們的伺服器時,訪問特定的 URL 可以驗證我們對網域的控制權。

nginx.conf 中,我們在 唯一 的 port 80 的 default_server 中,加入以下設定:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    # 處理 Let's Encrypt 的驗證請求
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # 將其他 HTTP 請求重定向到 HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

因為我的設計中,只有一個 80 port 的設定,其他的都是 https 443 port。如果有多個 80 port 的 server 設計,而且該 server 需要 Let's Encrypt 驗證,就需要在該 server 區塊中加入相同的路徑設定。

測試驗證是否成功

最後,測試一下我們是否能成功取得憑證。

使用以下指令:

docker compose run --entrypoint "" certbot certbot certonly --webroot -w /var/www/certbot \
  -d portal.example.com \
  --email your-email@example.com --agree-tos --non-interactive

這裡最後可以加上 --dry-run 來進行測試:--dry-run 參數會模擬憑證申請流程,不會實際發出請求,方便我們確認設定是否正確。

在這個指令中,我們臨時覆蓋了 docker-compose.yml 中預設的 entrypoint。因為原本的 entrypoint 會進入一個背景更新的循環,會導致命令卡住,我們需要確保這個指令只執行憑證請求,避免這種情況發生。

此外,如果執行此指令後出現孤立的容器,可以使用 docker compose up --remove-orphans 來清理。

驗證成功的輸出

如果 --dry-run 成功,會出現:

Simulating renewal of an existing certificate for portal.example.com
The dry run was successful.

如果正式驗證成功,會出現:

Requesting a certificate for portal.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/portal.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/portal.example.com/privkey.pem
This certificate expires on 2025-02-01.
These files will be updated when the certificate renews.
...

取得的憑證和私鑰會存放在 /etc/letsencrypt/live/portal.example.com/ 目錄下。

讓 Reverse Proxy 使用取得的憑證

現在我們已經拿到了憑證,但要如何讓 reverse-proxy 使用這些憑證呢?我摸索了一陣子,最後決定直接寫個腳本來處理。

建立自訂的 Docker 映像

首先,將原本直接使用的 nginx:1.27-alpine 改成使用自訂的 Dockerfile。

# 原本的設定
image: nginx:1.27-alpine

# 修改後
build: ./reverse-proxy

撰寫 Dockerfile

reverse-proxy 目錄下,建立一個 Dockerfile

# 使用官方的 Nginx 映像作為基礎
FROM nginx:1.27-alpine

# 複製自訂的 entrypoint 腳本
COPY custom-entrypoint.sh /custom-entrypoint.sh
RUN chmod +x /custom-entrypoint.sh

# 複製 Nginx 設定檔案
COPY nginx.conf /etc/nginx/nginx.conf
COPY conf.d /etc/nginx/conf.d
COPY html /usr/share/nginx/html

# 開放埠號
EXPOSE 80 443

# 使用我們自訂的 custom-entrypoint.sh 作為入口點
ENTRYPOINT ["/custom-entrypoint.sh"]

# 保留 Nginx 官方映像原本的 CMD,以確保 Nginx 能夠正常啟動
CMD ["nginx", "-g", "daemon off;"]

調整 Nginx 設定

為了讓結構更為整潔,我將原本每個 server 區塊中的 SSL 設定:

ssl_certificate /etc/nginx/certs/portal.example.com.crt;
ssl_certificate_key /etc/nginx/certs/portal.example.com.key;

改成:

include /etc/nginx/conf.d/ssl/external_portal.example.com.include;

.include 檔案用來放 SSL 相關的設定,並在其中使用變數作為 placeholder,例如 ${SSL_CERTIFICATE}

.include 檔案內容如下:

# SSL 憑證路徑的 placeholder,將由 custom-entrypoint.sh 動態替換
ssl_certificate ${SSL_CERTIFICATE};
ssl_certificate_key ${SSL_CERTIFICATE_KEY};

編寫自訂的 Entrypoint 腳本

建立 custom-entrypoint.sh,內容如下:

#!/bin/sh
#
# custom-entrypoint.sh
# © 2024 Andrew Shen <your-email@example.com>
#
# Distributed under the same license as Webapp-Deployment
#

set -e

# 定義可能需要切換到 Let's Encrypt 憑證的網域列表(也可以透過環境變數設定)
DOMAINS=${DOMAINS:-"portal.example.com www.example.com www.example.com.tw"}

# 遍歷每個網域,處理對應的 SSL 設定
for DOMAIN in $DOMAINS; do
  # 定義當前網域的 SSL 設定檔案
  SSL_CONF_FILE="/etc/nginx/conf.d/ssl/external_$DOMAIN.include"

  # 檢查 SSL 設定檔案是否存在
  if [ ! -f "$SSL_CONF_FILE" ]; then
    echo "[custom-entrypoint.sh ERROR] SSL configuration file $SSL_CONF_FILE not found. Exiting."
    exit 1
  fi

  # 定義 Let's Encrypt 和自簽憑證的路徑
  LETSENCRYPT_CERT="/etc/nginx/certs/letsencrypt/live/$DOMAIN/fullchain.pem"
  LETSENCRYPT_KEY="/etc/nginx/certs/letsencrypt/live/$DOMAIN/privkey.pem"
  SELF_SIGNED_CERT="/etc/nginx/certs/self-signed/$DOMAIN.crt"
  SELF_SIGNED_KEY="/etc/nginx/certs/self-signed/$DOMAIN.key"

  if [ -f "$LETSENCRYPT_CERT" ] && [ -f "$LETSENCRYPT_KEY" ]; then
    # 如果存在 Let's Encrypt 憑證,使用 LETSENCRYPT_CERT/KEY
    SSL_CERTIFICATE="$LETSENCRYPT_CERT"
    SSL_CERTIFICATE_KEY="$LETSENCRYPT_KEY"
    echo "[custom-entrypoint.sh INFO] Using Let's Encrypt certificate for $DOMAIN"
  elif [ -f "$SELF_SIGNED_CERT" ] && [ -f "$SELF_SIGNED_KEY" ]; then
    # 否則使用自簽憑證 SELF_SIGNED_CERT/KEY
    SSL_CERTIFICATE="$SELF_SIGNED_CERT"
    SSL_CERTIFICATE_KEY="$SELF_SIGNED_KEY"
    echo "[custom-entrypoint.sh INFO] Using self-signed certificate for $DOMAIN"
  else
    echo "[custom-entrypoint.sh ERROR] No certificates found for $DOMAIN. Exiting."
    exit 1
  fi

  # 匯出變數供 envsubst 使用
  export SSL_CERTIFICATE
  export SSL_CERTIFICATE_KEY

  # 在替換前檢查變數值
  echo "[custom-entrypoint.sh DEBUG] SSL_CERTIFICATE=$SSL_CERTIFICATE"
  echo "[custom-entrypoint.sh DEBUG] SSL_CERTIFICATE_KEY=$SSL_CERTIFICATE_KEY"

  # 使用 envsubst 替換 SSL 設定檔案中的變數
  envsubst '${SSL_CERTIFICATE} ${SSL_CERTIFICATE_KEY}' < "$SSL_CONF_FILE" > "$SSL_CONF_FILE.tmp"
  mv "$SSL_CONF_FILE.tmp" "$SSL_CONF_FILE"
done

# 執行 Nginx 映像原本的 entrypoint 腳本
exec /docker-entrypoint.sh "$@"

補充說明

  • 定義網域列表DOMAINS 變數中列出的網域是可能需要切換到 Let's Encrypt 憑證的。如果是完全內部使用的網域,就不需要包含在內。
  • 決定使用哪種憑證:腳本會先檢查 Let's Encrypt 憑證是否存在,如果存在則使用;否則,檢查自簽憑證,依此決定使用哪種憑證,確保服務不會因為缺少憑證而無法啟動。
  • 執行原本的 entrypoint 腳本/docker-entrypoint.sh 是 Nginx 映像中預設的 entrypoint,執行了一些重要的初始化工作,不能被跳過。因此,我們在腳本最後使用 exec /docker-entrypoint.sh "$@",確保 Nginx 能正常啟動,同時也保留我們自訂的處理。

完成部署

最後,只要重新建置並啟動 reverse-proxy,就可以自動載入剛剛申請的憑證。如果在本地測試或正式環境還沒有申請到憑證,也會自動切換到位於 ./config/certs/self-singed/ 中的自簽憑證。

注意:不確定哪裡的快取造成的,有時候在首次成功使用 Certbot 拿到證書後,可能需要重新啟動 reverse-proxy 服務兩次,才能確保它正確載入 Let's Encrypt 的憑證。


上一篇
docker volume 備份方案:使用 volumerize 建立 odoo 恢復點
下一篇
阻止暴力破解攻擊:在 Docker 環境下保護 odoo 的 Fail2ban 實踐
系列文
Odoo 部署策略30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言