kubernetes系列:服務外部訪問集中管理組件-ingress-nginx


介紹

原文鏈接k8s技術圈-陽明

在Kubernetes中,服務和Pod的IP地址僅可以在集群網絡內部使用,對於集群外的應用是不可見的。為了使外部的應用能夠訪問集群內的服務,Kubernetes目前提供了以下幾種方案:

  • NodePort
  • LoadBalancer
  • Ingress

Ingress只是Kubernetes中的一個普通資源對象,需要一個對應的Ingress Controller來解析 Ingress 的規則,暴露服務到外部,如ingress-nginx。
ingress-nginx和traefik都是熱門的ingress-controller。

相對於traefik來說,nginx-ingress性能更加優秀,但配置比 traefik 復雜,當然功能也要強大一些,支持的功能多。

官網 有一些常見的ingress controller。

Ingress工作原理

Ingress其實就是從kubernets集群外部去訪問集群的一個統一管理入口,它會將外部請求轉發到集群內不同service的endpoint列表中的pod

ingress controller監聽kube-apiserver,實時感知后端service、pod的變化。得到的信息變化后,ingress controller再結合ingress的配置,更新反向代理負載均衡器,達到服務發現的作用。

這個邏輯和consul非常類似。

部署nginx Ingress

Ingress組成

  • ingress controller:將新加入的Ingress轉化成Nginx的配置文件並使之生效。
  • ingress服務:將Nginx的配置抽象成一個Ingress對象,每添加一個新的服務只需寫一個新的Ingress的yaml文件,然后應用到kubernetes集群。

要使用Ingress對外暴露服務,就需要提前安裝一個Ingress Controller。

生產環境使用LB + DaemonSet hostNetwork模式需要修改values.yaml

  • .Values.kind設置為DaemonSet,生產環境使用 LB + DaemonSet hostNetwork 模式
  • .Values.hostNetwork設置為true,表示ingress-nginx這個pod啟動的端口直接在ingress nginx DaemonSet運行的節點上開啟。
    比如DaemonSet啟動在nodeSelector設置為http-endpoint:here的k8s-node1,k8s-node2兩個節點上運行,那只會在這兩個節點開啟80,443端口。而不會在k8s-node3上啟動
  • .Values.publishService.enabled設置為false,hostNetwork 模式下設置為false,通過節點IP地址上報ingress status數據
  • .Values.controller.digest,鏡像手動上傳的,這行一定要注釋掉。否則一直提示找不到拉取不到鏡像
  • .Values.dnsPolicy設置為ClusterFirstWithHostNet,如果pod工作在主機網絡,效率更高
  • .Values.defaultbackend.enabled設置為true,默認后端pod
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm fetch ingress-nginx/ingress-nginx
tar -xvf ingress-nginx-3.23.0.tgz
kubectl create ns ingress-nginx
helm install --debug --namespace  ingress-nginx   ingress-nginx  ingress-nginx

注意:

  • 默認配置監聽所有命名空間的ingress對象。--watch-namespace 可以限制監聽的namespace
  • 如果多個Ingresses定義了同一個host的不同路徑,ingress控制器會合並這些規則
  • 如果使用的是GKE,則需要使用以下命令將用戶初始化為cluster-admin: console kubectl create clusterrolebinding cluster-admin-binding \ --clusterrole cluster-admin \ --user $(gcloud config get-value account)

線上環境為了保證高可用,需要運行多個nginx-ingress實例,然后用一個nginx/haproxy 作為入口,通過keepalived來訪問邊緣節點(集群內部用來向集群外暴露服務能力的節點)的vip地址

檢查

# pod運行狀態,可以看到這兩個pod的IP直接就是node節點的IP
[root@k8s-master1 ingress-nginx]# kubectl get pods -n ingress-nginx -o wide

NAME                                           READY   STATUS    RESTARTS   AGE   IP              NODE        NOMINATED NODE   READINESS GATES
ingress-nginx-controller-cgf99                 1/1     Running   0          16m   192.168.1.120   k8s-node1   <none>           <none>
ingress-nginx-controller-krgkp                 1/1     Running   0          16m   192.168.1.121   k8s-node2   <none>           <none>
ingress-nginx-defaultbackend-cb7bcf6d7-hkmhs   1/1     Running   0          16m   100.2.4.26      k8s-node2   <none>           <none>

#service
[root@k8s-master1 ingress-nginx]# kubectl get svc -n ingress-nginx 

NAME                                 TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
ingress-nginx-controller-admission   ClusterIP   172.16.206.255   <none>        443/TCP   28m
ingress-nginx-defaultbackend         ClusterIP   172.16.81.103    <none>        80/TCP    28m

# nginx ingress controller 日志
[root@k8s-master1 ingress-nginx]# kubectl logs  -n  ingress-nginx  ingress-nginx-controller-cgf99 

-------------------------------------------------------------------------------
NGINX Ingress controller
  Release:       v0.44.0
  Build:         f802554ccfadf828f7eb6d3f9a9333686706d613
  Repository:    https://github.com/kubernetes/ingress-nginx
  nginx version: nginx/1.19.6

-------------------------------------------------------------------------------

創建一個簡單的nginx應用的ingress資源

cat > test-app-nginx.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-nginx
spec:
  selector:
    matchLabels:
      app: test-nginx
  template:
    metadata:
      labels:
        app: test-nginx
    spec:
      containers:
      - name: test-nginx
        image: nginx
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: test-nginx
  labels:
    app: test-nginx
spec:
  ports:
  - port: 80
    protocol: TCP
    name: http
  selector:
    app: test-nginx
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: test-nginx
  annotations:
    kubernetes.io/ingress.class: "nginx"  #指定這個ingress資源用ingress-nginx來處理
