Skip to content

开发和运维对K8S中的应用都做了什么? 原创

在应用的整个生命周期里,开发和运维都和它密不可分。一个塑造它,一个保养它。

如果应用需要部署到 K8S 中,开发和运维在其中都做了什么呢?

开发侧

从开发侧来说,我们的应用应该具备以下能力:

  • 具有健康检测接口
  • 具有优雅退出能力
  • 具有 metrics 接口
  • 能够接入链路追踪系统
  • 日志输出标准统一

定义健康检测接口

健康检测接口用于检测应用的健康状态,在 K8S 中,使用 Readiness 和 Liveness 分别来探测应用是否就绪和是否存活,如果未就绪或者未存活,K8S 会采取相应的措施来确保应用可用。

如果我们应用未定义好相应的健康检测接口,K8S 就无法判断应用是否正常可用,整个应用对我们来说就是黑匣子,也就谈不上应用稳定性了。

定义一个简单的健康检测接口如下:

go

package router



import (

  "github.com/gin-gonic/gin"

  v1 "go-hello-world/app/http/controllers/v1"

)



func SetupRouter(router *gin.Engine) {

  ruc := new(v1.RootController)

  router.GET("/", ruc.Root)



  huc := new(v1.HealthController)

  router.GET("/health", huc.HealthCheck)

}
go

package v1



import (

  "github.com/gin-gonic/gin"

  "go-hello-world/app/http/controllers"

  "go-hello-world/pkg/response"

  "net/http"

)



type HealthController struct {

  controllers.BaseController

}



func (h *HealthController) HealthCheck(c *gin.Context) {

  response.WriteResponse(c, http.StatusOK, nil, gin.H{

    "result": "健康检测页面",

    "status": "OK",

  })

}

如上我们定义了health接口,当应用启动后,只需要探测这个接口,如果返回 OK,表示应用是正常的。

当然,上面的接口是非常简单的,在实际情况下,应用本身也许还依赖其他应用,比如 redis,mysql,mq 等,如果它们异常,应用是不是异常的呢?那我们的应用健康检测需不需要检测其他应用的健康状态呢?

既然我们定义好了健康检测接口,那我们的 YAML 模板就可以增加健康检测功能,如下:

yaml

readinessProbe:

  httpGet:

    path: /health

    port: http

  timeoutSeconds: 3

  initialDelaySeconds: 20

livenessProbe:

  httpGet:

    path: /health

    port: http

  timeoutSeconds: 3

  initialDelaySeconds: 30

定义优雅下线功能

应用发版是常规不能再常规的操作,通常情况下都是滚动更新的方式上线,也就是先起一个新应用,再删一个老应用。

如果这时候老应用有部分的流量,突然把老应用的进程杀了,这部分流量就无法得到正确的处理,部分用户也会因此受到影响。

怎么才会不受影响呢?

假如我们在停止应用之前先告诉网关或者注册中心,等对方把我们应用摘除后再下线,这样就不会有任何流量受到影响了。

在 K8S 中,当我们要删除 Pod 的时候,Pod 会变成 Terminating 状态,kubelet 看到 Pod 的状态如果为 Terminating,就会开始执行关闭 Pod 的流程,给 Pod 发 SIGTERM 信号,如果达到宽限期 Pod 还未结束就给 Pod 发 SIGKILL 信号,从 Endpoints 中摘除 Pod 等。

从上面可知,Pod 在停止之前会收到 SIG 信号,如果应用本身没有处理这些信号的能力,那应用如果知道什么时候该结束呢?

下面简单定义一个处理 SIG 信号的功能。

go

package shutdown



import (

  "context"

  "fmt"

  "net/http"

  "os"

  "os/signal"

  "time"

)



// 优雅退出



type Shutdown struct {

  ch      chan os.Signal

  timeout time.Duration

}



func New(t time.Duration) *Shutdown {

  return &Shutdown{

    ch:      make(chan os.Signal),

    timeout: t,

  }

}



