最簡單、也最為人熟知的分散式模式:複製式負載平衡服務(replicated load-balanced service)。服務中每台伺服器完全相同、都能承接流量;可擴展數量的伺服器前面放一個負載平衡器(load balancer),後者通常採完全輪詢(round-robin)或某種形式的工作階段黏著(session stickiness)。

無狀態服務#

  • 無狀態服務(stateless service):不需要保存狀態就能正確運作的服務。最簡單的情況下,甚至每個獨立請求都可以被路由到不同的服務實例。例子:靜態內容伺服器、從多個後端系統接收並聚合回應的複雜中介層系統。

圖 5-1:基本的複製式無狀態服務

  • 無狀態系統靠複製提供冗餘與規模;水平擴展系統靠增加副本承接越來越多的使用者。

圖 5-2:複製式無狀態應用的水平擴展

負載平衡的就緒探針#

  • 光是複製加負載平衡還不完整——設計複製式服務時,同樣重要的是建立**就緒探針(readiness probe)**來通知負載平衡器。
  • 區別:健康探針(health probe)讓容器編排系統判斷應用何時需要重啟;就緒探針判斷應用何時準備好服務使用者請求
  • 許多應用啟動後需要時間初始化——連資料庫、載入外掛、從網路下載服務檔案。這些時候容器是活著的,但還沒準備好。

為複製式服務模式建構應用時,務必包含一個實作就緒檢查的專屬 URL。

動手做:在 Kubernetes 建立複製式服務

以一個提供字典釋義的小型 NodeJS 應用為例。先在本機試跑:

docker run -p 8080:8080 brendanburns/dictionary-server

造訪 http://localhost:8080/dog 可看到 dog 的釋義。看容器日誌會發現它立刻開始服務,但要等約 8 MB 的字典從網路下載完成後才回報就緒。

在 Kubernetes 用 Deployment 部署(注意 readinessProbe 設定):

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: dictionary-server
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: dictionary-server
    spec:
      containers:
        - name: server
          image: brendanburns/dictionary-server
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5

kubectl create -f dictionary-deploy.yaml 建立。有了多個副本後,需要負載平衡器把請求帶到副本——它同時分配負載、把複製式服務與其消費者隔開,並提供一個獨立於任何特定副本的可解析名稱。在 Kubernetes 用 Service 物件建立:

kind: Service
apiVersion: v1
metadata:
  name: dictionary-server-service
spec:
  selector:
    app: dictionary-server
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

kubectl create -f dictionary-service.yaml 建立。

工作階段追蹤服務#

  • 前面的模式把所有使用者的請求平均分給所有副本;但有時你希望特定使用者的請求永遠落在同一台機器
    • 該使用者的資料快取在記憶體裡,落在同一台能提高快取命中率。
    • 互動本質上是長時間的,請求之間需要維持某些狀態。
  • 工作階段追蹤服務(session tracked services)確保單一使用者的所有請求映射到同一個副本。一般做法是雜湊來源與目的 IP 位址作為鍵來決定伺服器——只要來源與目的 IP 不變,請求就送往同一副本。
  • 追蹤通常用**一致性雜湊(consistent hashing)**實作:擴縮容時副本數改變,使用者對副本的映射難免變動,一致性雜湊能把「實際換了副本」的使用者數降到最低,減少擴縮容對應用的衝擊。

圖 5-3:工作階段追蹤服務——特定使用者的所有請求都被路由到單一實例

基於 IP 的工作階段追蹤在叢集內部(內部 IP)可行,但對外部 IP 通常不管用——因為網路位址轉換(NAT)。對外的工作階段追蹤,偏好應用層級的追蹤(例如透過 cookie)。

應用層複製式服務#

前述例子的複製與負載平衡都發生在服務的網路層,與 TCP/IP 之上實際使用的協定無關。但許多應用彼此用 HTTP 溝通——知道應用層協定,就能對複製式無狀態服務模式做進一步加值。

引入快取層#

  • 無狀態服務的程式碼仍可能很昂貴:服務請求時要查資料庫、做大量渲染或資料混合。此時快取層非常合理。
  • Web 應用最簡單的快取形式是快取式 Web 代理(caching web proxy):一個把使用者請求維持在記憶體狀態的 HTTP 伺服器。兩位使用者請求同一頁面時,只有一個請求打到後端,另一個直接從記憶體回應。本章使用開源 Web 快取 Varnish

圖 5-4:快取伺服器的運作方式

部署快取#

  • 最簡單的部署方式是用邊車模式,讓快取跟著每個 Web 伺服器實例——但這有缺點:快取得跟 Web 伺服器等比例擴展

圖 5-5:以邊車形式加入 Web 快取伺服器

  • 快取的理想是副本少、每個副本資源多(例如:與其 10 個各 1 GB RAM 的副本,不如 2 個各 5 GB 的副本)。因為每一頁會存在每個副本裡:10 個副本等於每頁存 10 次,能放進快取的頁面總量變少,**命中率(hit rate)**下降,快取的效用隨之降低。
  • 反過來,Web 伺服器往往需要很多小副本:許多語言(如 NodeJS)只能利用單一核心,需要多副本才能吃滿多核。
  • 結論:把快取層配置為 Web 服務層之上的第二個無狀態複製式服務層

圖 5-6:把快取層加進複製式服務