spec:
  rules:
  - host: erbiao.com  # 將域名映射到 test-nginx 服務
    http:
      paths:
      - path: /
        backend:
          serviceName: test-nginx  # 將所有請求發送到 test-nginx 服務的 80 端口
          servicePort: 80     # 不過需要注意大部分Ingress controller都不是直接轉發到Service
                            # 而是只是通過Service來獲取后端的Endpoints列表,直接轉發到Pod,這樣可以減少網絡跳轉,提高性能
EOF

kubectl create ns test
kubectl apply -f test-app-nginx.yaml -n test

資源創建成功后將域名erbiao.com解析到ingress-nginx所在邊緣節點。就可以通過域名訪問。

請求流程:客戶端解析域名erbiao.com得到邊緣節點IP,然后向節點上的Ingress Controller發送http請求。依據Ingress對象里的描述匹配域名,找到對應的service對象,獲取關聯的endpoint列表,最后將客戶端請求轉發給其中某個pod。

在ingress-nginx-controller的pod的nginx.conf配置中,生成了nginx配置段

  ## start server erbiao.com
    server {
        server_name erbiao.com ;
        
        listen 80  ;
        listen 443  ssl http2 ;
        
        set $proxy_upstream_name "-";
        
        ssl_certificate_by_lua_block {
            certificate.call()
        }
        
        location / {
            
            set $namespace      "test";
            set $ingress_name   "test-nginx";
            set $service_name   "test-nginx";
            set $service_port   "80";
            set $location_path  "/";
            set $global_rate_limit_exceeding n;
            
            rewrite_by_lua_block {
                lua_ingress.rewrite({
                    force_ssl_redirect = false,
                    ssl_redirect = true,
                    force_no_ssl_redirect = false,
                    use_port_in_redirects = false,
                global_throttle = { namespace = "", limit = 0, window_size = 0, key = { }, ignored_cidrs = { } },
                })
                balancer.rewrite()
                plugins.run()
            }
            
            # be careful with `access_by_lua_block` and `satisfy any` directives as satisfy any
            # will always succeed when there's `access_by_lua_block` that does not have any lua code doing `ngx.exit(ngx.DECLINED)`
            # other authentication method such as basic auth or external auth useless - all requests will be allowed.
            #access_by_lua_block {
            #}
            
            header_filter_by_lua_block {
                lua_ingress.header()
                plugins.run()
            }
            
            body_filter_by_lua_block {
                plugins.run()
            }
            
            log_by_lua_block {
                balancer.log()
                
                monitor.call()
                
                plugins.run()
            }
            
            port_in_redirect off;
            
            set $balancer_ewma_score -1;
            set $proxy_upstream_name "test-test-nginx-80";
            set $proxy_host          $proxy_upstream_name;
            set $pass_access_scheme  $scheme;
            
            set $pass_server_port    $server_port;
            
            set $best_http_host      $http_host;
            set $pass_port           $pass_server_port;
            
            set $proxy_alternative_upstream_name "";
            
            client_max_body_size                    1m;
            
            proxy_set_header Host                   $best_http_host;
            
            # Pass the extracted client certificate to the backend
            
            # Allow websocket connections
            proxy_set_header                        Upgrade           $http_upgrade;
            
            proxy_set_header                        Connection        $connection_upgrade;
            
            proxy_set_header X-Request-ID           $req_id;
            proxy_set_header X-Real-IP              $remote_addr;
            
            proxy_set_header X-Forwarded-For        $remote_addr;
            
            proxy_set_header X-Forwarded-Host       $best_http_host;
            proxy_set_header X-Forwarded-Port       $pass_port;
            proxy_set_header X-Forwarded-Proto      $pass_access_scheme;
            
            proxy_set_header X-Scheme               $pass_access_scheme;
            
            # Pass the original X-Forwarded-For
            proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
            
            # mitigate HTTPoxy Vulnerability
            # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
            proxy_set_header Proxy                  "";
            
            # Custom headers to proxied server
            
            proxy_connect_timeout                   5s;
            proxy_send_timeout                      60s;
            proxy_read_timeout                      60s;
            
            proxy_buffering                         off;
            proxy_buffer_size                       4k;
            proxy_buffers                           4 4k;
            
            proxy_max_temp_file_size                1024m;
            
            proxy_request_buffering                 on;
            proxy_http_version                      1.1;
            
            proxy_cookie_domain                     off;
            proxy_cookie_path                       off;
            
            # In case of errors try the next upstream server before returning an error
            proxy_next_upstream                     error timeout;
            proxy_next_upstream_timeout             0;
            proxy_next_upstream_tries               3;
            
            proxy_pass http://upstream_balancer;
            
            proxy_redirect                          off;
            
        }
        
    }
    ## end server erbiao.com

URL Rewrite功能

NGINX Ingress Controller很多高級的用法可以通過Ingress對象的annotation進行配置,如URL Rewrite功能。

比如一個 todo 的前端應用。

kubectl apply -f https://github.com/cnych/todo-app/raw/master/k8s/mongo.yaml
kubectl apply -f https://github.com/cnych/todo-app/raw/master/k8s/web.yaml

對應的Ingress資源對象:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
  - host: erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: todo
          servicePort: 3000

部署,解析域名后就可正常訪問到。

現在針對URL路徑做一個rewrite:在URI中添加一個app的前綴。做法就是在annotations中添加rewrite-target的注解。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo
  namespace: default
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  rules:
  - host: erbiao.me
    http:
      paths:
      - backend:
          serviceName: todo
          servicePort: 3000
        path: /app(/|$)(.*)

github中還有其他annotations的介紹

更新后再訪問就需要加上/app這個URI了

image