func (s *Shutdown) Add(signals ...os.Signal) {

  signal.Notify(s.ch, signals...)

}



func (s *Shutdown) Start(server *http.Server) {

  <-s.ch

  fmt.Println("start exist......")



  ctx, cannel := context.WithTimeout(context.Background(), s.timeout*time.Second)

  defer cannel()

  if err := server.Shutdown(ctx); err != nil {

    fmt.Println("Graceful exit failed. err: ", err)

  }

  fmt.Println("Graceful exit success.")

}
go

package main



import (

  "github.com/gin-gonic/gin"

  "go-hello-world/pkg/shutdown"

  "go-hello-world/router"

  "log"

  "net/http"

  "syscall"

  "time"

)




func main() {

  r := gin.New()



  // 注册路由

  router.SetupRouter(r)



  server := &http.Server{

    Addr:    ":8080",

    Handler: r,

  }



  // 运行服务

  go func() {

    err := server.ListenAndServe()

    if err != nil && err != http.ErrServerClosed {

      log.Fatalf("server.ListenAndServe err: %v", err)

    }

  }()



  // 优雅退出

  quit := shutdown.New(10)

  quit.Add(syscall.SIGINT, syscall.SIGTERM)

  quit.Start(server)

}

当接收到 SIG 信号的时候,就会调用Shutdown方法做应用退出处理。

除此,还要结合 K8S 的PreStop Hook来定义结束前的钩子,如下:

yaml

lifecycle:

  preStop:

    exec:

      command:

        - /bin/sh

        - '-c'

        - sleep 30

如果使用注册中心,比如 nacos,我们可以在PreStop Hook中先告诉 nacos 要下线,如下:

yaml

lifecycle:

  preStop:

    exec:

      command:

        - /bin/sh

        - -c

        - "curl -X DELETE your_nacos_ip:8848/nacos/v1/ns/instance?serviceName=nacos.test.1&ip=${POD_IP}&port=8880&clusterName=DEFAULT" && sleep 30

定义 Metrics 接口

Metrics 主要用来暴露应用指标,可以根据实际情况自定义指标,以便于监控工具 Prometheus 进行数据收集展示。

有些语言有现成的 exporter,比如 java 的 jmx_exporter,没有的就需要自己在应用中集成。

比如:

go

package main



import (

  "github.com/SkyAPM/go2sky"

  v3 "github.com/SkyAPM/go2sky-plugins/gin/v3"

  "github.com/SkyAPM/go2sky/reporter"

  "github.com/gin-gonic/gin"

  "github.com/prometheus/client_golang/prometheus/promhttp"

  "go-hello-world/pkg/shutdown"

  "go-hello-world/router"

  "log"

  "net/http"

  "syscall"

  "time"

)



var SKYWALKING_ENABLED = false



func main() {

  r := gin.New()



  // 注册路由

  router.SetupRouter(r)



  server := &http.Server{

    Addr:    ":8080",

    Handler: r,

  }



  // 启动metrics服务

  go func() {

    http.Handle("/metrics", promhttp.Handler())

    if err := http.ListenAndServe(":9527", nil); err != nil {

      log.Printf("metrics port listen failed. err: %s", err)

    }

  }()



  // 运行服务

  go func() {

    err := server.ListenAndServe()

    if err != nil && err != http.ErrServerClosed {

      log.Fatalf("server.ListenAndServe err: %v", err)

    }

  }()



  // 优雅退出

  quit := shutdown.New(10)

  quit.Add(syscall.SIGINT, syscall.SIGTERM)

  quit.Start(server)

}

这种会暴露默认的 Http 指标,可以通过curl 127.0.0.1:9527/metrics获取指标。

yaml

......

# HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code.

# TYPE promhttp_metric_handler_requests_total counter

promhttp_metric_handler_requests_total{code="200"} 0

