Kubernetes: NGINX/PHP-FPM 502錯誤和優雅結束
- 2021 年 2 月 27 日
- 筆記
我們有一個運行在Kubernetes上的PHP應用,每個POD由兩個獨立的容器組成 – Nginx和PHP-FPM。
在我們對應用進行縮容時,遇到了502錯誤,例如,當一個POD在結束中時,POD裡面的容器無法正確關閉連接。
在這個博文中,讓我們深入看一下POD的結束流程,特別是Nginx和PHP-FPM容器。
本文中的測試是在AWS Kubernetes Service上使用Yandex.Tank工具進行。
使用AWS ALB Ingress Controller創建Ingress並自動創建AWS Application Load Balancer。
Kubernetes工作節點上使用Docker作為容器運行時。
Pod的生命周期之Pod的結束
首先,讓我們來看看pod結束的過程。
Pod其實是一組運行在Kubernetes工作節點上的進程,也受標準的IPC (Inter-Process Communication) 訊號控制的。
為了讓pod可以正常完成它的操作,容器運行時會先發送一個SIGTERM訊號(優雅結束)給每個容器內的PID 1進程(參考docker stop)。同時,集群會開始計時,在grace period計時結束後,會發送SIGKILL訊號直接殺掉pod。
在容器鏡像中,可以使用STOPSIGNAL重寫SIGTERM訊號。
Pod刪除的完整流程如下(以下引用自官方文檔):
- 當用戶通過kubectl delete或kubectl scale deployment命令觸發pod刪除時,集群會同時開始grace period的計時(默認30秒);
- API server會把pod的狀態從Running更新為Terminating(參考Container states)。Pod所在工作節點上的kubelet接收到pod狀態變化後,開始了pod結束流程;如果pod裡面有容器配置了
preStop
hook,kubelet會執行它。假如30秒的grace period結束時,preStop hook還在執行,grace period會自動延長2秒鐘。Grace period可以通過terminationGracePeriodSeconds配置。 - 當preStop hook完成時,kubelet會通知Docker運行時停止pod內的所有容器。Docker守護進程會發送SIGTERM訊號給容器內的PID 1進程。所有容器收到訊號的順序是隨機的。
- 在優雅結束開始的同時,kube-controller-manager會把pod從endpoints(參考Kubernetes – Endpoints)中移除,此時Service會停止往這個pod轉發流量;
- 在grace period計時結束後,kubelet會強制停止容器 – Docker會發送SIGKILL訊號給pod裡面所有容器內的所有進程,此時進程不再有機會正常完成它們的操作,而是會被直接結束;
kubelet
triggers deletion of the pod from the API server- Kubelet發送刪除pod的請求給API server;
- API server 把pod對應的記錄從etcd中刪除。
這裡有兩個問題:
- Nginx和PHP-FPM把SIGTERM訊號當作強制結束訊號,並且會立刻結束進程,不再處理當前的連接而是立即關閉(參考 Controlling nginx 和 php-fpm(8) – Linux man page)
- 第2和第3步,也就是發送SIGTERM訊號和移除endpoint是同時進行的,但實際上Ingress Controller可能沒那麼快就能夠更新endpoints的數據,pod被kill掉時,ingress可能還在往pod轉發流量,此時就會導致502錯誤的發生
例如,當Nginx主進程正在fast shutdown時,我們往nginx發送一個連接請求,nginx會直接丟棄這個連接請求,而我們的客戶端則會接收到一個502錯誤,參考Avoiding dropped connections in nginx containers with 「STOPSIGNAL SIGQUIT」。
NGINX STOPSIGNAL
和 502
好了,現在我們已經有了大概的了解,讓我們開始來重現第一個問題。
以下的例子參考了上面的文檔,並部署到kubernetes集群中。
準備好Dockerfile:
FROM nginx RUN echo 'server {\n\ listen 80 default_server;\n\ location / {\n\ proxy_pass http://httpbin.org/delay/10;\n\ }\n\ }' > /etc/nginx/conf.d/default.conf CMD ["nginx", "-g", "daemon off;"]
在這裡,nginx會把請求轉發給//httpbin.org並延遲10秒鐘以模仿後端PHP應用。
構建一個鏡像並推送到鏡像倉庫:
$ docker build -t setevoy/nginx-sigterm .
$ docker push setevoy/nginx-sigterm
現在,用這個鏡像部署一個有10給實例的Deployment。
下面這個清單包括了Namespace, Service和Ingress,在接下來的測試中不再重複,只會提及需要更新的部分。
--- apiVersion: v1 kind: Namespace metadata: name: test-namespace --- apiVersion: apps/v1 kind: Deployment metadata: name: test-deployment namespace: test-namespace labels: app: test spec: replicas: 10 selector: matchLabels: app: test template: metadata: labels: app: test spec: containers: - name: web image: setevoy/nginx-sigterm ports: - containerPort: 80 resources: requests: cpu: 100m memory: 100Mi readinessProbe: tcpSocket: port: 80 --- apiVersion: v1 kind: Service metadata: name: test-svc namespace: test-namespace spec: type: NodePort selector: app: test ports: - protocol: TCP port: 80 targetPort: 80 --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: test-ingress namespace: test-namespace annotations: kubernetes.io/ingress.class: alb alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]' spec: rules: - http: paths: - backend: serviceName: test-svc servicePort: 80
部署:
$ kubectl apply -f test-deployment.yaml namespace/test-namespace created deployment.apps/test-deployment created service/test-svc created ingress.extensions/test-ingress created
檢查Ingress:
$ curl -I aadca942-testnamespace-tes-5874–698012771.us-east-2.elb.amazonaws.com HTTP/1.1 200 OK
現在有10個 pods在運行:
$ kubectl -n test-namespace get pod NAME READY STATUS RESTARTS AGE test-deployment-ccb7ff8b6–2d6gn 1/1 Running 0 26s test-deployment-ccb7ff8b6–4scxc 1/1 Running 0 35s test-deployment-ccb7ff8b6–8b2cj 1/1 Running 0 35s test-deployment-ccb7ff8b6-bvzgz 1/1 Running 0 35s test-deployment-ccb7ff8b6-db6jj 1/1 Running 0 35s test-deployment-ccb7ff8b6-h9zsm 1/1 Running 0 20s test-deployment-ccb7ff8b6-n5rhz 1/1 Running 0 23s test-deployment-ccb7ff8b6-smpjd 1/1 Running 0 23s test-deployment-ccb7ff8b6-x5dc2 1/1 Running 0 35s test-deployment-ccb7ff8b6-zlqxs 1/1 Running 0 25s
為Yandex.Tank準備好load.yaml:
phantom: address: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com header_http: "1.1" headers: - "[Host: aadca942-testnamespace-tes-5874-698012771.us-east-2.elb.amazonaws.com]" uris: - / load_profile: load_type: rps schedule: const(100,30m) ssl: false console: enabled: true telegraf: enabled: false package: yandextank.plugins.Telegraf config: monitoring.xml
這裡,我們會以每秒一次的速率請求Ingress後端的pods。
開始測試:
到目前為止一切正常。
現在,把Deployment縮容到一個實例:
$ kubectl -n test-namespace scale deploy test-deployment — replicas=1 deployment.apps/test-deployment scaled
Pods狀態變成Terminating:
$ kubectl -n test-namespace get pod NAME READY STATUS RESTARTS AGE test-deployment-647ddf455–67gv8 1/1 Terminating 0 4m15s test-deployment-647ddf455–6wmcq 1/1 Terminating 0 4m15s test-deployment-647ddf455-cjvj6 1/1 Terminating 0 4m15s test-deployment-647ddf455-dh7pc 1/1 Terminating 0 4m15s test-deployment-647ddf455-dvh7g 1/1 Terminating 0 4m15s test-deployment-647ddf455-gpwc6 1/1 Terminating 0 4m15s test-deployment-647ddf455-nbgkn 1/1 Terminating 0 4m15s test-deployment-647ddf455-tm27p 1/1 Running 0 26m …
此時,我們收到了502報錯:
現在,我們更新一下Dockerfile – 添加STOPSIGNAL SIGQUIT:
FROM nginx RUN echo 'server {\n\ listen 80 default_server;\n\ location / {\n\ proxy_pass http://httpbin.org/delay/10;\n\ }\n\ }' > /etc/nginx/conf.d/default.conf STOPSIGNAL SIGQUIT CMD ["nginx", "-g", "daemon off;"]
構建並推送鏡像:
$ docker build -t setevoy/nginx-sigquit .
docker push setevoy/nginx-sigquit
更新Deployment中的鏡像:
... spec: containers: - name: web image: setevoy/nginx-sigquit ports: - containerPort: 80 ...
重新部署並測試:
再次對deployment進行縮容:
$ kubectl -n test-namespace scale deploy test-deployment — replicas=1 deployment.apps/test-deployment scaled
這次不再報錯:
Traffic, preStop
, 和 sleep
但其實,如果我們重複測試的話,有時還是會有502錯誤:
這時,我們應該是遇到了第二個問題 – endpoints更新和SIGTERM同步發生的問題。
讓我們加一個sleep的preStop hook,在集群接收到停止pod的請求之後,kubelet會先等待5秒鐘後才發送SIGTERM訊號,留一些冗餘時間以更新endpoints和Ingress。
... spec: containers: - name: web image: setevoy/nginx-sigquit ports: - containerPort: 80 lifecycle: preStop: exec: command: ["/bin/sleep","5"] ...
重新測試,這一次不再有錯誤了。
我們的PHP-FPM沒有這個問題,因為它原本的鏡像就已經添加了 STOPSIGNAL SIGQUIT。
其它可能的解決方案
當然,在調試期間我嘗試了一些其它的方法。
具體請參考本文最後的參考文獻,這裡我只做簡單的介紹。
preStop
和 nginx -s quit
其中一個方法就是在preStop hook中發送QUIT訊號給Nginx:
lifecycle: preStop: exec: command: - /usr/sbin/nginx - -s - quit
或:
... lifecycle: preStop: exec: command: - /bin/sh - -SIGQUIT - 1 ....
然並卵。雖然這個主意(在kubelet/docker發送TERM訊號之情,先發送QUIT訊號給nginx進程優雅結束)看上去沒什麼問題,但不知為啥不行。
你可以嘗試通過strace看看nginx是否真的接收到QUIT訊號了。
NGINX + PHP-FPM, supervisord
, 和 stopsignal
我們這個應用是在一個pod裡面運行兩個容器,我也嘗試過使用單個容器運行Nginx + PHP-FPM,例如 trafex/alpine-nginx-php7。
使用這個鏡像並在supervisor.conf文件中給Nginx和PHP-FPM配置stopsignal sigquit,雖然想法看起來是對的,結果也是不行。
有興趣的朋友可以試試。
PHP-FPM, 和 process_control_timeout
在 Graceful shutdown in Kubernetes is not always trivial 和 Stackoveflow 上的 Nginx / PHP FPM graceful stop (SIGQUIT): not so graceful 中提到,FPM』s master 進程先於子進程被殺也會導致502錯誤。
雖然這不是我們討論的問題,但你可以關注 process_control_timeout
.
NGINX, HTTP, 和keep-alive session
還有,在http頭中加入 [Connection: close]
也是個不錯的主意,這樣子客戶端就會在一個請求完成後關閉連接,從而減少502的發生。
但始終還是不能完全避免nginx在處理請求時接收到SIGTERM導致的問題。
參考 HTTP persistent connection.
參考文獻
- Graceful shutdown in Kubernetes is not always trivial (перевод на Хабре)
- Gracefully Shutting Down Pods in a Kubernetes Cluster — the
nginx -s quit
in thepreStop
solution, also there is a good description of the issue with the traffic being sent to terminated pods - Kubernetes best practices: terminating with grace
- Termination of Pods
- Kubernetes』 dirty endpoint secret and Ingress
- Avoiding dropped connections in nginx containers with 「STOPSIGNAL SIGQUIT」 — actually, here I』ve found our solution plus an idea of how to reproduce it
Originally published at RTFM: Linux, DevOps and system administration.