可看到靜態資源在/stylesheets路徑下,做了URL Rewrite后,要正常訪問那也需要加上前綴http://erbiao.me/app/stylesheets/screen.css。對於圖片或者其他靜態資源也是如此,當然去更改頁面引入靜態資源的方式為相對路徑也是可以的,但畢竟要修改代碼,此時可借助ingress-nginx 中的configuration-snippet來對靜態資源做一次跳轉。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo
  namespace: default
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/configuration-snippet: |
      rewrite ^/stylesheets/(.*)$ /app/stylesheets/$1 redirect;  # 添加 /app 前綴
      rewrite ^/images/(.*)$ /app/images/$1 redirect;  # 添加 /app 前綴
spec:
  rules:
  - host: erbiao.me
    http:
      paths:
      - backend:
          serviceName: todo
          servicePort: 3000
        path: /app(/|$)(.*)

現在訪問主域名erbiao.me還是404,要解決此問題可設置app-root的注解,如此當訪問主域名時會自動跳轉到指定的app-root資源下。即訪問http://erbiao.me會自動跳轉到http://erbiao.me/app。但是還有一個問題是path 路徑其實也匹配了/app 這樣的路徑,可能我們更加希望應用在最后添加一個 / 這樣的slash,同樣,可通過configuration-snippet配置來完成。更新后應用訪問地址就都會以/樣的slash結尾了

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo
  namespace: default
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/app-root: /app/
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/configuration-snippet: |
      rewrite ^(/app)$ $1/ redirect;
      rewrite ^/stylesheets/(.*)$ /app/stylesheets/$1 redirect;
      rewrite ^/images/(.*)$ /app/images/$1 redirect;
spec:
  rules:
  - host: erbiao.me
    http:
      paths:
      - backend:
          serviceName: todo
          servicePort: 3000
        path: /app(/|$)(.*)

Basic Auth

在 Ingress Controller上面可配置一些基本的認證,如Basic Auth,可用 htpasswd生成一個密碼文件來驗證身份驗證。

# htpasswd 生成一個密碼文件
[root@k8s-master1 test]# htpasswd -c auth erbiao
New password: 
Re-type new password: 
Adding password for user erbiao

#創建依據生成的auth文件創建secret對象。
#創建的secret一定要和應用是在同一命名空間,否則會報503
[root@k8s-master1 test]# kubectl create secret generic basic-auth --from-file=auth

對上述todo應用做個認證

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/auth-type: basic #認證類型 
    nginx.ingress.kubernetes.io/auth-secret: basic-auth #包含 user/password 定義的 secret 對象名
    nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required - erbiao' #要顯示的帶有適當上下文的消息,說明需要身份驗證的原因
spec:
  rules:
  - host: erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: todo
          servicePort: 3000

NGINX Ingress Controller 還支持一些其他高級的認證,比如OAUTH認證之類的

灰度發布

在日常工作中經常需要對服務進行版本更新升級,所以經常會使用到滾動升級藍綠發布灰度發布等不同的發布操作。而ingress-nginx支持通過Annotations配置來實現不同場景下的灰度發布和測試,可以滿足金絲雀發布、藍綠部署與 A/B 測試等業務場景。ingress-nginx的Annotation支持以下幾種Canary規則:

  • nginx.ingress.kubernetes.io/canary-by-header基於 Request Header 的流量切分,適用於灰度發布以及 A/B 測試。當 Request Header 設置為 always 時,請求將會被一直發送到 Canary 版本;當 Request Header 設置為 never時,請求不會被發送到 Canary 入口;對於任何其他 Header 值,將忽略 Header,並通過優先級將請求與其他金絲雀規則進行優先級的比較。
  • nginx.ingress.kubernetes.io/canary-by-header-value要匹配的 Request Header 的值,用於通知 Ingress 將請求路由到 Canary Ingress 中指定的服務。當 Request Header 設置為此值時,它將被路由到 Canary 入口。該規則允許用戶自定義 Request Header 的值,必須與上一個 annotation (即:canary-by-header) 一起使用。
  • nginx.ingress.kubernetes.io/canary-by-header-pattern:與上面的canary-by-header-value類似,唯一的區別是它是用正則表達式(PCRE Regex matching)對來匹配請求頭的值,而不是只固定某一個值。注意:當與canary-by-header-value同時存在,注解canary-by-header-pattern將被忽略。當指定的正則表達式在請求處理過程中導致錯誤時,該請求將被視為不匹配
  • nginx.ingress.kubernetes.io/canary-weight基於服務權重的流量切分,適用於藍綠部署,權重范圍 0 - 100 按百分比將請求路由到 Canary Ingress 中指定的服務。權重為 0 意味着該金絲雀規則不會向 Canary 入口的服務發送任何請求,權重為 100 意味着所有請求都將被發送到 Canary 入口。
  • nginx.ingress.kubernetes.io/canary-by-cookie基於 cookie 的流量切分,適用於灰度發布與 A/B 測試。用於通知 Ingress 將請求路由到 Canary Ingress 中指定的服務的cookie。當 cookie 值設置為 always 時,它將被路由到 Canary 入口;當 cookie 值設置為 never 時,請求不會被發送到 Canary 入口;對於任何其他值,將忽略 cookie 並將請求與其他金絲雀規則進行優先級的比較。

需要注意的是金絲雀規則按優先順序進行排序:canary-by-header - > canary-by-cookie - > canary-weight

當Ingress被標記為Canary Ingress,除nginx.ingress.kubernetes.io/load-balancenginx.ingress.kubernetes.io/upstream-hash-by外,所有其他非Canary注解都被忽略。

已知局限性:每個Ingress規則最多可以應用一個Canary Ingress

總的來說可以把以上 annotation 規則划分為以下兩類:

  • 基於權重的 Canary 規則

  • 基於用戶請求的 Canary 規則

下面通過一個示例應用來對灰度發布功能進行說明。

1. 部署一個production版本的應用

cat > production.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: production
  labels:
    app: production