promhttp_metric_handler_requests_total{code="500"} 0

promhttp_metric_handler_requests_total{code="503"} 0

如果需要自定义指标的话,只需按规则定义即可,如下:

go

package metrics



import (

  "github.com/prometheus/client_golang/prometheus"

  "net/http"

  "time"

)



var (

  // HttpserverRequestTotal 表示接收http请求总数

  HttpserverRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{

    Name: "httpserver_request_total",

    Help: "The Total number of httpserver requests",

  },

    // 设置标签:请求方法和路径

    []string{"method", "endpoint"})



  HttpserverRequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{

    Name:    "httpserver_request_duration_seconds",

    Help:    "httpserver request duration distribution",

    Buckets: []float64{0.1, 0.3, 0.5, 0.7, 0.9, 1},

  },

    []string{"method", "endpoint"})

)



// 注册监控指标

func init() {

  prometheus.MustRegister(HttpserverRequestTotal)

  prometheus.MustRegister(HttpserverRequestDuration)

}



func NewMetrics(router http.HandlerFunc) http.HandlerFunc {

  return func(w http.ResponseWriter, r *http.Request) {

    start := time.Now()

    router(w, r)

    duration := time.Since(start)

    // httpserverRequestTotal 记录

    HttpserverRequestTotal.With(prometheus.Labels{"method": r.Method, "endpoint": r.URL.Path}).Inc()

    // httpserverRequestDuration 记录

    HttpserverRequestDuration.With(prometheus.Labels{"method": r.Method, "endpoint": r.URL.Path}).Observe(duration.Seconds())

  }

}

这样就定义了httpserver_request_totalhttpserver_request_duration_seconds指标,引用过后就能在/metrics中看到对应的数据。

定义好了指标,下面就是收集了。既可以通过自定义收集规则收集,也可以通过自动发现的方式收集,为了方便,主要采用自动发现的方式。

我们只需要在 deployment 的 templates 中定义好 annotation,prometheeus 就会自动添加采集目标,如下:

yaml

apiVersion: apps/v1

kind: Deployment

metadata:

  labels:

    app: httpserver

  name: httpserver

  namespace: default

spec:

  replicas: 2

  selector:

    matchLabels:

      app: httpserver

  template:

    metadata:

      annotations:

        prometheus.io/scrape: "true"

        prometheus.io/port: "metrics"

      labels:

        app: httpserver

    spec:

      containers:

          image: baidjay/httpserver:ubuntu-v3-metrics

          imagePullPolicy: IfNotPresent

          lifecycle:

            preStop:

              exec:

                command:

                  - /bin/sh

                  - -c

                  - sleep 15

          livenessProbe:

            failureThreshold: 3

            httpGet:

              path: /healthz

              port: http

              scheme: HTTP

            initialDelaySeconds: 30

            periodSeconds: 10

            successThreshold: 1

            timeoutSeconds: 3

          name: httpserver

          ports:

            - containerPort: 8080

              name: http

              protocol: TCP

            - name: metrics

              protocol: TCP

              containerPort: 9527

          readinessProbe:

            failureThreshold: 3

            httpGet:

              path: /healthz

              port: http

              scheme: HTTP

            initialDelaySeconds: 20

            periodSeconds: 10

            successThreshold: 1

            timeoutSeconds: 3

定义 Trace 功能

Trace 用于跟踪,每个请求都会生成一个TraceID,这个 ID 会伴随请求的整个生命周期,我们也可以根据这个 ID 查询请求的整个链路情况。

链路追踪,目前市面上有很多开源系统,比如 Skywalking,Jeager,Zipkin 等,它们各有各的特点,如下。

| | Pinpoint | Zipkin | Jaeger | Skywalking |

| --- | --- | --- | --- | --- |

| OpenTracing 兼容 | 否 | 是 | 是 | 是 |

| 客户端支持语言 | java\php | java\go\php 等 | java\go\php 等 | java\nodejs\php 等 |

