原文鏈接:Kubernetes 部署策略詳解
在Kubernetes
中有幾種不同的方式發佈應用,所以爲了讓應用在升級期間依然平穩提供服務,選擇一個正確的發佈策略就非常重要了。
選擇正確的部署策略是要依賴於我們的業務需求的,下面我們列出了一些可能會使用到的策略:
重建(recreate):停止舊版本部署新版本
滾動更新(rolling-update):一個接一個地以滾動更新方式發佈新版本
藍綠(blue/green):新版本與舊版本一起存在,然後切換流量
金絲雀(canary):將新版本面向一部分用戶發佈,然後繼續全量發佈
A/B測(a/b testing):以精確的方式(HTTP 頭、cookie、權重等)向部分用戶發佈新版本。
A/B測
實際上是一種基於數據統計做出業務決策的技術。在 Kubernetes 中並不原生支持,需要額外的一些高級組件來完成改設置(比如Istio、Linkerd、Traefik、或者自定義 Nginx/Haproxy 等)。
你可以在Kubernetes
集羣上來對上面的這些策略進行測試,下面的倉庫中有需要使用到的資源清單:https://github.com/ContainerSolutions/k8s-deployment-strategies
接下來我們來介紹下每種策略,看看在什麼場景下面適合哪種策略。
重建(Recreate) - 最好在開發環境
策略定義爲Recreate
的Deployment
,會終止所有正在運行的實例,然後用較新的版本來重新創建它們。
spec: replicas: 3 strategy: type: Recreate
Recreate
重新創建策略是一個虛擬部署,包括關閉版本A,然後在關閉版本A後部署版本B. 此技術意味着服務的停機時間取決於應用程序的關閉和啓動持續時間。
我們這裏創建兩個相關的資源清單文件,app-v1.yaml:
apiVersion: v1kind: Servicemetadata: name: my-app labels: app: my-appspec: type: NodePort ports: - name: http port: 80 targetPort: http selector: app: my-app---apiVersion: apps/v1kind: Deploymentmetadata: name: my-app labels: app: my-appspec: replicas: 3 selector: matchLabels: app: my-app strategy: type: Recreate selector: matchLabels: app: my-app template: metadata: labels: app: my-app version: v1.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v1.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
app-v2.yaml 文件內容如下:
apiVersion: apps/v1kind: Deploymentmetadata: name: my-app labels: app: my-appspec: replicas: 3 strategy: type: Recreate selector: matchLabels: app: my-app template: metadata: labels: app: my-app version: v2.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v2.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
上面兩個資源清單文件中的 Deployment 定義幾乎是一直的,唯一不同的是定義的環境變量VERSION
值不同,接下來按照下面的步驟來驗證Recreate
策略:
版本1提供服務
刪除版本1
部署版本2
等待所有副本準備就緒
首先部署第一個應用:
$ kubectl apply -f app-v1.yamlservice "my-app" created deployment.apps "my-app" created
測試版本1是否部署成功:
$ kubectl get pods -l app=my-appNAME READY STATUS RESTARTS AGE my-app-7b4874cd75-m5kct 1/1 Running 0 19m my-app-7b4874cd75-pc444 1/1 Running 0 19m my-app-7b4874cd75-tlctl 1/1 Running 0 19m$ kubectl get svc my-appNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE my-app NodePort 10.108.238.76 <none> 80:32532/TCP 5m$ curl http://127.0.0.1:32532Host: my-app-7b4874cd75-pc444, Version: v1.0.0
可以看到版本1的應用正常運行了。爲了查看部署的運行情況,打開一個新終端並運行以下命令:
$ watch kubectl get po -l app=my-app
然後部署版本2的應用:
$ kubectl apply -f app-v2.yaml
這個時候可以觀察上面新開的終端中的 Pod 列表的變化,可以看到之前的3個 Pod 都會先處於Terminating
狀態,並且3個 Pod 都被刪除後纔開始創建新的 Pod。
然後測試第二個版本應用的部署進度:
$ while sleep 0.1; do curl http://127.0.0.1:32532; donecurl: (7) Failed connect to 127.0.0.1:32532; Connection refused curl: (7) Failed connect to 127.0.0.1:32532; Connection refused ...... Host: my-app-f885c8d45-sp44p, Version: v2.0.0 Host: my-app-f885c8d45-t8g7g, Version: v2.0.0 Host: my-app-f885c8d45-sp44p, Version: v2.0.0 ......
可以看到最開始的階段服務都是處於不可訪問的狀態,然後到第二個版本的應用部署成功後才正常訪問,可以看到現在訪問的數據是版本2了。
最後,可以執行下面的命令來清空上面的資源對象:
$ kubectl delete all -l app=my-app
結論:
應用狀態全部更新
停機時間取決於應用程序的關閉和啓動消耗的時間
滾動更新(rolling-update)
滾動更新通過逐個替換實例來逐步部署新版本的應用,直到所有實例都被替換完成爲止。它通常遵循以下過程:在負載均衡器後面使用版本 A 的實例池,然後部署版本 B 的一個實例,當服務準備好接收流量時(Readiness Probe 正常),將該實例添加到實例池中,然後從實例池中刪除一個版本 A 的實例並關閉,如下圖所示:
ramped
下圖是滾動更新過程應用接收流量的示意圖:
rolling-update requests
下面是 Kubernetes 中通過 Deployment 來進行滾動更新的關鍵參數:
spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 2 # 一次可以添加多少個Pod maxUnavailable: 1 # 滾動更新期間最大多少個Pod不可用
現在仍然使用上面的 app-v1.yaml 這個資源清單文件,新建一個定義滾動更新的資源清單文件 app-v2-rolling-update.yaml,文件內容如下:
apiVersion: apps/v1kind: Deploymentmetadata: name: my-app labels: app: my-appspec: replicas: 10 # maxUnavailable設置爲0可以完全確保在滾動更新期間服務不受影響,還可以使用百分比的值來進行設置。 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: my-app template: metadata: labels: app: my-app version: v2.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v2.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe # 初始延遲設置高點可以更好地觀察滾動更新過程 initialDelaySeconds: 15 periodSeconds: 5
上面的資源清單中我們在環境變量中定義了版本2,然後通過設置strategy.type=RollingUpdate
來定義該 Deployment 使用滾動更新的策略來更新應用,接下來我們按下面的步驟來驗證滾動更新策略:
版本1提供服務
部署版本2
等待直到所有副本都被版本2替換完成
同樣,首先部署版本1應用:
$ kubectl apply -f app-v1.yamlservice "my-app" created deployment.apps "my-app" created
測試版本1是否部署成功:
$ kubectl get pods -l app=my-appNAME READY STATUS RESTARTS AGE my-app-7b4874cd75-h8c4d 1/1 Running 0 47s my-app-7b4874cd75-p4l8f 1/1 Running 0 47s my-app-7b4874cd75-qnt7p 1/1 Running 0 47s$ kubectl get svc my-appNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE my-app NodePort 10.109.99.184 <none> 80:30486/TCP 1m$ curl http://127.0.0.1:30486Host: my-app-7b4874cd75-qnt7p, Version: v1.0.0
同樣,在一個新終端中執行下面命令觀察 Pod 變化:
$ watch kubectl get pod -l app=my-app
然後部署滾動更新版本2應用:
$ kubectl apply -f app-v2-rolling-update.yamldeployment.apps "my-app" configured
這個時候在上面的 watch 終端中可以看到多了很多 Pod,還在創建當中,並沒有一開始就刪除之前的 Pod,同樣,這個時候執行下面命令,測試應用狀態:
$ while sleep 0.1; do curl http://127.0.0.1:30486; doneHost: my-app-7b4874cd75-vrlj7, Version: v1.0.0 ...... Host: my-app-7b4874cd75-vrlj7, Version: v1.0.0 Host: my-app-6b5479d97f-2fk24, Version: v2.0.0 Host: my-app-7b4874cd75-p4l8f, Version: v1.0.0 ...... Host: my-app-6b5479d97f-s5ctz, Version: v2.0.0 Host: my-app-7b4874cd75-5ldqx, Version: v1.0.0 ...... Host: my-app-6b5479d97f-5z6ww, Version: v2.0.0
我們可以看到上面的應用並沒有出現不可用的情況,最開始訪問到的都是版本1的應用,然後偶爾會出現版本2的應用,直到最後全都變成了版本2的應用,而這個時候看上面 watch 終端中 Pod 已經全部變成10個版本2的應用了,我們可以看到這就是一個逐步替換的過程。
如果在滾動更新過程中發現新版本應用有問題,我們可以通過下面的命令來進行一鍵回滾:
$ kubectl rollout undo deploy my-appdeployment.apps "my-app"
如果你想保持兩個版本的應用都存在,那麼我們也可以執行 pause 命令來暫停更新:
$ kubectl rollout pause deploy my-appdeployment.apps "my-app" paused
這個時候我們再去循環訪問我們的應用就可以看到偶爾會出現版本1的應用信息了。
如果新版本應用程序沒問題了,也可以繼續恢復更新:
$ kubectl rollout resume deploy my-appdeployment.apps "my-app" resumed
最後,可以執行下面的命令來清空上面的資源對象:
$ kubectl delete all -l app=my-app
結論:
版本在實例之間緩慢替換
rollout/rollback 可能需要一定時間
無法控制流量
藍/綠(blue/green) - 最好用來驗證 API 版本問題
藍/綠髮布是版本2 與版本1 一起發佈,然後流量切換到版本2,也稱爲紅/黑部署。藍/綠髮布與滾動更新不同,版本2(綠
) 與版本1(藍
)一起部署,在測試新版本滿足要求後,然後更新更新 Kubernetes 中扮演負載均衡器角色的 Service 對象,通過替換 label selector 中的版本標籤來將流量發送到新版本,如下圖所示:
blug/green
下面是藍綠髮布策略下應用方法的示例圖:
blue/green request
在 Kubernetes 中,我們可以用兩種方法來實現藍綠髮布,通過單個 Service 對象或者 Ingress 控制器來實現藍綠髮布,實際操作都是類似的,都是通過 label 標籤去控制。
實現藍綠髮布的關鍵點就在於 Service 對象中 label selector 標籤的匹配方法,比如我們重新定義版本1 的資源清單文件 app-v1-single-svc.yaml,文件內容如下:
apiVersion: v1kind: Servicemetadata: name: my-app labels: app: my-appspec: type: NodePort ports: - name: http port: 80 targetPort: http # 注意這裏我們匹配 app 和 version 標籤,當要切換流量的時候,我們更新 version 標籤的值,比如:v2.0.0 selector: app: my-app version: v1.0.0---apiVersion: apps/v1kind: Deploymentmetadata: name: my-app-v1 labels: app: my-appspec: replicas: 3 selector: matchLabels: app: my-app version: v1.0.0 template: metadata: labels: app: my-app version: v1.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v1.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
上面定義的資源對象中,最重要的就是 Service 中 label selector 的定義:
selector: app: my-app version: v1.0.0
版本2 的應用定義和以前一樣,新建文件 app-v2-single-svc.yaml,文件內容如下:
apiVersion: apps/v1kind: Deploymentmetadata: name: my-app-v2 labels: app: my-appspec: replicas: 3 selector: matchLabels: app: my-app version: v2.0.0 template: metadata: labels: app: my-app version: v2.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v2.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
然後按照下面的步驟來驗證使用單個 Service 對象實現藍/綠部署的策略:
版本1 應用提供服務
部署版本2 應用
等到版本2 應用全部部署完成
切換入口流量從版本1 到版本2
關閉版本1 應用
首先,部署版本1 應用:
$ kubectl apply -f app-v1-single-svc.yamlservice "my-app" created deployment.apps "my-app-v1" created
測試版本1 應用是否部署成功:
$ kubectl get pods -l app=my-appNAME READY STATUS RESTARTS AGE my-app-v1-7b4874cd75-7xh6s 1/1 Running 0 41s my-app-v1-7b4874cd75-dmq8f 1/1 Running 0 41s my-app-v1-7b4874cd75-t64z7 1/1 Running 0 41s$ kubectl get svc -l app=my-appNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE my-app NodePort 10.106.184.144 <none> 80:31539/TCP 50s$ curl http://127.0.0.1:31539Host: my-app-v1-7b4874cd75-7xh6s, Version: v1.0.0
同樣,新開一個終端,執行如下命令觀察 Pod 變化:
$ watch kubectl get pod -l app=my-app
然後部署版本2 應用:
$ kubectl apply -f app-v2-single-svc.yamldeployment.apps "my-app-v2" created
然後在上面 watch 終端中可以看到會多3個my-app-v2
開頭的 Pod,待這些 Pod 部署成功後,我們再去訪問當前的應用:
$ while sleep 0.1; do curl http://127.0.0.1:31539; doneHost: my-app-v1-7b4874cd75-dmq8f, Version: v1.0.0 Host: my-app-v1-7b4874cd75-dmq8f, Version: v1.0.0 ......
我們會發現訪問到的都是版本1 的應用,和我們剛剛部署的版本2 沒有任何關係,這是因爲我們 Service 對象中通過 label selector 匹配的是version=v1.0.0
這個標籤,我們可以通過修改 Service 對象的匹配標籤,將流量路由到標籤version=v2.0.0
的 Pod 去:
$ kubectl patch service my-app -p '{"spec":{"selector":{"version":"v2.0.0"}}}'service "my-app" patched
然後再去訪問應用,可以發現現在都是版本2 的信息了:
$ while sleep 0.1; do curl http://127.0.0.1:31539; doneHost: my-app-v2-f885c8d45-r5m6z, Version: v2.0.0 Host: my-app-v2-f885c8d45-r5m6z, Version: v2.0.0 ......
如果你需要回滾到版本1,同樣只需要更改 Service 的匹配標籤即可:
$ kubectl patch service my-app -p '{"spec":{"selector":{"version":"v1.0.0"}}}'
如果新版本已經完全符合我們的需求了,就可以刪除版本1 的應用了:
$ kubectl delete deploy my-app-v1
最後,同樣,執行如下命令清理上述資源對象:
$ kubectl delete all -l app=my-app
結論:
實時部署/回滾
避免版本問題,因爲一次更改是整個應用的改變
需要兩倍的資源
在發佈到生產之前,應該對整個應用進行適當的測試
金絲雀(Canary) - 讓部分用戶參與測試
金絲雀部署是讓部分用戶訪問到新版本應用,在 Kubernetes 中,可以使用兩個具有相同 Pod 標籤的 Deployment 來實現金絲雀部署。新版本的副本和舊版本的一起發佈。在一段時間後如果沒有檢測到錯誤,則可以擴展新版本的副本數量並刪除舊版本的應用。
如果需要按照具體的百分比來進行金絲雀發佈,需要儘可能的啓動多的 Pod 副本,這樣計算流量百分比的時候才方便,比如,如果你想將 1% 的流量發送到版本 B,那麼我們就需要有一個運行版本 B 的 Pod 和 99 個運行版本 A 的 Pod,當然如果你對具體的控制策略不在意的話也就無所謂了,如果你需要更精確的控制策略,建議使用服務網格(如 Istio),它們可以更好地控制流量。
Canary
在下面的例子中,我們使用 Kubernetes 原生特性來實現一個窮人版的金絲雀發佈,如果你想要對流量進行更加細粒度的控制,請使用豪華版本的 Istio。下面是金絲雀發佈的應用請求示意圖:
canary requests
接下來我們按照下面的步驟來驗證金絲雀策略:
10個副本的版本1 應用提供服務
版本2 應用部署1個副本(意味着小於10%的流量)
等待足夠的時間來確認版本2 應用足夠穩定沒有任何錯誤信息
將版本2 應用擴容到10個副本
等待所有實例完成
關閉版本1 應用
首先,創建版本1 的應用資源清單,app-v1-canary.yaml,內容如下:
apiVersion: v1kind: Servicemetadata: name: my-app labels: app: my-appspec: type: NodePort ports: - name: http port: 80 targetPort: http selector: app: my-app---apiVersion: apps/v1kind: Deploymentmetadata: name: my-app-v1 labels: app: my-appspec: replicas: 10 selector: matchLabels: app: my-app version: v1.0.0 template: metadata: labels: app: my-app version: v1.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v1.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
其中核心的部分也是 Service 對象中的 label selector 標籤,不在具有版本相關的標籤了,然後定義版本2 的資源清單文件,app-v2-canary.yaml,文件內容如下:
apiVersion: apps/v1kind: Deploymentmetadata: name: my-app-v2 labels: app: my-appspec: replicas: 1 selector: matchLabels: app: my-app version: v2.0.0 template: metadata: labels: app: my-app version: v2.0.0 annotations: prometheus.io/scrape: "true" prometheus.io/port: "9101" spec: containers: - name: my-app image: containersol/k8s-deployment-strategies ports: - name: http containerPort: 8080 - name: probe containerPort: 8086 env: - name: VERSION value: v2.0.0 livenessProbe: httpGet: path: /live port: probe initialDelaySeconds: 5 periodSeconds: 5 readinessProbe: httpGet: path: /ready port: probe periodSeconds: 5
版本1 和版本2 的 Pod 都具有一個共同的標籤app=my-app
,所以對應的 Service 會匹配兩個版本的 Pod。
首先,部署版本1 應用:
$ kubectl apply -f app-v1-canary.yamlservice "my-app" created deployment.apps "my-app-v1" created
然後測試版本1 應用是否正確部署了:
$ kubectl get svc -l app=my-appNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE my-app NodePort 10.105.133.213 <none> 80:30760/TCP 47s$ curl http://127.0.0.1:30760Host: my-app-v1-7b4874cd75-tsh2s, Version: v1.0.0
同樣,新開一個終端,查看 Pod 的變化:
$ watch kubectl get po
然後部署版本2 應用:
$ kubectl apply -f app-v2-canary.yamldeployment.apps "my-app-v2" created
然後在 watch 終端頁面可以看到多了一個 Pod,現在一共 11 個 Pod,其中只有1 個 Pod 運行新版本應用,然後同樣可以循環訪問該應用,查看是否會有版本2 的應用信息:
$ while sleep 0.1; do curl http://127.0.0.1:30760; doneHost: my-app-v1-7b4874cd75-bhxbp, Version: v1.0.0 Host: my-app-v1-7b4874cd75-wmcqc, Version: v1.0.0 Host: my-app-v1-7b4874cd75-tsh2s, Version: v1.0.0 Host: my-app-v1-7b4874cd75-ml58j, Version: v1.0.0 Host: my-app-v1-7b4874cd75-spsdv, Version: v1.0.0 Host: my-app-v2-f885c8d45-mc2fx, Version: v2.0.0 ......
正常情況下可以看到大部分都是返回的版本1 的應用信息,偶爾會出現版本2 的應用信息,這就證明我們的金絲雀發佈成功了,待確認了版本2 的這個應用沒有任何問題後,可以將版本2 應用擴容到10 個副本:
$ kubectl scale --replicas=10 deploy my-app-v2deployment.extensions "my-app-v2" scaled
其實這個時候訪問應用的話新版本和舊版本的流量分配是1:1了,確認了版本2 正常後,就可以刪除版本1 的應用了:
$ kubectl delete deploy my-app-v1deployment.extensions "my-app-v1" deleted
最終留下的是 10 個新版本的 Pod 了,到這裏我們的整個金絲雀發佈就完成了。
同樣,最後,執行下面的命令刪除上面的資源對象:
$ kubectl delete all -l app=my-app
結論:
部分用戶獲取新版本
方便錯誤和性能監控
快速回滾
發佈較慢
流量精準控制很浪費(99%A / 1%B = 99 Pod A,1 Pod B)
如果你對新功能的發佈沒有信心,建議使用金絲雀發佈的策略。
A/B測試(A/B testing) - 最適合部分用戶的功能測試
A/B 測試實際上是一種基於統計信息而非部署策略來制定業務決策的技術,與業務結合非常緊密。但是它們也是相關的,也可以使用金絲雀發佈來實現。
除了基於權重在版本之間進行流量控制之外,A/B 測試還可以基於一些其他參數(比如 Cookie、User Agent、地區等等)來精確定位給定的用戶羣,該技術廣泛用於測試一些功能特性的效果,然後按照效果來進行確定。
我們經常可以在
今日頭條
的客戶端中就會發現有大量的 A/B 測試,同一個地區的用戶看到的客戶端有很大不同。
要使用這些細粒度的控制,仍然還是建議使用 Istio,可以根據權重或 HTTP 頭等來動態請求路由控制流量轉發。
ab test
下面是使用 Istio 進行規則設置的示例,因爲 Istio 還不太穩定,以下示例規則將來可能會更改:
route:- tags: version: v1.0.0 weight: 90- tags: version: v2.0.0 weight: 10
關於在 Istio 中具體如何做 A/B 測試,我們這裏就不再詳細介紹了,我們在istio-book
文檔中有相關的介紹。
ab test request
結論:
幾個版本並行運行
完全控制流量分配
特定的一個訪問錯誤難以排查,需要分佈式跟蹤
Kubernetes 沒有直接的支持,需要其他額外的工具
總結
發佈應用有許多種方法,當發佈到開發/測試環境的時候,重建
或者滾動更新
通常是一個不錯的選擇。在生產環境,滾動更新
或者藍綠髮布
比較合適,但是新版本的提前測試是非常有必要的。如果你對新版本的應用不是很有信心的話,那應該使用金絲雀
發佈,將用戶的影響降到最低。最後,如果你的公司需要在特定的用戶羣體中進行新功能的測試,例如,移動端用戶請求路由到版本 A,桌面端用戶請求路由到版本 B,那麼你就看使用A/B 測試
,通過使用 Kubernetes 服務網關的配置,可以根據某些請求參數來確定用戶應路由的服務。
如果您有任何問題或反饋,請隨時在下面留言。
本文主要參考鏈接:https://container-solutions.com/kubernetes-deployment-strategies/
作者:k8s技術圈
鏈接:https://www.jianshu.com/p/71e14c31cb82
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯繫作者獲得授權並註明出處。