## 什麼是優雅重啟在不停機的情況下,就地部署一個應用程式的新版本或者修改其配置的能力已經成為現代軟體系統的標配。這篇文章討論優雅重啟一個應用的不同方法,並且提供一個功能獨立的案例來深挖實現細節。如果你不熟悉 Teleport 話,Teleport 是我們使用 Golang 針對彈性架構設計的 [SHH 和 Kubernetes 特權訪問管理解決方案](https://gravitational.com/teleport/)。使用 Go 建立和維護服務的開發人員和網站可靠性工程師(SRE)應該對這篇文章有興趣。## SO_REUSEPORT vs 複製通訊端的背景為了推進 Teleport 高可用的工作,我們最近花了些時間研究如何優雅重啟 Teleport 的 TLS 和 SSH 的連接埠監聽器[(GitHub issue #1679)](https://github.com/gravitational/teleport/pull/1679)。我們的目標是能夠更新一個 Teleport 二進位檔案而不需要讓執行個體停止服務。Marek Majkowski 在他的部落格文章[《為什麼一個 NGINX 背景工作執行緒會承擔所有負載?》](https://gravitational.com/teleport/) 討論了兩種普遍的方法。這些方法可以被如下概括:* 你可以在通訊端上設定 `SO_REUSEPORT` ,從而讓多個進程能夠被綁定到同一個連接埠上。利用這個方法,你會有多個接受隊列向多個進程提供資料。* 複製通訊端,並把它以檔案的形式傳送給一個子進程,然後在新的進程中重新建立這個通訊端。使用這種方法,你將有一個接受隊列向多個進程提供資料。]在我們初期的討論中,我們瞭解到幾個關於 `SO_REUSEPORT` 的問題。我們的一個工程師之前使用這個方法,並且注意到由於其多個接受隊列,有時候會丟棄掛起的 TCP 串連。除此之外,當我們進行這些討論的時候,Go 並沒有很好地支援在一個 `net.Listener` 上設定 `SO_REUSEPORT`。然而,在過去的幾天中,在這個問題上有了進展,看起來像 [Go 不久就會支援設定通訊端屬性](https://github.com/golang/go/issues/9661)。第二種方法也很吸引人,因為它的簡單性以及大多數開發人員熟悉的傳統Unix 的 fork/exec 產生模型,即將所有開啟檔案傳遞給子進程的約定。需要注意的一點,`os/exec` 包實際上不贊同這種用法。主要是出於安全上的考量,它只傳遞 `stdin` , `stdout` 和 `stderr` 給子進程。然而, os 包確實提供較低級的原語,可用於將檔案傳遞給子程式,這就是我們想做的。## 使用訊號切換通訊端進程所有者在我們看源碼之前,瞭解一些這個方法如何工作的細節是值得的。啟動一個全新的 Teleport 程式後,該進程會在綁定的連接埠上建立一個監聽通訊端接受所有入站流量。對於 Teleport,入口流量就是 LTS 和 SSH 流量。我們添加了一個處理 [SIGUSR2](https://www.gnu.org/software/libc/manual/html_node/Kill-Example.html) 訊號的控制代碼,該控制代碼讓 Teleport 複製監聽通訊端,然後產生一個新的進程,同時將監聽通訊端以檔案的形式和這個通訊端的中繼資料以環境變數的形式傳入給該進程。一旦新的進程開始,他會依據傳進來的檔案和中繼資料重建這個通訊端,並且處理它所獲得的流量。應該注意的是,當一個通訊端被複製時,入棧流量會在兩個通訊端之間以輪詢的方式進行負載平衡。如所示,這就意味著有一段時間,兩個 Teleport 進程都會接受新的串連。![](https://raw.githubusercontent.com/studygolang/gctt-images/master/gracefully-restart-a-go-program-without-downtime/graceful-restart-diag-1.png)父進程的關閉是相同的事情,但是反過來做。一旦 Teleport 進程接受到 SIGOUIT 訊號,他會開始關閉這個進程,停止接受新的串連,等待所有的現有串連斷開或是逾時發生。一旦入站流量被清空,這個瀕死進程就會關閉它的監聽通訊端並且退出。這種情況下,新的進程會接管核心發送過來的所有請求。![](https://raw.githubusercontent.com/studygolang/gctt-images/master/gracefully-restart-a-go-program-without-downtime/graceful-restart-diag-2.png)## 優雅重啟演練我們基於上面的方法寫了一個簡單的程式,你可以自己嘗試使用一下。原始碼在文章的最後,你可以按照以下步驟嘗試這個例子。首先,編譯和啟動程式。```$ go build restart.go$ ./restart &[1] 95147$ Created listener file descriptor for :8080.$ curl http://localhost:8080/helloHello from 95147!```將 USR2 訊號發送給初始進程。現在,當你訪問這個 HTTP 入口的時候,他會返回兩個不同的進程的 PID。```$ kill -SIGUSR2 95147user defined signal 2 signal received.Forked child 95170.$ Imported listener file descriptor for :8080.$ curl http://localhost:8080/helloHello from 95170!$ curl http://localhost:8080/helloHello from 95147!```殺死初始進程後,你將只會從新的進程中獲得返回。```$ kill -SIGTERM 95147signal: killed[1]+ Exit 1 go run restart.go$ curl http://localhost:8080/helloHello from 95170!$ curl http://localhost:8080/helloHello from 95170!```最後殺死新進程,訪問將會被拒絕。```$ kill -SIGTERM 95170$ curl http://localhost:8080/hellocurl: (7) Failed to connect to localhost port 8080: Connection refused```## 總結和樣本原始碼像你看到,一旦你瞭解了他是如何工作的,增加優雅重啟功能到 Go 寫的服務中是相當簡單的事情,並且有效地提高服務使用者的使用者體驗。如果你想在 Teleport 中看到這一點,我們邀請你瞧瞧我們的參考 [AWS SSH 和 Kubernetes Bastion Host部署](https://github.com/gravitational/teleport/tree/master/examples/aws),裡麵包含了一個 ansible 指令碼,該指令碼利用就地優雅重啟實現無停機更新 Teleport 二進位檔案。[Golang 優雅重啟案例原始碼](https://gist.github.com/russjones/09e7ace4c7497515f6bd0285f710c2e4)```gopackage mainimport ("context""encoding/json""flag""fmt""net""net/http""os""os/signal""path/filepath""syscall""time")type listener struct {Addr string `json:"addr"`FD int `json:"fd"`Filename string `json:"filename"`}func importListener(addr string) (net.Listener, error) {// 從環境變數中抽離出被編碼的 listener 的中繼資料。listenerEnv := os.Getenv("LISTENER")if listenerEnv == "" {return nil, fmt.Errorf("unable to find LISTENER environment variable")}// 解碼 listener 的中繼資料。var l listenererr := json.Unmarshal([]byte(listenerEnv), &l)if err != nil {return nil, err}if l.Addr != addr {return nil, fmt.Errorf("unable to find listener for %v", addr)}// 檔案已經被傳入到這個進程中,從中繼資料中抽離檔案描述符和名字,為 listener 重建/發現 *os.filelistenerFile := os.NewFile(uintptr(l.FD), l.Filename)if listenerFile == nil {return nil, fmt.Errorf("unable to create listener file: %v", err)}defer listenerFile.Close()// Create a net.Listener from the *os.File.ln, err := net.FileListener(listenerFile)if err != nil {return nil, err}return ln, nil}func createListener(addr string) (net.Listener, error) {ln, err := net.Listen("tcp", addr)if err != nil {return nil, err}return ln, nil}func createOrImportListener(addr string) (net.Listener, error) {// 嘗試為地址匯入一個 listener, 如果匯入成功,則使用。ln, err := importListener(addr)if err == nil {fmt.Printf("Imported listener file descriptor for %v.\n", addr)return ln, nil}// 沒有 listener 被匯入,這就意味著進程必須自己建立一個。ln, err = createListener(addr)if err != nil {return nil, err}fmt.Printf("Created listener file descriptor for %v.\n", addr)return ln, nil}func getListenerFile(ln net.Listener) (*os.File, error) {switch t := ln.(type) {case *net.TCPListener:return t.File()case *net.UnixListener:return t.File()}return nil, fmt.Errorf("unsupported listener: %T", ln)}func forkChild(addr string, ln net.Listener) (*os.Process, error) {// 從 listener 中擷取檔案描述符,在環境變數編碼在傳遞給這個子進程的中繼資料。lnFile, err := getListenerFile(ln)if err != nil {return nil, err}defer lnFile.Close()l := listener{Addr: addr,FD: 3,Filename: lnFile.Name(),}listenerEnv, err := json.Marshal(l)if err != nil {return nil, err}// 將 stdin, stdout, stderr 和 listener 傳入子進程。// 譯註: 以上四個檔案描述符分別為 0,1,2,3files := []*os.File{os.Stdin,os.Stdout,os.Stderr,lnFile,}// 擷取當前環境變數,並且傳入子進程。environment := append(os.Environ(), "LISTENER="+string(listenerEnv))// 擷取當前進程名和工作目錄execName, err := os.Executable()if err != nil {return nil, err}execDir := filepath.Dir(execName)// 產生子進程p, err := os.StartProcess(execName, []string{execName}, &os.ProcAttr{Dir: execDir,Env: environment,Files: files,Sys: &syscall.SysProcAttr{},})if err != nil {return nil, err}return p, nil}func waitForSignals(addr string, ln net.Listener, server *http.Server) error {signalCh := make(chan os.Signal, 1024)signal.Notify(signalCh, syscall.SIGHUP, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGQUIT)for {select {case s := <-signalCh:fmt.Printf("%v signal received.\n", s)switch s {case syscall.SIGHUP:// Fork 一個子進程。p, err := forkChild(addr, ln)if err != nil {fmt.Printf("Unable to fork child: %v.\n", err)continue}fmt.Printf("Forked child %v.\n", p.Pid)// 建立一個在 5 秒鐘過去的 Context, 使用這個逾時定時器關閉。ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()// 返回關閉過程中發生的任何錯誤。return server.Shutdown(ctx)case syscall.SIGUSR2:// Fork 一個子進程。p, err := forkChild(addr, ln)if err != nil {fmt.Printf("Unable to fork child: %v.\n", err)continue}// 輸出被 fork 的子進程的 PID,並等待更多的訊號。fmt.Printf("Forked child %v.\n", p.Pid)case syscall.SIGINT, syscall.SIGQUIT:// 建立一個在 5 秒鐘過去的 Context, 使用這個逾時定時器關閉。ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()// 返回關閉過程中發生的任何錯誤。return server.Shutdown(ctx)}}}}func handler(w http.ResponseWriter, r *http.Request) {fmt.Fprintf(w, "Hello from %v!\n", os.Getpid())}func startServer(addr string, ln net.Listener) *http.Server {http.HandleFunc("/hello", handler)httpServer := &http.Server{Addr: addr,}go httpServer.Serve(ln)return httpServer}func main() {// Parse command line flags for the address to listen on.var addr stringflag.StringVar(&addr, "addr", ":8080", "Address to listen on.")// Create (or import) a net.Listener and start a goroutine that runs// a HTTP server on that net.Listener.ln, err := createOrImportListener(addr)if err != nil {fmt.Printf("Unable to create or import a listener: %v.\n", err)os.Exit(1)}server := startServer(addr, ln)// 等待覆制或結束的訊號err = waitForSignals(addr, ln, server)if err != nil {fmt.Printf("Exiting: %v\n", err)return}fmt.Printf("Exiting.\n")}```## 如果你讀到了這裡Teleport 是一個開源軟體,你可以免費地在 [GitHub](https://github.com/gravitational/teleport) 上深入瞭解它。如果你對 Teleport 或是其他類似的分布式系統軟體的工作有興趣,我們時刻期待著[優秀的軟體工程師](https://gravitational.com/careers/systems-engineer/)。
via: https://gravitational.com/blog/golang-ssh-bastion-graceful-restarts/
作者:RUSSELL JONES 譯者:magichan 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
386 次點擊 ∙ 1 贊