| 存储 | hbase | es\mysql\内存等 | es\kafka\内存等 | es\mysql\h2 等 |

| 传输协议支持 | thrift | http\mq | udp\http | grpc |

| UI 丰富程度 | 高 | 低 | 中 | 中 |

| 实现方式 | 字节码注入 | 拦截请求 | 拦截请求 | 字节码注入 |

| 扩展性 | 低 | 高 | 高 | 中 |

| Trace 查询 | 不支持 | 支持 | 支持 | 支持 |

| 告警支持 | 支持 | 不支持 | 不支持 | 支持 |

| JVM 监控 | 支持 | 不支持 | 不支持 | 支持 |

| 性能损失 | 高 | 中 | 中 | 低 |

我比较推荐使用 Jaeger,它是 CNCF 的毕业项目,成长空间和云原生的系统架构兼容性比较好。

不过,我这里采用的 Skywalking。

Skywalking 有许多现成的客户端,比如 Java、Python 等,可以直接使用,它们都会自动埋点,但是对于 Go 来说就只有自己手动埋点了,需要我们自己去写代码。

比如:

go

package main



import (

  "github.com/SkyAPM/go2sky"

  v3 "github.com/SkyAPM/go2sky-plugins/gin/v3"

  "github.com/SkyAPM/go2sky/reporter"

  "github.com/gin-gonic/gin"

  "github.com/prometheus/client_golang/prometheus/promhttp"

  "go-hello-world/pkg/shutdown"

  "go-hello-world/router"

  "log"

  "net/http"

  "syscall"

  "time"

)



var SKYWALKING_ENABLED = false



func main() {

  r := gin.New()



  // 配置skywalking

  if SKYWALKING_ENABLED {

    rp, err := reporter.NewGRPCReporter("skywalking-oap:11800", reporter.WithCheckInterval(time.Second))

    if err != nil {

      log.Printf("create gosky reporter failed. err: %s", err)

    }

    defer rp.Close()

    tracer, _ := go2sky.NewTracer("go-hello-world", go2sky.WithReporter(rp))

    r.Use(v3.Middleware(r, tracer))

  }



  // 注册路由

  router.SetupRouter(r)



  server := &http.Server{

    Addr:    ":8080",

    Handler: r,

  }



  // 启动metrics服务

  go func() {

    http.Handle("/metrics", promhttp.Handler())

    if err := http.ListenAndServe(":9527", nil); err != nil {

      log.Printf("metrics port listen failed. err: %s", err)

    }

  }()



  // 运行服务

  go func() {

    err := server.ListenAndServe()

    if err != nil && err != http.ErrServerClosed {

      log.Fatalf("server.ListenAndServe err: %v", err)

    }

  }()



  // 优雅退出

  quit := shutdown.New(10)

  quit.Add(syscall.SIGINT, syscall.SIGTERM)

  quit.Start(server)

}

定义 reporter 用于上报数据给 Skywalking,这就是一个简单的集成 Trace 的例子。

定义标准的日志

应用的可观测性主要来源日志、监控、链路追踪,标准的日志有利于日志收集以及排查问题。

原则上,不论是什么类型的日志输出,什么格式的日志内容,都能收集。但是为了方便友好,建议把日志输出到标准输出,这样收集更方便。

我个人理解,在 K8s 中,完全没必要把日志输出到文件,浪费不说,没多大意义,因为所有的日志我们都会收集到日志系统,而输出到文件的日志也会随着应用发版而丢失,所以输出到文件的意义是什么呢?

运维侧

开发把系统开发完,就会交付给运维部署。为了保障应用的稳定性,运维在部署应用的时候应该考虑以下几点。

  • 应用尽可能保持无状态
  • 应用尽可能保持高可用
  • 应该具备优雅上线能力
  • 应该具备异常自愈能力
  • 可以使用 HTTPS 访问