spec:
  selector:
    matchLabels:
      app: production
  template:
    metadata:
      labels:
        app: production
    spec:
      containers:
      - name: production
        image: cnych/echoserver
        ports:
        - containerPort: 8080
        env:
          - name: NODE_NAME
            valueFrom:
              fieldRef:
                fieldPath: spec.nodeName
          - name: POD_NAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name
          - name: POD_NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace
          - name: POD_IP
            valueFrom:
              fieldRef:
                fieldPath: status.podIP
---
apiVersion: v1
kind: Service
metadata:
  name: production
  labels:
    app: production
spec:
  ports:
  - port: 80
    targetPort: 8080
    name: http
  selector:
    app: production
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: production
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - host: echo.erbiao.me
    http:
      paths:
      - backend:
          serviceName: production
          servicePort: 80
EOF
kubectl apply -f production.yaml

應用部署成功后,將域名echo.erbiao.me解析到邊緣節點即可訪問:

Hostname: production-856d5fb99-zvbd2

Pod Information:
	node name:	k8s-node1
	pod name:	production-856d5fb99-zvbd2
	pod namespace:	default
	pod IP:	100.2.3.39

Server values:
	server_version=nginx: 1.13.3 - lua: 10008

Request Information:
	client_address=100.2.4.0
	method=GET
	real path=/
	query=
	request_version=1.1
	request_scheme=http
	request_uri=http://echo.erbiao.me:8080/

