开发和运维对K8S中的应用都做了什么?
在应用的整个生命周期里,开发和运维都和它密不可分。一个塑造它,一个保养它。
如果应用需要部署到K8S中,开发和运维在其中都做了什么呢?
开发侧从开发侧来说,我们的应用应该具备以下能力:
具有健康检测接口具有优雅退出能力具有metrics接口能够接入链路追踪系统日志输出标准统一定义健康检测接口
健康检测接口用于检测应用的健康状态,在K8S中,使用Readiness和Liveness分别来探测应用是否就绪和是否存活,如果未就绪或者未存活,K8S会采取相应的措施来确保应用可用。
如果我们应用未定义好相应的健康检测接口,K8S就无法判断应用是否正常可用,整个应用对我们来说就是黑匣子,也就谈不上应用稳定性了。
定义一个简单的健康检测接口如下:
复制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)}1.2.3.4.5.6.7.8.9.10.11.12.13.14.复制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", })}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.如上我们定义了health接口,当应用启动后,只需要探测这个接口,如果返回OK,表示应用是正常的。
当然,上面的接口是非常简单的,在实际情况下,应用本身也许还依赖起来应用,比如redis,mysql,mq等,如果它们异常,应用是不是异常的呢?那我们的应用健康检测需不需要检测其他应用的健康状态呢?
既然我们定义好了健康检测接口,那我们的YAML模板就可以增加健康检测功能,如下:
health
port:http
timeoutSeconds: 3 initialDelaySeconds: 20livenessProbe: httpGet: path: /health
port:http
timeoutSeconds: 3 initialDelaySeconds: 301.2.3.4.5.6.7.8.9.10.11.12.定义优雅下线功能
应用发版是常规不能再常规的操作,通常情况下都是滚动更新的方式上线,也就是先起一个新应用,再删一个老应用。
如果这时候老应用有部分的流量,突然把老应用的进程杀了,这部分流量就无法得到正确的处理,部分用户也会因此受到影响。
怎么才会不受影响呢?
假如我们在停止应用之前先告诉网关或者注册中心,等对方把我们应用摘除后再下线,这样就不会有任何流量受到影响了。
在K8S中,当我们要删除Pod的时候,Pod会变成Terminating状态,kubelet看到Pod的状态如果为Terminating,就会开始执行关闭Pod的流程,给Pod发SIGTERM信号,如果达到宽限期Pod还未结束就给Pod发SIGKILL信号,从Endpoints中摘除Pod等。
从上面可知,Pod在停止之前会收到SIG信号,如果应用本身没有处理这些信号的能力,那应用如果知道什么时候该结束呢?
下面简单定义一个处理SIG信号的功能。
复制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.")}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.复制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)}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.当接收到SIG信号的时候,就会调用Shutdown方法做应用退出处理。
除此,还要结合K8S的PreStop Hook来定义结束前的钩子,如下:
sh
- -c - sleep 301.2.3.4.5.6.7.如果使用注册中心,比如nacos,我们可以在PreStop Hook中先告诉nacos要下线,如下:
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 301.2.3.4.5.6.7.定义Metrics接口
Metrics主要用来暴露应用指标,可以根据实际情况自定义指标,以便于监控工具Prometheus进行数据收集展示。
有些语言有现成的exporter,比如java的jmx_exporter,没有的就需要自己在应用中集成。
比如:
复制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 = falsefunc 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)}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.这种会暴露默认的Http指标,可以通过curl 127.0.0.1:9527/metrics获取指标。
复制......
# HELP promhttp_metric_handler_requests_total Total number of scrapes byHTTP status code.
# TYPE promhttp_metric_handler_requests_total counter
promhttp_metric_handler_requests_total{code="200"} 0promhttp_metric_handler_requests_total{code="500"} 0promhttp_metric_handler_requests_total{code="503"} 01.2.3.4.5.6.如果需要自定义指标的话,只需按规则定义即可,如下:
复制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()) }}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.这样就定义了httpserver_request_total和httpserver_request_duration_seconds指标,引用过后就能在/metrics中看到对应的数据。
定义好了指标,下面就是收集了。既可以通过自定义收集规则收集,也可以通过自动发现的方式收集,为了方便,主要采用自动发现的方式。
我们只需要在deployment的templates中定义好annotation,prometheeus就会自动添加采集目标,如下:
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: 31.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.定义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来说就只有自己手动埋点了,需要我们自己去写代码。
比如:
复制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 = falsefunc 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)}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.定义reporter用于上报数据给Skywalking,这就是一个简单的集成Trace的例子。
定义标准的日志
应用的可观测性主要来源日志、监控、链路追踪,标准的日志有利于日志收集以及排查问题。
原则上,不论是什么类型的日志输出,什么格式的日志内容,都能收集。但是为了方便友好,建议把日志输出到标准输出,这样收集更方便。
我个人理解,在K8s中,完全没必要把日志输出到文件,浪费不说,没多大意义,因为所有的日志我们都会收集到日志系统,而输出到文件的日志也会随着应用发版而丢失,所以输出到文件的意义是什么呢?
运维侧开发把系统开发完,就会交付给运维部署。为了保障应用的稳定性,运维在部署应用的时候应该考虑以下几点。
应用尽可能保持无状态应用尽可能保持高可用应该具备优雅上线能力应该具备异常自愈能力可以使用HTTPS访问应用尽可能保持无状态
K8S中可以部署有状态应用,也可以部署无状态应用。对于有状态应用,我其实很少部署到K8S中,大部分还是部署的无状态应用,至于为什么,用多了就晓得了。
对于业务应用,强烈建议使其保持无状态,就算有需要持久化的东西,要么保存到数据库,要么保存到对象存储或者其他单独的文件系统中,不要挂载到应用Pod上。
这样的好处是,应用和数据是分开的,应用可以随意启停、扩展、迁移等。
应用尽可能的保持高可用
保持高可用应该是每个运维人员的使命。
在K8S中,我们应该怎么配置呢?(1)应用Pod应该是多副本
(2)应用Pod之间做反亲和性,避免同一应用调度到同一台主机,如下。
复制......
spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key:app
operator: In values: [ "httpserver" ] topologyKey: kubernetes.io/hostname
......1.2.3.4.5.6.7.8.9.10.11.12.(3) 为了避免应用因为节点维护等原因驱逐Pod,导致全部Pod被驱逐,特别配置了PodDisruptionBudget,保障应用至少有一个可用,如下。
v1beta1
kind:PodDisruptionBudget
metadata: name:httpserver
spec: minAvailable: 1 selector: matchLables: app: httpserver1.2.3.4.5.6.7.8.9.(4)如果某个节点因为一些原因需要驱逐一些Pod,为了避免重要应用被驱逐,应该给应用配置较高的QoS,如下:
Gi
requests: cpu: "1" memory: 2Gi1.2.3.4.5.6.7.应用具备优雅上线能力
所谓优雅上线能力,就是要确保应用能够提供服务了,再接入外界流量,不能在还没完全启动的情况下就提供服务。
在K8S中,应用在启动后会加入endpoints中,然后通过service接入流量,那在什么情况下才算启动成功呢?主要是通过K8S的ReadinessProbe来进行检测。这时候开发的健康检测接口就派上用场了,如下:
复制...
readinessProbe: failureThreshold: 3 httpGet: path: /health
port:http
scheme:HTTP
initialDelaySeconds: 20 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 3...1.2.3.4.5.6.7.8.9.10.11.12.所以我们K8S的YAML文件应该加上如上的配置。
应该具备异常自愈能力
所谓异常自愈,就是应用本身在出现Crash,或者应用Pod所在节点出现异常的情况,应用能够自动重启或者迁移。这时候就需要通过K8S的LivenessProbe来进行检测了,如下。
复制......
livenessProbe: failureThreshold: 3 httpGet: path: /health
port:http
scheme:HTTP
initialDelaySeconds: 30 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 3......1.2.3.4.5.6.7.8.9.10.11.12.当K8S的YAML清单加上如上配置过后,就会定时去探测应用是否正常,如果异常,就会触发重启的动作。如果是节点异常,K8S会对Pod进行重新调度。
可以使用HTTPS进行访问
应用通过HTTPS访问是比较常见的,企业级应用建议自己购买相应的SSL证书,然后进行配置即可。
比如。
复制# 创建证书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......1.2.3.4.5.6.7.8.9.10.11.12.总结
上面介绍了开发和运维对于应用上线应该做的工作,不全但够用。
在不同的企业都有不同的尿性,但是作为运维,我们都要牢牢记住稳定永远是第一尿性。通过上面的梳理,我们的应用模板就整理如下:
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: httpserver1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146.147.148.149.150.151.152.153.154.155.156.157.