应用尽可能保持无状态

K8S 中可以部署有状态应用,也可以部署无状态应用。对于有状态应用,我其实很少部署到 K8S 中,大部分还是部署的无状态应用,至于为什么,用多了就晓得了。

对于业务应用,强烈建议使其保持无状态,就算有需要持久化的东西,要么保存到数据库,要么保存到对象存储或者其他单独的文件系统中,不要挂载到应用 Pod 上。

这样的好处是,应用和数据是分开的,应用可以随意启停、扩展、迁移等。

应用尽可能的保持高可用

保持高可用应该是每个运维人员的使命。

在 K8S 中,我们应该怎么配置呢?

(1)应用 Pod 应该是多副本

(2)应用 Pod 之间做反亲和性,避免同一应用调度到同一台主机,如下。

yaml

......

spec:

  affinity:

    podAntiAffinity:

      requiredDuringSchedulingIgnoredDuringExecution:

          - labelSelector:

            matchExpressions:

              - key: app

                operator: In

                values: [ "httpserver" ]

            topologyKey: kubernetes.io/hostname

......

(3) 为了避免应用因为节点维护等原因驱逐 Pod,导致全部 Pod 被驱逐,特别配置了 PodDisruptionBudget,保障应用至少有一个可用,如下。

yaml
apiVersion: policy/v1beta1

kind: PodDisruptionBudget

metadata:

  name: httpserver

spec:

  minAvailable: 1

  selector:

    matchLables:

      app: httpserver

(4)如果某个节点因为一些原因需要驱逐一些 Pod,为了避免重要应用被驱逐,应该给应用配置较高的 QoS,如下:

yaml

resources:

  limits:

    cpu: "1"

    memory: 2Gi

  requests:

    cpu: "1"

    memory: 2Gi

应用具备优雅上线能力

所谓优雅上线能力,就是要确保应用能够提供服务了,再接入外界流量,不能在还没完全启动的情况下就提供服务。

在 K8S 中,应用在启动后会加入 endpoints 中,然后通过 service 接入流量,那在什么情况下才算启动成功呢?主要是通过 K8S 的ReadinessProbe来进行检测。这时候开发的健康检测接口就排上用场了,如下:

yaml
---
readinessProbe:

  failureThreshold: 3

  httpGet:

    path: /health

    port: http

    scheme: HTTP

  initialDelaySeconds: 20

  periodSeconds: 10

  successThreshold: 1

  timeoutSeconds: 3

所以我们 K8S 的 YAML 文件应该加上如上的配置。

应该具备异常自愈能力

所谓异常自愈,就是应用本身在出现 Crash,或者应用 Pod 所在节点出现异常的情况,应用能够自动重启或者迁移。这时候就需要通过 K8S 的LivenessProbe来进行检测了,如下。

yaml

......

livenessProbe:

  failureThreshold: 3

  httpGet:

    path: /health

    port: http

    scheme: HTTP

  initialDelaySeconds: 30

  periodSeconds: 10

  successThreshold: 1

  timeoutSeconds: 3

......

当 K8S 的 YAML 清单加上如上配置过后,就会定时去探测应用是否正常,如果异常,就会触发重启的动作。如果是节点异常,K8S 会对 Pod 进行重新调度。

可以使用 HTTPS 进行访问

应用通过 HTTPS 访问是比较常见的,企业级应用建议自己购买相应的 SSL 证书,然后进行配置即可。

比如。

yaml

# 创建证书secret

kubectl create secret tls httpserver-tls-secret --cert=path/to/tls.cert --key=path/to/tls.key

# 在ingress中引用

......

spec:

  tls:

    hosts:

      - httpserver.coolops.cn

    secretName: httpserver-tls-secret

  rules:

    - host: httpserver.coolops.cn

......

总结

上面介绍了开发和运维对于应用上线应该做的工作,不全但够用