Request Headers:
	accept=*/*
	host=echo.erbiao.me
	user-agent=curl/7.29.0
	x-forwarded-for=192.168.1.105
	x-forwarded-host=echo.erbiao.me
	x-forwarded-port=80
	x-forwarded-proto=http
	x-real-ip=192.168.1.105
	x-request-id=20a18db31ea6693a0207e5fc73a14baf
	x-scheme=http

Request Body:
	-no body in request-

2. 依據上述版本,創建Canary版本的應用

cat > canary.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: canary
  labels:
    app: canary
spec:
  selector:
    matchLabels:
      app: canary
  template:
    metadata:
      labels:
        app: canary
    spec:
      containers:
      - name: canary
        image: cnych/echoserver
        ports:
        - containerPort: 8080
        env:
          - name: NODE_NAME
            valueFrom:
              fieldRef:
                fieldPath: spec.nodeName
          - name: POD_NAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name
          - name: POD_NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace
          - name: POD_IP
            valueFrom:
              fieldRef:
                fieldPath: status.podIP
---
apiVersion: v1
kind: Service
metadata:
  name: canary
  labels:
    app: canary
spec:
  ports:
  - port: 80
    targetPort: 8080
    name: http
  selector:
    app: canary
EOF
kubectl apply -f canary.yaml

3.Annotation 規則配置

  1. 基於權重基於權重的流量切分的典型應用場景就是藍綠部署,可通過將權重設置為 0 或 100 來實現

可將 Green 版本設置為主要部分,並將 Blue 版本的入口配置為 Canary。最初,將權重設置為 0,因此不會將流量代理到 Blue 版本。一旦新版本測試和驗證都成功后,即可將 Blue 版本的權重設置為 100,即所有流量從 Green 版本轉向 Blue。

創建一個基於權重的 Canary 版本的應用路由 Ingress 對象。

cat > canary-ingress.yaml << EOF
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: canary
  annotations:
    kubernetes.io/ingress.class: nginx 
    nginx.ingress.kubernetes.io/canary: "true"   # 要開啟灰度發布機制,首先需要啟用 Canary
    nginx.ingress.kubernetes.io/canary-weight: "30"  # 分配30%流量到當前Canary版本
spec:
  rules:
  - host: echo.erbiao.me
    http:
      paths:
      - backend:
          serviceName: canary
          servicePort: 80
EOF

kubectl apply -f canary-ingress.yaml

Canary版本應用創建成功后,在命令行終端中來不斷訪問這個應用,觀察 Hostname 變化。Canary版本應用分配了30%左右權重的流量,符合我們的預期。:

[root@k8s-master1 ~]# for i in $(seq 1 20); do echo -n "count:$i ," && curl -s echo.erbiao.me | grep "Hostname"   ; done

count:1 ,Hostname: canary-66cb497b7f-pnqq9
count:2 ,Hostname: canary-66cb497b7f-pnqq9
count:3 ,Hostname: production-856d5fb99-zvbd2
count:4 ,Hostname: production-856d5fb99-zvbd2
count:5 ,Hostname: canary-66cb497b7f-pnqq9
count:6 ,Hostname: production-856d5fb99-zvbd2
count:7 ,Hostname: production-856d5fb99-zvbd2
count:8 ,Hostname: production-856d5fb99-zvbd2
count:9 ,Hostname: canary-66cb497b7f-pnqq9
count:10 ,Hostname: production-856d5fb99-zvbd2
count:11 ,Hostname: canary-66cb497b7f-pnqq9
count:12 ,Hostname: production-856d5fb99-zvbd2
count:13 ,Hostname: production-856d5fb99-zvbd2
count:14 ,Hostname: production-856d5fb99-zvbd2
count:15 ,Hostname: production-856d5fb99-zvbd2
count:16 ,Hostname: canary-66cb497b7f-pnqq9
count:17 ,Hostname: canary-66cb497b7f-pnqq9
count:18 ,Hostname: production-856d5fb99-zvbd2
count:19 ,Hostname: canary-66cb497b7f-pnqq9
count:20 ,Hostname: production-856d5fb99-zvbd2
  1. Request Header基於Request Header進行流量切分的典型應用場景即灰度發布或A/B測試場景

在上面canary版本的Ingress對象中新增一條annotation配置nginx.ingress.kubernetes.io/canary-by-header: canary(value可以是任意值)。使當前的 Ingress 實現基於 Request Header 進行流量切分,由於canary-by-header 的優先級大於canary-weight,所以會忽略原有的canary-weight的規則。

annotations:
  kubernetes.io/ingress.class: nginx 
  nginx.ingress.kubernetes.io/canary: "true"   # 要開啟灰度發布機制,首先需要啟用 Canary
  nginx.ingress.kubernetes.io/canary-by-header: canary  # 基於header的流量切分
  nginx.ingress.kubernetes.io/canary-weight: "30"  # 會被忽略,因為配置了 canary-by-headerCanary版本

更新上面的 Ingress 資源對象后,在請求中加入不同的 Header值,再次訪問應用的域名。

注意:當 Request Header 設置為 never 或 always 時,請求將不會或一直被發送到 Canary 版本,對於任何其他 Header 值,將忽略 Header,並通過優先級將請求與其他 Canary 規則進行優先級的比較

#請求時設置了 canary: never 這個 Header 值,所以請求沒有發送到 Canary 應用中去。

[root@k8s-master1 ~]# for i in $(seq 1 20); do echo -n "count:$i ," && curl -s -H "canary: never" echo.erbiao.me | grep "Hostname"   ; done

count:1 ,Hostname: production-856d5fb99-zvbd2
count:2 ,Hostname: production-856d5fb99-zvbd2
count:3 ,Hostname: production-856d5fb99-zvbd2
count:4 ,Hostname: production-856d5fb99-zvbd2
count:5 ,Hostname: production-856d5fb99-zvbd2
count:6 ,Hostname: production-856d5fb99-zvbd2
count:7 ,Hostname: production-856d5fb99-zvbd2
count:8 ,Hostname: production-856d5fb99-zvbd2
count:9 ,Hostname: production-856d5fb99-zvbd2
count:10 ,Hostname: production-856d5fb99-zvbd2
count:11 ,Hostname: production-856d5fb99-zvbd2
count:12 ,Hostname: production-856d5fb99-zvbd2
count:13 ,Hostname: production-856d5fb99-zvbd2
count:14 ,Hostname: production-856d5fb99-zvbd2
count:15 ,Hostname: production-856d5fb99-zvbd2
count:16 ,Hostname: production-856d5fb99-zvbd2
count:17 ,Hostname: production-856d5fb99-zvbd2
count:18 ,Hostname: production-856d5fb99-zvbd2
count:19 ,Hostname: production-856d5fb99-zvbd2
count:20 ,Hostname: production-856d5fb99-zvbd2
#請求設置的 Header 值為 canary: other-value(不匹配never或always),所以 ingress-nginx 會通過優先級將請求與其他 Canary 規則進行優先級的比較,我們這里也就會進入 canary-weight: "30" 這個規則去。

[root@k8s-master1 ~]# for i in $(seq 1 20); do echo -n "count:$i ," && curl -s -H "canary: other-value" echo.erbiao.me | grep "Hostname"   ; done
count:1 ,Hostname: production-856d5fb99-zvbd2
count:2 ,Hostname: canary-66cb497b7f-pnqq9
count:3 ,Hostname: canary-66cb497b7f-pnqq9
count:4 ,Hostname: production-856d5fb99-zvbd2
count:5 ,Hostname: production-856d5fb99-zvbd2
count:6 ,Hostname: production-856d5fb99-zvbd2
count:7 ,Hostname: production-856d5fb99-zvbd2
count:8 ,Hostname: production-856d5fb99-zvbd2
count:9 ,Hostname: production-856d5fb99-zvbd2
count:10 ,Hostname: production-856d5fb99-zvbd2
count:11 ,Hostname: production-856d5fb99-zvbd2
count:12 ,Hostname: canary-66cb497b7f-pnqq9
count:13 ,Hostname: canary-66cb497b7f-pnqq9
count:14 ,Hostname: production-856d5fb99-zvbd2
count:15 ,Hostname: canary-66cb497b7f-pnqq9
count:16 ,Hostname: canary-66cb497b7f-pnqq9
count:17 ,Hostname: production-856d5fb99-zvbd2
count:18 ,Hostname: production-856d5fb99-zvbd2
count:19 ,Hostname: production-856d5fb99-zvbd2
count:20 ,Hostname: canary-66cb497b7f-pnqq9

在上述ingress對象的基礎上,再添加nginx.ingress.kubernetes.io/canary-by-header-value: user-value注解,結合nginx.ingress.kubernetes.io/canary-by-header就可以將請求路由到canary版本的服務中。

annotations:
  kubernetes.io/ingress.class: nginx 
  nginx.ingress.kubernetes.io/canary: "true"   # 要開啟灰度發布機制,首先需要啟用 Canary
  nginx.ingress.kubernetes.io/canary-by-header-value: user-value  
  nginx.ingress.kubernetes.io/canary-by-header: canary  # 基於header的流量切分
  nginx.ingress.kubernetes.io/canary-weight: "30"  # 分配30%流量到當前Canary版本
# 請求時設置canary: user-value,所有請求都會被路由到canary版本

[root@k8s-master1 ~]# for i in $(seq 1 20); do echo -n "count:$i ," && curl -s -H "canary: user-value" echo.erbiao.me | grep "Hostname"   ; done
count:1 ,Hostname: canary-66cb497b7f-pnqq9
count:2 ,Hostname: canary-66cb497b7f-pnqq9
count:3 ,Hostname: canary-66cb497b7f-pnqq9
count:4 ,Hostname: canary-66cb497b7f-pnqq9
count:5 ,Hostname: canary-66cb497b7f-pnqq9
count:6 ,Hostname: canary-66cb497b7f-pnqq9
count:7 ,Hostname: canary-66cb497b7f-pnqq9
count:8 ,Hostname: canary-66cb497b7f-pnqq9
count:9 ,Hostname: canary-66cb497b7f-pnqq9
count:10 ,Hostname: canary-66cb497b7f-pnqq9
count:11 ,Hostname: canary-66cb497b7f-pnqq9
count:12 ,Hostname: canary-66cb497b7f-pnqq9
count:13 ,Hostname: canary-66cb497b7f-pnqq9
count:14 ,Hostname: canary-66cb497b7f-pnqq9
count:15 ,Hostname: canary-66cb497b7f-pnqq9
count:16 ,Hostname: canary-66cb497b7f-pnqq9
count:17 ,Hostname: canary-66cb497b7f-pnqq9
count:18 ,Hostname: canary-66cb497b7f-pnqq9
count:19 ,Hostname: canary-66cb497b7f-pnqq9
count:20 ,Hostname: canary-66cb497b7f-pnqq9

3. 基於cookie

與基於 Request Header 的 annotation 用法規則類似。例如在 A/B 測試場景下,需要讓地域為北京的用戶訪問 Canary 版本。那么當 cookie 的 annotation 設置為 nginx.ingress.kubernetes.io/canary-by-cookie: "users_from_Beijing",此時后台可對登錄的用戶請求進行檢查,若該用戶訪問源來自北京則設置 cookie users_from_Beijing 的值為 always,這樣就可以確保北京的用戶僅訪問 Canary 版本。

annotations:
  kubernetes.io/ingress.class: nginx 
  nginx.ingress.kubernetes.io/canary: "true"   # 要開啟灰度發布機制,首先需要啟用 Canary
  nginx.ingress.kubernetes.io/canary-by-cookie: "users_from_Beijing"  # 基於 cookie
  nginx.ingress.kubernetes.io/canary-weight: "30"  # 會被忽略,因為配置了 canary-by-cookie
#請求時設置一個 users_from_Beijing=always 的 Cookie 值,所有請求都會被路由到canary版本

[root@k8s-master1 ~]# for i in $(seq 1 20); do echo -n "count:$i ," && curl -s  -b "users_from_Beijing=always"  echo.erbiao.me | grep "Hostname"   ; done

count:1 ,Hostname: canary-66cb497b7f-pnqq9
count:2 ,Hostname: canary-66cb497b7f-pnqq9
count:3 ,Hostname: canary-66cb497b7f-pnqq9
count:4 ,Hostname: canary-66cb497b7f-pnqq9
count:5 ,Hostname: canary-66cb497b7f-pnqq9
count:6 ,Hostname: canary-66cb497b7f-pnqq9
count:7 ,Hostname: canary-66cb497b7f-pnqq9
count:8 ,Hostname: canary-66cb497b7f-pnqq9
count:9 ,Hostname: canary-66cb497b7f-pnqq9
count:10 ,Hostname: canary-66cb497b7f-pnqq9
count:11 ,Hostname: canary-66cb497b7f-pnqq9
count:12 ,Hostname: canary-66cb497b7f-pnqq9
count:13 ,Hostname: canary-66cb497b7f-pnqq9
count:14 ,Hostname: canary-66cb497b7f-pnqq9
count:15 ,Hostname: canary-66cb497b7f-pnqq9
count:16 ,Hostname: canary-66cb497b7f-pnqq9
count:17 ,Hostname: canary-66cb497b7f-pnqq9
count:18 ,Hostname: canary-66cb497b7f-pnqq9
count:19 ,Hostname: canary-66cb497b7f-pnqq9
count:20 ,Hostname: canary-66cb497b7f-pnqq9

ssl證書手動管理

通過 Secret 對象來引用證書文件:

kubectl create secret tls erbiao-tls --cert=tls.crt --key=tls.key

#如何自簽證書
# openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout tls.key -out tls.crt -subj "/CN=ssl.erbiao.me"

創建一個帶有使用ssl證書的應用

cat > ssl-nginx.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ssl-nginx
spec:
  selector:
    matchLabels:
      app: ssl-nginx
  template:
    metadata:
      labels:
        app: ssl-nginx
    spec:
      containers:
      - name: ssl-nginx
        image: nginx
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: ssl-nginx
  labels:
    app: ssl-nginx
spec:
  ports:
  - port: 80
    protocol: TCP
    name: http
  selector:
    app: ssl-nginx
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress-with-auth
spec:
  rules:
  - host: ssl.erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: ssl-nginx
          servicePort: 80
  tls:
  - hosts:
    - ssl.erbiao.me
    secretName: erbiao-tls
EOF

kubectl apply -f ssl-nginx.yaml

除了自簽名證書或者購買正規機構的 CA 證書之外,我們還可以通過 letsencrypt 來自動生成合法的證書。


ssl證書自動續期工具:cert-manager

cert-manager 是一個雲原生證書管理開源項目,用於在 Kubernetes 集群中提供 HTTPS 證書並自動續期,支持Let's Encrypt/HashiCorp/Vault這些免費證書的簽發。

在Kubernetes中,可以通過Kubernetes Ingress和Let's Encrypt 實現外部服務的自動化HTTPS。

下面是官方給出的架構圖,可以看到 cert-manager 在 Kubernetes 中定義了兩個自定義類型資源:Issuer(ClusterIssuer) 和 Certificate

  • Issuer 代表的是證書頒發者,可以定義各種提供者的證書頒發者,當前支持基於 Let's Encrypt/HashiCorp/Vault 和 CA 的證書頒發者,還可以定義不同環境下的證書頒發者
  • Certificate 代表的是生成證書的請求,一般其中存入生成證書元信息,如域名等。

當在Kubernetes中定義了上述兩類資源部署的cert-manager會根據Issuer和Certificate 生成TLS證書,並將證書保存進Kubernetes的Secret資源中,然后在Ingress資源中就可引用到這些生成的Secret資源作為TLS證書使用,對於已經生成的證書,還會定期檢查證書的有效期,如將超過有效期,還會自動續期

安裝cert-manager也簡單,官方提供一個單一的資源清單文件,包含所有的資源對象,直接安裝

# Kubernetes 1.16+
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.1.0/cert-manager.yaml
# Kubernetes <1.16
kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.1.0/cert-manager-legacy.yaml
#檢查狀態
[root@k8s-master1 test]# kubectl get pods -n cert-manager 
NAME                                      READY   STATUS    RESTARTS   AGE

cert-manager-5597cff495-ptg2n             1/1     Running   0          5m39s
cert-manager-cainjector-bd5f9c764-jmfrt   1/1     Running   0          5m39s
cert-manager-webhook-5f57f59fbc-4lctx     1/1     Running   0          5m39s

在簽發證書前,群集中至少配置一個Issuer或ClusterIssuer資源

創建一個Issuer資源對象來測試webhook工作是否正常。創建了一個名為 cert-manager-test 的命名空間,創建了一個自簽名的 Issuer 證書頒發機構,然后使用這個 Issuer 來創建一個證書請求的 Certificate 對象,直接創建資源清單

cat <<EOF > test-selfsigned.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: cert-manager-test
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: test-selfsigned
  namespace: cert-manager-test
spec:
  selfSigned: {}  # 配置自簽名的證書機構類型
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: selfsigned-cert
  namespace: cert-manager-test
spec:
  dnsNames:
  - example.com
  secretName: selfsigned-cert-tls
  issuerRef:
    name: test-selfsigned
EOF

kubectl apply -f test-selfsigned.yaml

創建完成后可以檢查新創建的證書狀態,在 cert-manager 處理證書請求之前,可能需要稍微等幾秒:

[root@k8s-master1 ~]# kubectl describe certificate -n cert-manager-test 

Name:         selfsigned-cert
Namespace:    cert-manager-test
Labels:       <none>
Annotations:  <none>
API Version:  cert-manager.io/v1
Kind:         Certificate

......

Spec:
  Dns Names:
    example.com
  Issuer Ref:
    Name:       test-selfsigned         #證書機構
  Secret Name:  selfsigned-cert-tls     #secret對象名稱
Status:
  Conditions:
    Last Transition Time:  2021-02-22T06:08:39Z
    Message:               Certificate is up to date and has not expired    #證書狀態
    Reason:                Ready
    Status:                True
    Type:                  Ready
  Not After:               2021-05-23T06:08:38Z     #證書有效期
  Not Before:              2021-02-22T06:08:38Z     #證書有效期
  Renewal Time:            2021-04-23T06:08:38Z     #證書下次更新時間
  Revision:                1
Events:
  Type    Reason     Age   From          Message
  ----    ------     ----  ----          -------
  Normal  Issuing    12m   cert-manager  Issuing certificate as Secret does not exist
  Normal  Generated  12m   cert-manager  Stored new private key in temporary Secret resource "selfsigned-cert-tqkwd"
  Normal  Requested  12m   cert-manager  Created new CertificateRequest resource "selfsigned-cert-zpdwq"
  Normal  Issuing    12m   cert-manager  The certificate has been successfully issued

從上面的Events中可看到證書已經成功簽發,生成的證書存放在一個名為selfsigned-cert-tls的Secret對象:

[root@k8s-master1 ~]# kubectl get secret -n cert-manager-test   selfsigned-cert-tls -o yaml
apiVersion: v1
data:
  ca.crt: ...
  tls.crt: ...
  tls.key: ...
kind: Secret
metadata:

...

  name: selfsigned-cert-tls
  namespace: cert-manager-test
  resourceVersion: "815281"
  selfLink: /api/v1/namespaces/cert-manager-test/secrets/selfsigned-cert-tls
  uid: 4e7fac45-284c-4755-bb89-5435021048ed
type: kubernetes.io/tls

到這里證明cert-manager已經安裝成功。需要注意的是cert-manager功能非常強大,不只是支持 ACME 類型證書簽發,還支持其他眾多類型,如SelfSigned(自簽名)、CA、Vault、Venafi、External、ACME,只是一般主要是使用ACME來生成自動化的證書。

cert-manager結合ingress-nginx為Kubernetes應用自動簽發Let's Encrypt類型的ssl證書

Let's Encrypt使用ACME協議來校驗域名的歸屬(目前主要有HTTP和DNS兩種校驗方式),校驗成功后就可自動頒發免費證書,證書有效期為90天,到期前需要再校驗一次來實現續期,而cert-manager是可以自動續期的,所以不用擔心證書過期問題。

HTTP-01校驗

HTTP-01的校驗通過給域名指向的HTTP服務增加一個臨時 location校驗時 Let's Encrypt會發送http請求到http://<YOUR_DOMAIN>/.well-known/acme-challenge/ 其中YOUR_DOMAIN是被校驗的域名TOKEN是cert-manager生成的一個路徑它通過修改Ingress規則來增加這個臨時校驗路徑並指向提供TOKEN的服務。Let's Encrypt 會對比 TOKEN 是否符合預期,校驗成功后就會頒發證書,不過這種方法不支持泛域名證書

使用HTTP校驗這種方式,首先需要配置域名解析,即需要保證ACME 服務端可以正常訪問到你的HTTP服務

由於Let's Encrypt的生產環境有着嚴格的接口調用限制,所以一般需要在 staging環境測試通過后,再切換到生產環境。

此處以一個todo項目為例

1. 創建Issuer/ClusterIssuer證書頒發機構

首先創建一個全局范圍staging環境使用的HTTP-01校驗方式的(ClusterIssuer對象)

cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging-http01
spec:
  acme:
    # ACME 服務端地址
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    # 用於 ACME 注冊的郵箱
    email: icnych@gmail.com
    # 用於存放 ACME 帳號 private key 的 secret
    privateKeySecretRef:
      name: letsencrypt-staging-http01
    solvers:
    - http01: # ACME HTTP-01 類型
        ingress:
          class: nginx  # 指定ingress的名稱
EOF

再創建一個用於生產環境使用的HTTP-01校驗方式的(ClusterIssuer對象)

cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-http01
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: icnych@gmail.com
    privateKeySecretRef:
      name: letsencrypt-http01
    solvers:
    - http01: 
        ingress:
          class: nginx
EOF
#生成兩個全局范圍ClusterIssuer的對象
[root@k8s-master1 ~]# kubectl get clusterissuers

NAME                         READY   AGE
letsencrypt-http01           True    106s
letsencrypt-staging-http01   True    2m2s

2. 生成證書

cert-manager提供了用於生成證書的自定義資源對象Certificate,不過這個對象需要在一個具體的命名空間下使用,證書最終會在這個命名空間下以 Secret 的資源對象存儲。這里是要結合ingress-nginx一起使用,只需要修改Ingress對象,添加上cert-manager的相關注解即可,不需要手動創建Certificate 對象。:

# 修改前的ingress對象
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo-web
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
  - host: todo.erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: todo-web
          servicePort: 3000
# 修改后的ingress對象
cat <<EOF | kubectl apply -f -
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-mongo
spec:
  selector:
    matchLabels:
      app: todo-mongo
  template:
    metadata:
      labels:
        app: todo-mongo
    spec:
      volumes:
      - name: todo-mongo-data
        emptyDir: {}
      containers:
      - name: todo-mongo
        image: todo-mongo
        ports:
        - containerPort: 27017
        volumeMounts:
        - name: data
          mountPath: /data/db
---
apiVersion: v1
kind: Service
metadata:
  name: todo-mongo
spec:
  selector:
    app: todo-mongo
  type: ClusterIP
  ports:
  - name: todo-mongo
    port: 27017
    targetPort: 27017
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: todo-web
spec:
  selector:
    matchLabels:
      app: todo-web
  template:
    metadata:
      labels:
        app: todo-web
    spec:
      containers:
      - name: todo-web
        image: cnych/todo:v1.1
        env:
        - name: "DBHOST"
          value: "mongodb://mongo.default.svc.cluster.local:27017" #不同的命名空間地址不同,此處為default
        ports:
        - containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: todo-web
spec:
  selector:
    app: todo-web
  type: ClusterIP
  ports:
  - name: todo-web
    port: 3000
    targetPort: 3000

---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo-web
  annotations:
    kubernetes.io/ingress.class: "nginx"
    cert-manager.io/cluster-issuer: "letsencrypt-staging-http01"  # 使用哪個issuer
spec:
  tls:
  - hosts:
    - todo.erbiao.me    # TLS 域名
    secretName: todo-tls   # 用於存儲證書的 Secret 對象名字 
  rules:
  - host: todo.erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: todo-web
          servicePort: 3000
EOF

上述執行完成,就會自動創建一個 Certificate 對象

kubectl get certificate

NAME       READY   SECRET     AGE
todo-tls   False   todo-tls   34s

在校驗過程中會自動創建一個 Ingress 對象用於 ACME 服務端訪問:

kubectl get ingress

NAME                        CLASS    HOSTS                ADDRESS        PORTS     AGE
todo-web                        <none>   todo.erbiao.me     192.168.1.120   80, 443   33s

校驗成功后會將證書保存到 todo-tls 的 Secret 對象中

[root@k8s-master1]# kubectl get certificate
NAME       READY   SECRET     AGE
todo-tls   True    todo-tls   21m

[root@k8s-master1]# kubectl get secret                                                 
NAME                          TYPE                                  DATA   AGE
default-token-hpd7s           kubernetes.io/service-account-token   3      55d
todo-tls                      kubernetes.io/tls                     2      20m

[root@k8s-master1]# kubectl describe certificate todo-tls
Name:         todo-tls
Namespace:    default
......
Events:
  Type    Reason     Age   From          Message
  ----    ------     ----  ----          -------
  Normal  Issuing    22m   cert-manager  Issuing certificate as Secret does not exist
  Normal  Generated  22m   cert-manager  Stored new private key in temporary Secret resource "todo-tls-tr4pq"
  Normal  Requested  22m   cert-manager  Created new CertificateRequest resource "todo-tls-2gchg"
  Normal  Issuing    21m   cert-manager  The certificate has been successfully issued

3. 將ClusterIssuer替換成生產環境的

證書自動獲取成功后,就可將 ClusterIssuer 替換成生產環境

cat <<EOF | kubectl apply -f -
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: todo-web
  annotations:
    kubernetes.io/ingress.class: "nginx"
    cert-manager.io/cluster-issuer: "letsencrypt-http01"  # 使用生產環境的issuer
spec:
  tls:
  - hosts:
    - todo.erbiao.me     # TLS 域名
    secretName: todo-tls   # 用於存儲證書的 Secret 對象名字 
  rules:
  - host: todo.erbiao.me
    http:
      paths:
      - path: /
        backend:
          serviceName: todo-web
          servicePort: 3000
EOF

[root@k8s-master1 ]# kubectl get certificate                         
NAME       READY   SECRET     AGE
todo-tls   True    todo-tls   25m

校驗成功后就可自動獲取真正的HTTPS證書,在瀏覽器中訪問 https://todo.erbiao.me就可看到證書有效。

DNS-01 校驗

DNS-01的校驗通過 DNS 提供商的 API 拿到你的 DNS 控制權限在 Let's Encrypt 為 cert-manager 提供 TOKEN 后cert-manager 將創建從該 TOKEN 和你的帳戶密鑰派生的 TXT 記錄,並將該記錄放在 _acme-challenge.<YOUR_DOMAIN>然后Let's Encrypt將向 DNS 系統查詢該記錄若找到匹配項,就頒發證書,這種方法是支持泛域名證書


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM