[TOC]
# 简介
按照一般的设计原则, 每个 HTTP 请求都是无状态的,因此大多情况下 Web 应用都很容易做水平扩展。“无状态”也意味着 HTTP 请求发起重试的成本是很低的,从而使得 Web 接口的开发很少关注优雅中止(一部分也因为 Web 框架做了这部分的考虑)。
不过,业务中 ① 总会存在对中止比较敏感的接口(比如支付相关),并且 ② 总会存在一些带状态的服务,此时优雅中止就显得比较重要了。
本文通过一个Go 定时任务示例来简单介绍 Go 技术栈中优雅中止的处理思路。
# k8s中pod的终止机制
作为高可靠的服务平台,k8s 定义了终止 Pod (业务进程在 Pod 中运行)的基本步骤:当主动删除 pod 时,系统会在强制终止 Pod 之前将 TERM 信号发送到每个容器中的主进程,过一段时间后(默认为 30 秒),再把 KILL 信号发送到这些进程。除此之外, k8s 还通过钩子方法提供了对 容器生命周期 的管理能力,允许用户通过自定义的方式配置容器启动后或终止前执行的操作。
当打包进镜像的应用运行在 k8s 中的时候,如果应用实现了优雅中止的机制,就可以充分利用上面提到的 k8s 的能力,在升级应用(发新版本)和管理 Pod (宿主机维护时把 Pod 漂移到另一个宿主机,或者在闲时动态地收缩 Pod 数量从而把资源省出来另作他用)的过程中实现服务的零中断。
# 优雅中止的 Go 代码示例
下面的代码定义了两个定时任务:mySecondJobs 每秒钟会触发一次,每次持续约 1 秒钟;myMinuteJobs 每分钟会触发一次,每次持续约 2 秒钟。具体地可以阅读下面的代码(可以直接复制下面的代码到自己的环境中运行):
~~~
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
c := make(chan os.Signal)
// Go 不允许监听 SIGKILL/SIGSTOP 信号
// 参考 https://github.com/golang/go/issues/9463
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
second := time.NewTicker(time.Second)
minute := time.NewTicker(time.Minute)
A: // 由于 for-select 嵌套使用,设置跳出 for 循环的标记
for {
select {
case s := <-c:
// 收到 SIGTERM/SIGINT 信号,跳出 for 循环结束进程
fmt.Printf("get signal %s, graceful ending...\n", s)
break A
case <-second.C:
go mySecondJobs()
case <-minute.C:
go myMinuteJobs()
}
}
fmt.Println("graceful ending")
// 做一些操作让异步任务正常结束,这里偷懒地采取简单等待的方式 😆
time.Sleep(time.Second * 10)
fmt.Println("graceful ended.")
}
func mySecondJobs() {
tS := time.Now().String()
fmt.Printf("starting second job: %s \n", tS)
time.Sleep(time.Second * 1) // 假设每个任务消耗 1 秒时间
fmt.Printf("second job %s are done. \n", tS)
}
func myMinuteJobs() {
tS := time.Now().String()
fmt.Printf("starting minute job: %s \n", tS)
time.Sleep(time.Second * 2) // 假设每个任务消耗 2 秒时间
fmt.Printf("minute job %s are done. \n", tS)
}
~~~
# 源码解读-优雅中止的处理思路
* 通过 signal.Notify 捕获特定的信号;
* 通过 for + select 来实现循环任务,同时检测上步中欲捕获的信号;
* 如果定时器被触发,则执行对应的任务;
* 如果发现收到了指定的信号,则跳出 for 循环,并采取一定措施结束异步任务。
# 源码解读-值得关注的几个点
代码中采用了 go mySecondJobs() 和 go myMinuteJobs() 异步任务的方式;如果采用同步的方式将无法捕获信号,因为此时主线程在处理业务逻辑,没有空闲处理信号捕获逻辑。
源码中偷懒地采取简单等待的方式来保证异步任务正常结束,非普适方法,实际开发中需要根据情况做定制。
time.Ticker 的使用是有注意事项的,当 select 语句中同一时刻有多个分支满足条件时会随机取一个执行,从而导致信息丢失,不过本文的代码不会触发这个问题,大家可以思考一下原因。
# http的shutdown
如何优雅的关闭http服务在Go Web开发中一直被提及和讨论的话题,今天Go 1.8的发布终于为我们带来了这个特性。
文档中是这样介绍的:
~~~
func (srv *Server) Shutdown(ctx context.Context) error
~~~
`Shutdown`将无中断的关闭正在活跃的连接,然后平滑的停止服务。处理流程如下:
* 首先关闭所有的监听
* 然后关闭所有的空闲连接
* 然后无限期等待连接处理完毕转为空闲,并关闭
* 如果提供了 带有超时的`Context`,将在服务关闭前返回`Context`的超时错误
需要注意的是,`Shutdown`并不尝试关闭或者等待`hijacked`连接,如`WebSockets`。如果需要的话调用者需要分别处理诸如长连接类型的等待和关闭。
其实,你只要调用`Shutdown`方法就好了。
~~~
// main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World, %v\n", time.Now())
})
s := &http.Server{
Addr: ":8080",
Handler: http.DefaultServeMux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
log.Println(s.ListenAndServe())
log.Println("server shutdown")
}()
// Handle SIGINT and SIGTERM.
ch := make(chan os.Signal)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
log.Println(<-ch)
// Stop the service gracefully.
log.Println(s.Shutdown(nil))
// Wait gorotine print shutdown message
time.Sleep(time.Second * 5)
log.Println("done.")
}
~~~
运行程序:
~~~
go run main.go
~~~
然后`ctrl + c`终止该程序,会打印出如下信息:
~~~
2017/02/17 11:36:28 interrupt
2017/02/17 11:36:28 <nil>
2017/02/17 11:36:28 http: Server closed
2017/02/17 11:36:28 server shutdown
~~~
可以看到,服务被正确关闭了。
在没有`Shutdown`方法之前,`ctrl + c`就硬生生的终止了,然后就没有然后了
# 小结
默认情况下,Go 应用在接收到 TERM 信号后直接退出主进程,如果此时有过程没处理完(比如 接收到外部请求后尚未返回响应,或者内部的异步任务尚未结束),则会导致过程的异常中断,影响服务质量。通过在代码中显式地捕获 TERM 信号及其他信号,感知操作系统对进程的处理,可以主动采取措施优雅地结束应用进程。
随着 k8s 的普及,考虑到其对进程生命周期的规范化管理,应用支持代码级的优雅中止(尤其是容器化的应用)有必要成为一种开发规范,值得引起每一位开发者的注意
- 基础
- 简介
- 主要特征
- 变量和常量
- 编码转换
- 数组
- byte与rune
- big
- sort接口
- 和mysql类型对应
- 函数
- 闭包
- 工作区
- 复合类型
- 指针
- 切片
- map
- 结构体
- sync.Map
- 随机数
- 面向对象
- 匿名组合
- 方法
- 接口
- 权限
- 类型查询
- 异常处理
- error
- panic
- recover
- 自定义错误
- 字符串处理
- 正则表达式
- json
- 文件操作
- os
- 文件读写
- 目录
- bufio
- ioutil
- gob
- 栈帧的内存布局
- shell
- 时间处理
- time详情
- time使用
- new和make的区别
- container
- list
- heap
- ring
- 测试
- 单元测试
- Mock依赖
- delve
- 命令
- TestMain
- path和filepath包
- log日志
- 反射
- 详解
- plugin包
- 信号
- goto
- 协程
- 简介
- 创建
- 协程退出
- runtime
- channel
- select
- 死锁
- 互斥锁
- 读写锁
- 条件变量
- 嵌套
- 计算单个协程占用内存
- 执行规则
- 原子操作
- WaitGroup
- 定时器
- 对象池
- sync.once
- 网络编程
- 分层模型
- socket
- tcp
- udp
- 服务端
- 客户端
- 并发服务器
- Http
- 简介
- http服务器
- http客户端
- 爬虫
- 平滑重启
- context
- httptest
- 优雅中止
- web服务平滑重启
- beego
- 安装
- 路由器
- orm
- 单表增删改查
- 多级表
- orm使用
- 高级查询
- 关系查询
- SQL查询
- 元数据二次定义
- 控制器
- 参数解析
- 过滤器
- 数据输出
- 表单数据验证
- 错误处理
- 日志
- 模块
- cache
- task
- 调试模块
- config
- 部署
- 一些包
- gjson
- goredis
- collection
- sjson
- redigo
- aliyunoss
- 密码
- 对称加密
- 非对称加密
- 单向散列函数
- 消息认证
- 数字签名
- mysql优化
- 常见错误
- go run的错误
- 新手常见错误
- 中级错误
- 高级错误
- 常用工具
- 协程-泄露
- go env
- gometalinter代码检查
- go build
- go clean
- go test
- 包管理器
- go mod
- gopm
- go fmt
- pprof
- 提高编译
- go get
- 代理
- 其他的知识
- go内存对齐
- 细节总结
- nginx路由匹配
- 一些博客
- redis为什么快
- cpu高速缓存
- 常用命令
- Go 永久阻塞的方法
- 常用技巧
- 密码加密解密
- for 循环迭代变量
- 备注
- 垃圾回收
- 协程和纤程
- tar-gz
- 红包算法
- 解决golang.org/x 下载失败
- 逃逸分析
- docker
- 镜像
- 容器
- 数据卷
- 网络管理
- 网络模式
- dockerfile
- docker-composer
- 微服务
- protoBuf
- GRPC
- tls
- consul
- micro
- crontab
- shell调用
- gorhill/cronexpr
- raft
- go操作etcd
- mongodb