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刪除的完整流程如下(以下引用自官方文檔):

這裡有兩個問題:

例如,當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-5874698012771.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.

 

參考文獻

Originally published at RTFM: Linux, DevOps and system administration.