這一章花了我不少時間,主要是因為我們的 reverse-proxy 無法自動切換憑證,讓我頗為煩惱:申請憑證需要 reverse-proxy 已經運行,但如果 nginx 無法找到我們指定的憑證檔案,服務就無法啟動,也還要兼顧本地測試的情況。原本我嘗試去找了一些別人的解決方案,例如 LinuxServer.io 的 SWAG,但發現它原生不支援多個網域,也沒有自動切換自簽和正式憑證的機制。繞了一圈,最後乾脆自己寫了個腳本,解法還算優雅,我蠻喜歡的。
Let's Encrypt 提供自動化的憑證簽發服務,主要透過 ACME 協議進行驗證。其中常用的驗證方式是 HTTP-01 挑戰。這種方式要求在網域下的特定路徑(如 /.well-known/acme-challenge/
)放置一個驗證檔案,Let's Encrypt 會嘗試透過 HTTP 存取該檔案,以確認我們對該網域的控制權。
首先,我們來確定能夠成功取得憑證。
我們在 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-proxy
和 certbot
共用一些檔案,主要是憑證存放的位置和挑戰檔案的位置,這樣 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
生成的憑證,以及挑戰檔案。
為了讓 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
使用這些憑證呢?我摸索了一陣子,最後決定直接寫個腳本來處理。
首先,將原本直接使用的 nginx:1.27-alpine
改成使用自訂的 Dockerfile。
# 原本的設定
image: nginx:1.27-alpine
# 修改後
build: ./reverse-proxy
在 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;"]
為了讓結構更為整潔,我將原本每個 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};
建立 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 憑證的。如果是完全內部使用的網域,就不需要包含在內。/docker-entrypoint.sh
是 Nginx 映像中預設的 entrypoint,執行了一些重要的初始化工作,不能被跳過。因此,我們在腳本最後使用 exec /docker-entrypoint.sh "$@"
,確保 Nginx 能正常啟動,同時也保留我們自訂的處理。最後,只要重新建置並啟動 reverse-proxy
,就可以自動載入剛剛申請的憑證。如果在本地測試或正式環境還沒有申請到憑證,也會自動切換到位於 ./config/certs/self-singed/
中的自簽憑證。
注意:不確定哪裡的快取造成的,有時候在首次成功使用 Certbot 拿到證書後,可能需要重新啟動
reverse-proxy
服務兩次,才能確保它正確載入 Let's Encrypt 的憑證。