cover

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,兩個流程在本質上是相通的。

核心概念

  1. 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。

  2. 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。

  3. 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;database engine 可以動態產生短效期的資料庫帳密;pki engine 可以簽發 TLS 憑證。(3)Policies:用 HCL 語法定義誰能存取哪些 secrets。例如 path "secret/data/prod/*" 只允許特定角色讀取 production secrets。(4)Auth Methods:Vault 支援多種身份認證方式,包括 Token、AppRole(適合應用程式)、OIDC(適合人類使用者)、Kubernetes Service Account(適合 K8s Pod)。

  4. Secrets Rotation(輪換):為什麼需要 rotate secrets?因為 secrets 存在的時間越長,被洩漏的機率越高——員工離職、筆電被竊、log 不小心印出密碼,都是 secrets 外洩的途徑。定期輪換確保即使舊的 secrets 被洩漏,攻擊者能利用的時間窗口有限。自動化輪換的關鍵是 graceful rotation:先建立新的 credential、部署到應用服務、驗證新 credential 可用、最後才撤銷舊的 credential。如果直接撤銷舊的再建新的,中間會有服務中斷。Vault 的 dynamic secrets 是更進階的做法:每次應用程式要存取資料庫時,Vault 動態產生一組短效期(例如 1 小時)的帳密,用完就自動過期,根本不需要手動 rotate。

  5. 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 天效期,這個設計是故意的——迫使大家實現自動化續期,而不是手動管理。

  6. 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

  7. 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,新服務上線時完全不需要手動處理憑證。

  8. 憑證監控與告警:再好的自動續期機制也可能失敗(DNS 驗證失敗、ACME 帳號被 rate limit、cert-manager pod 掛了)。所以必須有獨立的監控機制。用 Prometheus 的 blackbox_exporter 對所有 HTTPS 端點做 TLS probe,取得 probe_ssl_earliest_cert_expiry metric,設定告警在到期前 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-secretstrufflehog、或 pre-commit hook,在 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,引入了對雲端供應商的依賴

延伸閱讀