不小心的話,快取會弄壞工作階段追蹤:使用預設的 IP 位址親和性時,所有請求的來源 IP 都是快取的 IP,不是終端使用者的。若照建議部署了少數大快取,基於 IP 的親和性甚至可能讓 Web 層的某些副本完全收不到流量。應改用 cookie 或 HTTP 標頭做工作階段追蹤。

動手做:部署快取層(Varnish)

之前建立的 dictionary-server-service 可透過 DNS 名稱發現。Varnish 設定指向它:

vcl 4.0;
backend default {
  .host = "dictionary-server-service";
  .port = "8080";
}

建立 ConfigMap 保存設定:

kubectl create configmap varnish-config --from-file=default.vcl

部署複製式 Varnish 快取:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: varnish-cache
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: varnish-cache
    spec:
      containers:
        - name: cache
          resources:
            requests:
              # We'll use two gigabytes for each varnish cache
              memory: 2Gi
          image: brendanburns/varnish
          command:
            - varnishd
            - -F
            - -f
            - /etc/varnish-config/default.vcl
            - -a
            - 0.0.0.0:8080
            - -s
            # This memory allocation should match the memory request above
            - malloc,2G
          ports:
            - containerPort: 8080
          volumeMounts:
            - name: varnish
              mountPath: /etc/varnish-config
      volumes:
        - name: varnish
          configMap:
            name: varnish-config

最後為 Varnish 快取部署負載平衡器:

kind: Service
apiVersion: v1
metadata:
  name: varnish-service
spec:
  selector:
    app: varnish-cache
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

分別以 kubectl create -f varnish-deploy.yamlkubectl create -f varnish-service.yaml 建立。

圖 5-7:為字典伺服器加上快取層

擴充快取層#

Varnish 這類 HTTP 反向代理(reverse proxy)通常是可外掛的,能提供快取之外的多項進階功能。

速率限制與阻斷服務防禦#

  • 很少人建站時預期會遭遇阻斷服務(denial-of-service)攻擊;但隨著大家越來越常建 API,「阻斷服務」可能只是開發者設錯客戶端、或 SRE 不小心對正式環境跑了壓測。
  • 因此在快取層加上**速率限制(rate limiting)**作為一般性防禦是合理的。Varnish 有 throttle 模組,可依 IP 位址、請求路徑、是否登入來節流。

部署 API 的最佳實踐:匿名存取給相對低的速率上限,要求登入才能取得更高上限。登入既提供稽核(查得出誰造成非預期負載),也是攻擊者的門檻——要成功攻擊得先取得多重身分。

  • 使用者觸頂時,伺服器回傳 429(請求過多)錯誤碼;許多使用者想知道還剩多少額度,因此通常也會在 HTTP 標頭放剩餘次數資訊——沒有標準標頭,但許多 API 使用 X-RateLimit-Remaining 的變體。

SSL 終結#

  • 邊緣層除了快取,另一常見任務是 SSL 終結(SSL termination)
  • 即使叢集內各層之間也打算用 SSL,邊緣與內部服務仍應使用不同憑證;且每個內部服務都該有自己的憑證,確保各層能獨立發版。
  • Varnish 不能做 SSL 終結,但 nginx 可以。因此在無狀態應用模式中加入第三層:一層複製式 nginx 伺服器,負責終結 HTTPS 流量並轉發給 Varnish 快取;HTTP 流量照樣進 Varnish,Varnish 再轉發給 Web 應用。
動手做:部署 nginx 與 SSL 終結

以下假設你已有憑證(最簡單的取得方式是 Let’s Encrypt 的工具,或用 openssl 自建),並命名為 server.crt(公開憑證)與 server.key(私鑰)。自簽憑證會觸發現代瀏覽器的安全警告,絕不可用於正式環境

先把憑證上傳為 Kubernetes 的 secret:

kubectl create secret tls ssl --cert=server.crt --key=server.key

建立服務 SSL 的 nginx 設定:

events {
  worker_connections    1024;
}

http {
  server {
    listen 443 ssl;
    server_name my-domain.com www.my-domain.com;
    ssl on;
    ssl_certificate         /etc/certs/tls.crt;
    ssl_certificate_key     /etc/certs/tls.key;
    location / {
        proxy_pass http://varnish-service:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
  }
}

轉為 ConfigMap(kubectl create configmap nginx-conf --from-file=nginx.conf),然後建立複製式無狀態 nginx 層:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nginx-ssl
spec:
  replicas: 4
  template:
    metadata:
      labels:
        app: nginx-ssl
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 443
          volumeMounts:
            - name: conf
              mountPath: /etc/nginx
            - name: certs
              mountPath: /etc/certs
      volumes:
        - name: conf
          configMap:
            # This is the ConfigMap for nginx we created previously
            name: nginx-conf
        - name: certs
          secret:
            # This is the secret we created above
            secretName: ssl

最後以 type: LoadBalancer 的 Service 對外暴露:

kind: Service
apiVersion: v1
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx-ssl
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 443
      targetPort: 443

在支援外部負載平衡器的 Kubernetes 叢集上,這會建立一個在公開 IP 上服務的對外服務;kubectl get services 可查得 IP,之後即可用瀏覽器存取。

本章小結#

圖 5-8:完整的複製式無狀態服務範例

  • 從最簡單的複製式無狀態服務模式出發,逐步疊加兩個額外的複製式負載平衡層:快取層(效能)與 SSL 終結層(安全的 Web 服務)。
  • 完整模式在 Kubernetes 上用三個 Deployment 與連接各層的 Service 負載平衡器即可部署;完整範例原始碼見 https://github.com/brendandburns/designing-distributed-systems