
Secrets 管理與憑證生命週期:從硬編碼到全自動化
把密碼寫死在程式碼裡,是每個團隊都犯過的錯。剛開始覺得方便,反正「只有我們自己用」。直到某天有人把 .env push 到公開 repo,或者 production 的 TLS 憑證半夜過期,客戶打開網站看到「您的連線不是私人連線」,才發現 secrets 管理和憑證生命週期不是「以後再說」的事。2023 年 Microsoft 因為一把被遺忘的 signing key 導致了大規模的安全事件;Let’s Encrypt 憑證只有 90 天效期,忘記續期的案例每天都在發生。這篇文章從 secrets 的分類與演進開始,深入 HashiCorp Vault 的架構與操作,再延伸到 TLS 憑證的完整生命週期管理,包含 Let’s Encrypt 自動續期和 Kubernetes cert-manager。
架構概覽
flowchart LR CSR["產生 CSR\nGenerate Key Pair"] --> CA["CA 簽發\nLet's Encrypt / 內部 CA"] CA --> Deploy["部署憑證\nNginx / K8s Ingress"] Deploy --> Monitor["監控到期\ncert-manager / cron"] Monitor -->|即將到期| Renew["自動續期\nACME Protocol"] Renew --> CA Monitor -->|憑證正常| Deploy
架構概覽
flowchart TD subgraph SecretsFlow["Secrets 注入流程"] Dev[Developer] -->|local .env| LocalApp[Local Development] Vault[HashiCorp Vault] -->|API / Agent| App[Application] Vault -->|CI Integration| CICD[CI/CD Pipeline] CICD -->|inject secrets| ProdHost[Production Container] Admin[Admin] -->|rotate / revoke| Vault Vault -->|audit log| AuditLog[Audit Trail] end subgraph CertLifecycle["憑證生命週期"] direction LR CSR[CSR 產生] --> Issue[CA 簽發] Issue --> Deploy[部署到服務] Deploy --> Monitor[監控到期時間] Monitor -->|到期前 30 天| Renew[自動續期] Renew --> Deploy end SecretsFlow -.->|TLS certs 也是 secret| CertLifecycle Certbot[Certbot / ACME] --> Issue CertManager[cert-manager] --> Issue Prometheus[Prometheus\nblackbox_exporter] --> Monitor
上半部是 Secrets 的注入流程:開發環境用 local .env,CI/CD 和 Production 從 Vault 取得 secrets,所有存取都有 audit log。下半部是 TLS 憑證的生命週期:從 CSR 產生、CA 簽發、部署到服務、監控到期時間、到自動續期,形成一個閉環。TLS 憑證本身也是一種 secret,兩個流程在本質上是相通的。
核心概念
-
Secrets 的種類:Secrets 不只是「密碼」。常見的 secrets 類型包括:(1)API Keys:第三方服務的存取金鑰,如 Stripe、SendGrid、AWS。(2)資料庫密碼:PostgreSQL、MySQL、Redis 的連線憑證。(3)TLS 憑證與私鑰:HTTPS 加密用的
.pem和.key檔案。(4)SSH Keys:部署用的 private key。(5)Tokens:JWT signing key、OAuth client secret、Personal Access Token。(6)Encryption Keys:資料加密用的對稱金鑰或非對稱金鑰對。每一種 secrets 的敏感程度、輪換頻率、儲存方式都不同,不能用同一套規則管理所有 secrets。 -
Secrets 管理的演進:大多數團隊的 secrets 管理經歷了四個階段。第一階段:硬編碼(
const password = "admin123"),密碼直接寫在程式碼裡,任何能讀原始碼的人都能看到,而且進了 Git history 就永遠刪不掉。第二階段:.env檔案,把 secrets 從程式碼抽出來放到.env,搭配.gitignore防止進 Git,但.env檔案本身是明文儲存、沒有存取控制、沒有 audit log。第三階段:CI/CD Variables,把 secrets 存在 GitLab/GitHub 的 CI Variables 裡,有基本的權限控制(Protected / Masked),但 secrets 分散在各個 repo 的設定裡,沒有集中管理和自動輪換。第四階段:Vault / Secrets Manager,用專門的 secrets 管理服務集中儲存所有 secrets,提供精細的存取控制、自動輪換、加密儲存、完整的 audit log。 -
HashiCorp Vault 架構:Vault 是目前最廣泛使用的 secrets 管理工具。幾個核心概念:(1)Seal / Unseal:Vault 啟動時處於 sealed 狀態,所有資料都是加密的,無法讀取。需要提供足夠數量的 unseal key(Shamir’s Secret Sharing)才能解封。這確保了即使 Vault 的儲存被竊取,沒有 unseal key 也無法解密。(2)Secret Engines:不同類型的 secrets 用不同的 engine 管理。
kv(Key-Value)是最基本的,直接存放靜態 secrets;databaseengine 可以動態產生短效期的資料庫帳密;pkiengine 可以簽發 TLS 憑證。(3)Policies:用 HCL 語法定義誰能存取哪些 secrets。例如path "secret/data/prod/*"只允許特定角色讀取 production secrets。(4)Auth Methods:Vault 支援多種身份認證方式,包括 Token、AppRole(適合應用程式)、OIDC(適合人類使用者)、Kubernetes Service Account(適合 K8s Pod)。 -
Secrets Rotation(輪換):為什麼需要 rotate secrets?因為 secrets 存在的時間越長,被洩漏的機率越高——員工離職、筆電被竊、log 不小心印出密碼,都是 secrets 外洩的途徑。定期輪換確保即使舊的 secrets 被洩漏,攻擊者能利用的時間窗口有限。自動化輪換的關鍵是 graceful rotation:先建立新的 credential、部署到應用服務、驗證新 credential 可用、最後才撤銷舊的 credential。如果直接撤銷舊的再建新的,中間會有服務中斷。Vault 的 dynamic secrets 是更進階的做法:每次應用程式要存取資料庫時,Vault 動態產生一組短效期(例如 1 小時)的帳密,用完就自動過期,根本不需要手動 rotate。
-
TLS 憑證生命週期:一張 TLS 憑證從出生到死亡經歷以下階段:(1)產生 CSR(Certificate Signing Request):包含域名、組織資訊和公鑰。(2)CA 簽發:把 CSR 提交給 Certificate Authority(Let’s Encrypt、DigiCert 等),CA 驗證域名所有權後簽發憑證。(3)部署:把憑證和私鑰部署到 Nginx 或其他服務。(4)監控:持續監控憑證的到期時間,在到期前 30 天發出告警。(5)續期:在到期前自動或手動續期,更新部署。Let’s Encrypt 的憑證只有 90 天效期,這個設計是故意的——迫使大家實現自動化續期,而不是手動管理。
-
Let’s Encrypt + Certbot:Let’s Encrypt 是免費的 CA,透過 ACME 協定自動驗證域名所有權並簽發憑證。Certbot 是最常用的 ACME 客戶端。驗證方式有兩種:HTTP-01 challenge(在 web server 的
/.well-known/acme-challenge/放置驗證檔案)和 DNS-01 challenge(在 DNS 加入 TXT record,支援 wildcard 憑證)。自動續期的關鍵是設定 cron job 或 systemd timer,在憑證到期前自動執行certbot renew,成功後 reload Nginx。 -
cert-manager(Kubernetes):在 Kubernetes 環境裡,手動管理每個 Ingress 的 TLS 憑證是不現實的。cert-manager 是 K8s 的憑證管理控制器,透過自訂資源(Certificate、Issuer、ClusterIssuer)自動化整個憑證生命週期。你只要宣告「我需要
api.example.com的 TLS 憑證」,cert-manager 就會自動向 Let’s Encrypt 申請、部署到 K8s Secret、監控到期時間、自動續期。搭配 Ingress Controller,新服務上線時完全不需要手動處理憑證。 -
憑證監控與告警:再好的自動續期機制也可能失敗(DNS 驗證失敗、ACME 帳號被 rate limit、cert-manager pod 掛了)。所以必須有獨立的監控機制。用 Prometheus 的 blackbox_exporter 對所有 HTTPS 端點做 TLS probe,取得
probe_ssl_earliest_cert_expirymetric,設定告警在到期前 14 天通知。這是最後一道防線,確保憑證過期不會在半夜把你叫醒。
實作範例
HashiCorp Vault:從零開始部署與使用
# docker-compose.yml - Vault 開發環境部署
version: "3.8"
services:
vault:
image: hashicorp/vault:1.15
container_name: vault
restart: unless-stopped
ports:
- "8200:8200"
environment:
VAULT_ADDR: "http://0.0.0.0:8200"
# Development mode(僅限開發環境,自動 unseal)
# Production 不要用 dev mode
VAULT_DEV_ROOT_TOKEN_ID: "dev-root-token"
cap_add:
- IPC_LOCK
volumes:
- vault-data:/vault/data
- ./vault-config:/vault/config
# Production 模式的設定(使用 file storage)
# vault-prod:
# image: hashicorp/vault:1.15
# container_name: vault-prod
# restart: unless-stopped
# ports:
# - "8200:8200"
# environment:
# VAULT_ADDR: "http://0.0.0.0:8200"
# cap_add:
# - IPC_LOCK
# volumes:
# - vault-data:/vault/data
# - ./vault-config:/vault/config
# command: vault server -config=/vault/config/vault.hcl
volumes:
vault-data:#!/bin/bash
# scripts/vault-init.sh - Vault 初始化與基本操作
set -euo pipefail
VAULT_ADDR="http://127.0.0.1:8200"
export VAULT_ADDR
echo "=== 1. 初始化 Vault(Production 模式)==="
# 產生 5 個 unseal key,需要 3 個才能 unseal(Shamir 3/5)
vault operator init -key-shares=5 -key-threshold=3 > vault-keys.txt
# 重要:vault-keys.txt 包含 unseal key 和 root token,
# 必須離線保管(紙本、USB、分散給不同管理員)
echo "=== 2. Unseal Vault ==="
# 需要輸入 3 個不同的 unseal key
vault operator unseal <UNSEAL_KEY_1>
vault operator unseal <UNSEAL_KEY_2>
vault operator unseal <UNSEAL_KEY_3>
echo "=== 3. 登入 ==="
vault login <ROOT_TOKEN>
echo "=== 4. 啟用 KV secret engine ==="
vault secrets enable -path=secret kv-v2
echo "=== 5. 寫入 secrets ==="
vault kv put secret/prod/database \
host="db.internal.example.com" \
port="5432" \
username="app_prod" \
password="$(openssl rand -base64 32)" \
database="myapp_prod"
vault kv put secret/prod/api \
stripe_key="sk_live_xxxxx" \
sendgrid_key="SG.xxxxx" \
jwt_secret="$(openssl rand -base64 64)"
echo "=== 6. 讀取 secrets ==="
vault kv get secret/prod/database
vault kv get -field=password secret/prod/database
echo "=== 7. 建立 policy ==="
vault policy write app-prod - <<EOF
# app-prod policy: 只能讀取 prod secrets,不能寫入
path "secret/data/prod/*" {
capabilities = ["read", "list"]
}
path "secret/metadata/prod/*" {
capabilities = ["list"]
}
EOF
echo "=== 8. 建立 AppRole(給應用程式用)==="
vault auth enable approle
vault write auth/approle/role/myapp-prod \
token_policies="app-prod" \
token_ttl=1h \
token_max_ttl=4h \
secret_id_ttl=720h
# 取得 role_id 和 secret_id(給應用程式使用)
vault read auth/approle/role/myapp-prod/role-id
vault write -f auth/approle/role/myapp-prod/secret-id
echo "=== Vault 初始化完成 ==="應用程式從 Vault 讀取 Secrets(API 方式與 Agent Sidecar)
#!/bin/bash
# scripts/app-read-vault.sh - 應用程式啟動時從 Vault 讀取 secrets
set -euo pipefail
VAULT_ADDR="${VAULT_ADDR:-http://vault.internal:8200}"
ROLE_ID="${VAULT_ROLE_ID}"
SECRET_ID="${VAULT_SECRET_ID}"
echo "=== 1. 用 AppRole 登入 Vault 取得 token ==="
VAULT_TOKEN=$(curl -s \
--request POST \
--data "{\"role_id\": \"$ROLE_ID\", \"secret_id\": \"$SECRET_ID\"}" \
"$VAULT_ADDR/v1/auth/approle/login" | jq -r '.auth.client_token')
if [ "$VAULT_TOKEN" = "null" ] || [ -z "$VAULT_TOKEN" ]; then
echo "ERROR: Failed to authenticate with Vault"
exit 1
fi
echo "=== 2. 從 Vault 讀取 secrets 並注入環境變數 ==="
DB_SECRETS=$(curl -s \
--header "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/secret/data/prod/database")
export DB_HOST=$(echo "$DB_SECRETS" | jq -r '.data.data.host')
export DB_PORT=$(echo "$DB_SECRETS" | jq -r '.data.data.port')
export DB_USER=$(echo "$DB_SECRETS" | jq -r '.data.data.username')
export DB_PASSWORD=$(echo "$DB_SECRETS" | jq -r '.data.data.password')
export DB_NAME=$(echo "$DB_SECRETS" | jq -r '.data.data.database')
API_SECRETS=$(curl -s \
--header "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/secret/data/prod/api")
export STRIPE_KEY=$(echo "$API_SECRETS" | jq -r '.data.data.stripe_key')
export JWT_SECRET=$(echo "$API_SECRETS" | jq -r '.data.data.jwt_secret')
echo "=== 3. 啟動應用程式 ==="
# secrets 已經在環境變數中,啟動應用
exec node /app/dist/server.js# docker-compose.yml - 使用 Vault Agent Sidecar 自動注入 secrets
version: "3.8"
services:
# Vault Agent 作為 sidecar,自動從 Vault 取得 secrets 寫入檔案
vault-agent:
image: hashicorp/vault:1.15
container_name: vault-agent
restart: unless-stopped
environment:
VAULT_ADDR: "http://vault.internal:8200"
volumes:
- ./vault-agent-config.hcl:/vault/config/agent.hcl
- shared-secrets:/vault/secrets
command: vault agent -config=/vault/config/agent.hcl
api:
image: registry.example.com/myapp/api:v1.2.0
container_name: api
restart: unless-stopped
volumes:
# 從 vault-agent 寫入的 secrets 檔案讀取
- shared-secrets:/run/secrets:ro
environment:
# 應用程式從檔案讀取 secrets
DB_PASSWORD_FILE: /run/secrets/db_password
JWT_SECRET_FILE: /run/secrets/jwt_secret
depends_on:
- vault-agent
ports:
- "8000:8000"
volumes:
shared-secrets:Let’s Encrypt + Certbot:自動 TLS 憑證管理
#!/bin/bash
# scripts/certbot-setup.sh - Certbot 設定與自動續期
set -euo pipefail
DOMAIN="example.com"
EMAIL="admin@example.com"
echo "=== 1. 安裝 Certbot ==="
apt-get update && apt-get install -y certbot python3-certbot-nginx
echo "=== 2. 申請憑證(Nginx 插件自動設定)==="
certbot certonly \
--nginx \
-d "$DOMAIN" \
-d "*.${DOMAIN}" \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
--preferred-challenges dns-01
# 或者用 standalone 模式(不依賴 web server)
# certbot certonly --standalone -d "$DOMAIN" --email "$EMAIL" --agree-tos
echo "=== 3. 查看憑證資訊 ==="
certbot certificates
# 輸出包含:到期日、憑證路徑、私鑰路徑
echo "=== 4. 測試續期(dry-run)==="
certbot renew --dry-run
echo "=== 5. 設定自動續期 ==="
# 方法一:systemd timer(推薦)
cat > /etc/systemd/system/certbot-renew.timer <<'TIMER'
[Unit]
Description=Certbot renewal timer
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
TIMER
cat > /etc/systemd/system/certbot-renew.service <<'SERVICE'
[Unit]
Description=Certbot renewal service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
SERVICE
systemctl enable --now certbot-renew.timer
# 方法二:crontab
# 0 3 * * * certbot renew --quiet --deploy-hook "nginx -s reload"
echo "=== Certbot 設定完成 ==="
echo "憑證路徑: /etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
echo "私鑰路徑: /etc/letsencrypt/live/${DOMAIN}/privkey.pem"# /etc/nginx/conf.d/api.conf - 使用 Let's Encrypt 憑證的 Nginx 設定
upstream api_backend {
server 127.0.0.1:8000;
keepalive 32;
}
# HTTP → HTTPS 強制跳轉
server {
listen 80;
server_name api.example.com;
# ACME challenge 路徑(Certbot 續期用)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.example.com;
# Let's Encrypt 憑證
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# 推薦的 TLS 設定
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# 安全 Header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}cert-manager:Kubernetes 自動憑證管理
# cert-manager-issuer.yml - Let's Encrypt ClusterIssuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: nginx
# DNS-01 solver(支援 wildcard 憑證)
# - dns01:
# cloudflare:
# email: admin@example.com
# apiTokenSecretRef:
# name: cloudflare-api-token
# key: api-token
---
# staging issuer(測試用,不受 rate limit)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: nginx
---
# Certificate 資源:宣告需要的憑證
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-tls
namespace: production
spec:
secretName: api-tls-secret
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- api.example.com
- admin.example.com
# cert-manager 會在到期前 30 天自動續期
renewBefore: 720h # 30 天
---
# Ingress 自動取得 TLS 憑證
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.example.com
secretName: api-tls-secret
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8000憑證到期監控:Prometheus blackbox_exporter TLS Probe
# prometheus/blackbox.yml - TLS 憑證探測設定
modules:
tls_probe:
prober: tcp
timeout: 10s
tcp:
tls: true
tls_config:
insecure_skip_verify: false
https_probe:
prober: http
timeout: 15s
http:
method: GET
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
valid_status_codes: [200, 301, 302]
follow_redirects: true
fail_if_ssl: false
fail_if_not_ssl: true
tls_config:
insecure_skip_verify: false# prometheus/prometheus.yml - 憑證監控 scrape 設定
scrape_configs:
- job_name: 'ssl-cert-check'
metrics_path: /probe
params:
module: [https_probe]
static_configs:
- targets:
- https://api.example.com
- https://admin.example.com
- https://grafana.example.com
- https://harbor.example.com
- https://gitlab.example.com
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115# prometheus/alert-rules/cert-expiry.yml - 憑證到期告警規則
groups:
- name: ssl-certificate-alerts
rules:
# 憑證 14 天內到期:Warning
- alert: SSLCertExpiringSoon
expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 14
for: 1h
labels:
severity: warning
annotations:
summary: "SSL 憑證即將到期: {{ $labels.instance }}"
description: >
{{ $labels.instance }} 的 TLS 憑證將在
{{ $value | humanizeDuration }} 後到期。
請確認 Certbot 或 cert-manager 的自動續期是否正常運作。
# 憑證 3 天內到期:Critical
- alert: SSLCertExpiringCritical
expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 3
for: 10m
labels:
severity: critical
annotations:
summary: "SSL 憑證即將到期(緊急): {{ $labels.instance }}"
description: >
{{ $labels.instance }} 的 TLS 憑證將在
{{ $value | humanizeDuration }} 後到期!
需要立即處理,否則服務將出現 HTTPS 錯誤。
# 憑證已過期
- alert: SSLCertExpired
expr: probe_ssl_earliest_cert_expiry - time() <= 0
for: 0m
labels:
severity: critical
annotations:
summary: "SSL 憑證已過期: {{ $labels.instance }}"
description: >
{{ $labels.instance }} 的 TLS 憑證已經過期!
使用者會看到瀏覽器安全警告,必須立即手動續期。
# TLS probe 失敗(連不上或憑證無效)
- alert: TLSProbeFailed
expr: probe_success{job="ssl-cert-check"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "TLS 探測失敗: {{ $labels.instance }}"
description: >
無法對 {{ $labels.instance }} 進行 TLS 探測,
可能是服務掛了、憑證無效、或網路問題。GitLab CI 整合 Vault Secrets
# .gitlab-ci.yml - 從 Vault 注入 secrets 到 CI/CD Pipeline
variables:
VAULT_ADDR: "https://vault.example.com"
SERVICE_NAME: "payment-api"
stages:
- test
- build
- deploy
# === 方式一:GitLab Vault 原生整合(推薦)===
deploy:prod:
stage: deploy
image: alpine:latest
# JWT 身份認證(GitLab CI 自動產生 JWT token)
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
# 從 Vault 讀取 secrets,自動注入為環境變數
secrets:
DB_PASSWORD:
vault: prod/database/password@secret
file: false
STRIPE_SECRET_KEY:
vault: prod/api/stripe_key@secret
file: false
JWT_SIGNING_KEY:
vault: prod/api/jwt_secret@secret
file: false
before_script:
- apk add --no-cache openssh-client curl
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
# secrets 已經自動注入為環境變數,直接使用
- |
ssh -o StrictHostKeyChecking=no deploy@${PROD_HOST} << REMOTE
cd /opt/stacks/${SERVICE_NAME}
echo "DB_PASSWORD=${DB_PASSWORD}" > .env.secrets
echo "STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}" >> .env.secrets
echo "JWT_SIGNING_KEY=${JWT_SIGNING_KEY}" >> .env.secrets
chmod 600 .env.secrets
docker compose --env-file .env.secrets up -d --remove-orphans
rm .env.secrets
REMOTE
- sleep 10
- curl -sf "https://${SERVICE_NAME}.example.com/health" || exit 1
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+/
when: manual
environment:
name: prod
url: https://${SERVICE_NAME}.example.com
# === 方式二:在 script 中手動呼叫 Vault API ===
deploy:staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache curl jq openssh-client
script:
# 用 GitLab CI 的 JWT token 向 Vault 認證
- |
VAULT_TOKEN=$(curl -s \
--request POST \
--data "{\"role\": \"gitlab-ci\", \"jwt\": \"$CI_JOB_JWT_V2\"}" \
"$VAULT_ADDR/v1/auth/jwt/login" | jq -r '.auth.client_token')
# 從 Vault 取得 secrets
- |
DB_PASSWORD=$(curl -s \
--header "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/secret/data/staging/database" | \
jq -r '.data.data.password')
# 部署(省略詳細步驟)
- echo "Deploying with Vault secrets..."
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+/常見問題與風險
-
Production 憑證半夜過期:最痛的事故之一。常見原因:Certbot 的 cron job 沒有設定或執行失敗、DNS 驗證因為 DNS provider API 變更而失敗、cert-manager 的 Pod 被 OOMKilled 或被 evict。避免方式:一定要有獨立的憑證監控(如上面的 blackbox_exporter),不要只依賴 Certbot/cert-manager 的自動續期。設定 ChatOps 告警 在到期前 14 天就通知,給自己足夠的時間處理。
-
Vault Sealed 無法 Unseal:Vault 重啟後會回到 sealed 狀態,需要 unseal key 才能解封。如果 unseal key 遺失(只存在某個離職員工的電腦裡),所有 secrets 都無法存取。避免方式:使用 Shamir’s Secret Sharing(例如 5 把 key 需要 3 把才能 unseal),把 key 分散給不同管理員保管。或者使用 auto-unseal 機制(透過雲端 KMS 如 AWS KMS 或 GCP Cloud KMS 自動 unseal)。定期測試 unseal 流程,確保 key 可用。
-
Secrets 進了 Git History:有人不小心把
.env或 private key 推到 Git。即使用git revert或刪除檔案,Git history 裡還是有。處理流程:(1)立即 rotate 所有外洩的 secrets,這是第一優先。(2)用git filter-repo或 BFG Repo-Cleaner 從 Git history 移除敏感檔案。(3)force push 到遠端(會影響所有協作者,需要通知)。(4)如果 repo 曾經是 public,假設 secrets 已經被第三方讀取,rotate 是唯一解。預防方式:安裝git-secrets、trufflehog、或pre-commithook,在 commit 前自動掃描是否有 secrets pattern。 -
Wildcard 憑證的風險:
*.example.com的 wildcard 憑證很方便,但如果私鑰被洩漏,攻擊者可以冒充所有子域名。而且 wildcard 不涵蓋子域名的子域名(*.*.example.com)。建議:只在確實需要大量子域名時使用 wildcard;私鑰的存取權限要嚴格限制;考慮用 cert-manager 為每個服務簽發獨立憑證。 -
Dynamic Secrets 的 TTL 太短導致服務中斷:使用 Vault dynamic secrets 時,如果 TTL 設太短(例如 5 分鐘),而應用程式沒有實作 token renewal,credential 過期後資料庫連線就會斷開。避免方式:應用程式必須實作 lease renewal 邏輯;設定合理的 TTL(至少 1 小時);使用 Vault Agent 自動處理 renewal。
-
憑證鏈不完整(Intermediate CA 缺失):部署 TLS 憑證時只放了 leaf certificate 沒有放 intermediate certificate,導致某些客戶端(尤其是舊版 Android)無法驗證憑證鏈。Let’s Encrypt 的
fullchain.pem已經包含 intermediate,但如果是從其他 CA 取得的憑證,要記得把完整的 certificate chain 都部署上去。可以用openssl s_client -connect example.com:443 -showcerts或 SSL Labs 的線上工具檢查。
優點
- Vault 提供集中化的 secrets 管理,所有存取都有 audit log,可追溯
- 自動化的憑證生命週期管理,消除人為遺忘導致的過期事故
- Dynamic secrets 大幅降低 credential 長期暴露的風險
- cert-manager 讓 Kubernetes 環境的憑證管理完全自動化
缺點 / 限制
- Vault 的部署和維護有一定複雜度,小團隊初期可能負擔較大
- Vault 本身成為關鍵基礎設施,掛了會影響所有 secrets 的存取,需要高可用部署
- Let’s Encrypt 的 rate limit(每個域名每週 50 張憑證)在大規模環境需要注意
- cert-manager 依賴 Kubernetes,非 K8s 環境無法使用
- auto-unseal 依賴雲端 KMS,引入了對雲端供應商的依賴