原文:istio源碼分析——pilot-agent如何管理envoy生命週期
聲明
- 分析的源碼為0.7.1版本
- 環境為k8s
- 由於沒有C++ 基礎,所以源碼分析止步於 C++,但也學到很多東西
pilot-agent 是什嗎?
當我們執行
kubectl apply -f <(~istioctl kube-inject -f sleep.yaml)
的時候,k8s就會幫我們建立3個容器。
[root@izwz9cffi0prthtem44cp9z ~]# docker ps |grep sleep8e0de7294922 istio/proxy ccddc800b2a2 registry.cn-shenzhen.aliyuncs.com/jukylin/sleep 990868aa4a42 registry-vpc.cn-shenzhen.aliyuncs.com/acs/pause-amd64:3.0
在這3個容器中,我們關注
istio/proxy
。這個容器運行著2個服務。
pilot-agent
就是接下來介紹的:如何管理envoy的生命週期。
[root@izwz9cffi0prthtem44cp9z ~]# docker exec -it 8e0de7294922 ps -efUID PID PPID C STIME TTY TIME CMD1337 1 0 0 May09 ? 00:00:49 /usr/local/bin/pilot-agent proxy1337 567 1 1 09:18 ? 00:04:42 /usr/local/bin/envoy -c /etc/ist
為什麼要用pilot-agent?
envoy不直接和k8s,Consul,Eureka等這些平台互動,所以需要其他服務與它們對接,管理配置,pilot-agent就是其中一個
【控制台】。
啟動envoy
載入配置
在啟動前 pilot-agent 會產生一個設定檔:/etc/istio/proxy/envoy-rev0.json:
istio.io/istio/pilot/pkg/proxy/envoy/v1/config.go #88func BuildConfig(config meshconfig.ProxyConfig, pilotSAN []string) *Config { ...... return out}
檔案的具體內容可以直接查看容器裡面的檔案
docker exec -it 8e0de7294922 cat /etc/istio/proxy/envoy-rev0.json
關於配置內容的含義可以看官方的文檔
啟動參數
一個二進位檔案啟動總會需要一些參數,envoy也不例外。
istio.io/istio/pilot/pkg/proxy/envoy/v1/watcher.go #274func (proxy envoy) args(fname string, epoch int) []string { ...... return startupArgs}
envoy啟動參數可以通過
docker logs 8e0de7294922
查看,下面是從終端截取envoy的參數。瞭解具體的參數含義官網文檔。
-c /etc/istio/proxy/envoy-rev0.json --restart-epoch 0--drain-time-s 45 --parent-shutdown-time-s 60--service-cluster sleep --service-node sidecar~172.00.00.000~sleep-55b5877479-rwcct.default~default.svc.cluster.local --max-obj-name-len 189 -l info --v2-config-only
啟動envoy
pilot-agent 使用
exec.Command
啟動envoy,並且會監聽envoy的運行狀態(如果envoy非正常退出,status 返回非nil,pilot-agent會有策略把envoy重新啟動)。
proxy.config.BinaryPath
為envoy二進位檔案路徑:/usr/local/bin/envoy。
args
為上面介紹的envoy啟動參數。
istio.io/istio/pilot/pkg/proxy/envoy/v1/watcher.go #353func (proxy envoy) Run(config interface{}, epoch int, abort <-chan error) error { ...... /* #nosec */ cmd := exec.Command(proxy.config.BinaryPath, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } ...... done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-abort: ...... case err := <-done: return err }}
熱更新envoy
在這裡我們只討論pilot-agent如何讓envoy熱更新,至於如何去觸發這步會在後面的文章介紹。
envoy熱更新策略
想詳細瞭解envoy的熱更新策略可以看官網部落格Envoy hot restart。
簡單介紹下envoy熱更新步驟:
- 啟動另外一個envoy2進程(Secondary process)
- envoy2通知envoy1(Primary process)關閉其管理的連接埠,由envoy2接管
- 通過UDS把envoy1可用的listen sockets拿過來
- envoy2初始化成功,通知envoy1在一段時間內(
drain-time-s
)優雅關閉正在工作的請求
- 到了時間(
parent-shutdown-time-s
),envoy2通知envoy1自行關閉
- envoy2升級為envoy1
從上面的執行步驟來看,poilt-agent只負責啟動另一個envoy進程,其他由envoy自行處理。
什麼時候進行熱更新?
在poilt-agent啟動的時候,會監聽
/etc/certs/
目錄下的檔案,如果這個目錄下的檔案被修改或刪除,poilt-agent就會通知envoy進行熱更新。至於如何觸發對這些檔案進行修改和刪除會在接下來的文章介紹。
istio.io/istio/pilot/pkg/proxy/envoy/v1/watcher.go #177func watchCerts(ctx context.Context, certsDirs []string, watchFileEventsFn watchFileEventsFn, minDelay time.Duration, updateFunc func()) { fw, err := fsnotify.NewWatcher() if err != nil { log.Warnf("failed to create a watcher for certificate files: %v", err) return } defer func() { if err := fw.Close(); err != nil { log.Warnf("closing watcher encounters an error %v", err) } }() // watch all directories for _, d := range certsDirs { if err := fw.Watch(d); err != nil { log.Warnf("watching %s encounters an error %v", d, err) return } } watchFileEventsFn(ctx, fw.Event, minDelay, updateFunc)}
熱更新啟動參數
-c /etc/istio/proxy/envoy-rev1.json --restart-epoch 1--drain-time-s 45 --parent-shutdown-time-s 60--service-cluster sleep --service-nodesidecar~172.00.00.000~sleep-898b65f84-pnsxr.default~default.svc.cluster.local --max-obj-name-len 189 -l info--v2-config-only
熱更新啟動參數和第一次啟動參數的不同的地方是 -c 和 --restart-epoch,其實-c 只是設定檔名不同,它們的內容是一樣的。--restart-epoch 每次進行熱更新的時候都會自增1,用於判斷是進行熱更新還是開啟一個存在的envoy(這裡的意思應該是第一次開啟envoy)
具體看官方描述
istio.io/istio/pilot/pkg/proxy/agent.go #258func (a *agent) reconcile() { ...... // discover and increment the latest running epoch epoch := a.latestEpoch() + 1 // buffer aborts to prevent blocking on failing proxy abortCh := make(chan error, MaxAborts) a.epochs[epoch] = a.desiredConfig a.abortCh[epoch] = abortCh a.currentConfig = a.desiredConfig go a.waitForExit(a.desiredConfig, epoch, abortCh)}
從終端截取觸發熱更新的日誌
2018-04-24T13:59:35.513160Z info watchFileEvents: "/etc/certs//..2018_04_24_13_59_35.824521609": CREATE2018-04-24T13:59:35.513228Z info watchFileEvents: "/etc/certs//..2018_04_24_13_59_35.824521609": MODIFY|ATTRIB2018-04-24T13:59:35.513283Z info watchFileEvents: "/etc/certs//..data_tmp": RENAME2018-04-24T13:59:35.513347Z info watchFileEvents: "/etc/certs//..data": CREATE2018-04-24T13:59:35.513372Z info watchFileEvents: "/etc/certs//..2018_04_24_04_30_11.964751916": DELETE
搶救envoy
envoy是一個服務,既然是服務都不可能保證100%的可用,如果envoy不幸運宕掉了,那麼pilot-agent如何進行搶救,保證envoy高可用?
擷取退出狀態
在上面提到pilot-agent啟動envoy後,會監聽envoy的退出狀態,發現非正常退出狀態,就會搶救envoy。
func (proxy envoy) Run(config interface{}, epoch int, abort <-chan error) error { ...... // Set if the caller is monitoring envoy, for example in tests or if envoy runs in same // container with the app. if proxy.errChan != nil { // Caller passed a channel, will wait itself for termination go func() { proxy.errChan <- cmd.Wait() }() return nil } done := make(chan error, 1) go func() { done <- cmd.Wait() }() ......}
搶救envoy
使用 kill -9 可以類比envoy非正常退出狀態。當出現非正常退出,pilot-agent的搶救機制會被觸發。如果第一次搶救成功,那當然是好,如果失敗了,pilot-agent會繼續搶救,最多搶救10次,每次間隔時間為 2 n
100 time.Millisecond。超過10次都沒有救活,pilit-agent就會放棄搶救,宣布死亡,並且退出istio/proxy,讓k8s重新啟動一個新容器。
istio.io/istio/pilot/pkg/proxy/agent.go #164func (a *agent) Run(ctx context.Context) { ...... for { ...... select { ...... case status := <-a.statusCh: ...... if status.err == errAbort { //pilot-agent通知退出 或 envoy非正常退出 log.Infof("Epoch %d aborted", status.epoch) } else if status.err != nil { //envoy非正常退出 log.Warnf("Epoch %d terminated with an error: %v", status.epoch, status.err) ...... a.abortAll() } else { //正常退出 log.Infof("Epoch %d exited normally", status.epoch) } ...... if status.err != nil { // skip retrying twice by checking retry restart delay if a.retry.restart == nil { if a.retry.budget > 0 { delayDuration := a.retry.InitialInterval * (1 << uint(a.retry.MaxRetries-a.retry.budget)) restart := time.Now().Add(delayDuration) a.retry.restart = &restart a.retry.budget = a.retry.budget - 1 log.Infof("Epoch %d: set retry delay to %v, budget to %d", status.epoch, delayDuration, a.retry.budget) } else { //宣布死亡,退出istio/proxy log.Error("Permanent error: budget exhausted trying to fulfill the desired configuration") a.proxy.Panic(a.desiredConfig) return } } else { log.Debugf("Epoch %d: restart already scheduled", status.epoch) } } case <-time.After(delay): ...... case _, more := <-ctx.Done(): ...... } }}
istio.io/istio/pilot/pkg/proxy/agent.go #72var ( errAbort = errors.New("epoch aborted") // DefaultRetry configuration for proxies DefaultRetry = Retry{ MaxRetries: 10, InitialInterval: 200 * time.Millisecond, })
搶救日誌
Epoch 6: set retry delay to 200ms, budget to 9Epoch 6: set retry delay to 400ms, budget to 8Epoch 6: set retry delay to 800ms, budget to 7
優雅關閉envoy
服務下線或升級我們都希望它們能很平緩的進行,讓使用者無感知 ,避免打擾使用者。這就要服務收到退出通知後,處理完正在執行的任務才關閉,而不是直接關閉。envoy是否支援優雅關閉?這需要k8s,pilot-agent也支援這種玩法。因為這存在一種關聯關係k8s管理pilot-agent,pilot-agent管理envoy。
k8s讓服務優雅退出
網上有篇部落格總結了k8s優雅關閉pods,我這邊簡單介紹下優雅關閉流程:
- k8s 發送 SIGTERM 訊號到pods下所有服務的1號進程
- 服務接收到訊號後,優雅關閉任務,並退出
- 過了一段時間(default 30s),如果服務沒有退出,k8s會發送 SIGKILL 訊號,讓容器強制退出。
pilot-agent 讓envoy優雅退出
pilot-agent會接收syscall.SIGINT, syscall.SIGTERM,這2個訊號都可以達到優雅關閉envoy的效果。
istio.io/istio/pkg/cmd/cmd.go #29func WaitSignal(stop chan struct{}) { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs close(stop) _ = log.Sync()}
在golang有一個上下文管理組件
context
,這個包通過廣播的方式通知各子服務執行關閉操作。
istio.io/istio/pilot/cmd/pilot-agent/main.go #242ctx, cancel := context.WithCancel(context.Background())go watcher.Run(ctx)stop := make(chan struct{})cmd.WaitSignal(stop)<-stop//通知子服務cancel()istio.io/istio/pilot/pkg/proxy/agent.gofunc (a *agent) Run(ctx context.Context) { ...... for { ...... select { ...... //接收到主服務語音總機envoy退出 case _, more := <-ctx.Done(): if !more { a.terminate() return } } }}istio.io/istio/pilot/pkg/proxy/envoy/v1/watcher.go #297func (proxy envoy) Run(config interface{}, epoch int, abort <-chan error) error { ...... select { case err := <-abort: log.Warnf("Aborting epoch %d", epoch) //發送 KILL訊號給envoy if errKill := cmd.Process.Kill(); errKill != nil { log.Warnf("killing epoch %d caused an error %v", epoch, errKill) } return err ...... }}
上面展示了pilot-agent從k8s接收訊號到通知envoy關閉的過程,這個過程說明了poilt-agent也是支援優雅關閉。但最終envoy並不能進行優雅關閉,這和pilot-agent發送KILL訊號沒關係,這是因為envoy本身就不支援。
envoy優雅關閉
來到這裡很遺憾通知你envoy自己不能進行優雅關閉,envoy會接收SIGTERM,SIGHUP,SIGCHLD,SIGUSR1這4個訊號,但是這4個都與優雅無關,這4個訊號的作用可看官方文檔。當然官方也注意到這個問題,可以到github瞭解一下2920 3307。
其實使用優雅關閉想達到的目的是:讓服務平滑升級,減少對使用者的影響。所以我們可以用金絲雀部署來實現,並非一定要envoy實現。大致的流程:
- 定義服務的舊版本(v1),新版本(v2)
- 發布新版本
- 將流量按照梯度的方式,慢慢遷移到v2
- 遷移完成,運行一段時間,沒問題就關閉v1
藉此機會瞭解下golang的優雅關閉,golang在1.8版本的時候就支援這個特性
net/http/server.go #2487func (srv *Server) Shutdown(ctx context.Context) error { atomic.AddInt32(&srv.inShutdown, 1) defer atomic.AddInt32(&srv.inShutdown, -1) srv.mu.Lock() // 把監聽者關掉 lnerr := srv.closeListenersLocked() srv.closeDoneChanLocked() //執行開發定義的函數如果有 for _, f := range srv.onShutdown { go f() } srv.mu.Unlock() //定時查詢是否有未關閉的連結 ticker := time.NewTicker(shutdownPollInterval) defer ticker.Stop() for { if srv.closeIdleConns() { return lnerr } select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } }}
其實golang的關閉機制和envoy在github上討論優雅關閉機制很相似:
golang機制
- 關閉監聽者(
ln, err := net.Listen("tcp", addr)
,向ln賦nil)
- 定時查詢是否有未關閉的連結
- 所有連結都是退出,服務退出
envoy機制:
- ingress listeners stop accepting new connections (clients see TCP connection refused) but continues to service existing connections. egress listeners are completely unaffected
- configurable delay to allow workload to finish servicing existing connections
- envoy (and workload) both terminate