在不同的企业都有不同的尿性,但是作为运维,我们都要牢牢记住稳定永远是第一尿性。通过上面的梳理,我们的应用模板就整理如下:

yaml

apiVersion: apps/v1

kind: Deployment

metadata:

  labels:

    app: httpserver

  name: httpserver

  namespace: default

spec:

  progressDeadlineSeconds: 600

  replicas: 2

  revisionHistoryLimit: 10

  selector:

    matchLabels:

      app: httpserver

  strategy:

    rollingUpdate:

      maxSurge: 25%

      maxUnavailable: 25%

    type: RollingUpdate

  template:

    metadata:

      annotations:

        prometheus.io/scrape: "true"

        prometheus.io/port: "metrics"

      labels:

        app: httpserver

    spec:

      affinity:

        podAntiAffinity:

          requiredDuringSchedulingIgnoredDuringExecution:

            - labelSelector:

                matchExpressions:

                  - key: app

                    operator: In

                    values: [ "httpserver" ]

              topologyKey: kubernetes.io/hostname

      containers:

        - env:

            - name: TZ

              value: Asia/Shanghai

            - name: POD_NAME

              valueFrom:

                fieldRef:

                  apiVersion: v1

                  fieldPath: metadata.name

            - name: POD_NAMESPACE

              valueFrom:

                fieldRef:

                  apiVersion: v1

                  fieldPath: metadata.namespace

          image: baidjay/httpserver:ubuntu-v3-metrics

          imagePullPolicy: IfNotPresent

          lifecycle:

            preStop:

              exec:

                command:

                  - /bin/sh

                  - -c

                  - sleep 15

          livenessProbe:

            failureThreshold: 3

            httpGet:

              path: /healthz

              port: http

              scheme: HTTP

            initialDelaySeconds: 30

            periodSeconds: 10

            successThreshold: 1

            timeoutSeconds: 3

          name: httpserver

          ports:

            - containerPort: 8080

              name: http

              protocol: TCP

            - name: metrics

              protocol: TCP

              containerPort: 9527

          readinessProbe:

            failureThreshold: 3

            httpGet:

              path: /healthz

              port: http

              scheme: HTTP

            initialDelaySeconds: 20

            periodSeconds: 10

            successThreshold: 1

            timeoutSeconds: 3

          resources:

            limits:

              cpu: "1"

              memory: 2Gi

            requests:

              cpu: "1"

              memory: 2Gi

          securityContext: {}

          terminationMessagePath: /dev/termination-log

          terminationMessagePolicy: File

      dnsPolicy: ClusterFirst

      restartPolicy: Always

      schedulerName: default-scheduler

---

apiVersion: v1

kind: Service

metadata:

  name: httpserver

spec:

  ports:

    - name: http

      port: 8080

      protocol: TCP

      targetPort: http

    - name: metrics

      port: 9527

      protocol: TCP

      targetPort: metrics

  selector:

    app: httpserver

  sessionAffinity: None

  type: ClusterIP

---

apiVersion: networking.k8s.io/v1

kind: Ingress

metadata:

  annotations:

    nginx.ingress.kubernetes.io/proxy-body-size: 100m

    nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"

    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"

    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"

    nginx.ingress.kubernetes.io/service-weight: ""

    nginx.org/client-max-body-size: 100m

  name: httpserver-tls

spec:

  tls:

  - hosts:

      - httpserver.coolops.cn

    secretName: httpserver-tls-secret

  rules:

    - host: httpserver.coolops.cn

      http:

        paths:

          - pathType: Prefix

            path: /

            backend:

              service:

                name: httpserver

                port:

                  number: 8080

---

apiVersion: policy/v1

kind: PodDisruptionBudget

metadata:

  name: httpserver

spec:

  minAvailable: 1

  selector:

    matchLabels:

      app: httpserver

为了凑字数,写了一大堆,大家凑合看,觉得有用就点个赞~~!

最近更新