使用 Go 構建 Resilient Services - 技術會談

碼頭工人 8年前發布 | 19K 次閱讀 Go語言 Google Go/Golang開發

使用 Go 構建 Resilient Services - 技術會談

這是一篇在 GopherCon 2015 的技術會談,主講人 Blake Caldwell 曾是 Fog Creek 里 Kiln 團隊的軟件工程師,他將講述如何使用 Go 來重寫的我們的 SSH 反向代理, KilnProxy,達到了性能的提升。 一起來聽聽他怎樣來重寫服務的,在將git clone時間減半的同時, 保證了服務質量更加穩定和可靠。

Blake 在他的博客寫一些關于 Go 語言和軟件開發的文章。他已經開源了在會談里提到的分析器 profiler,會談的幻燈片也放在了他的GitHub上。

視頻鏈接:https://youtu.be/PyBJQA4clf

關于 Fog Creek 技術會談

在 Fog Creek 每周都會一個技術性的會談,我們自己的員工上去講或是邀請一些嘉賓。 通常都是一些較短的、不太正式的演講, 內容都是軟件開發工程們感興趣的話題。我們會盡力分享更多給大家的。

內容和時間點

  • 簡介(0:00)

  • 項目背景(0:22)

  • 關于SSH反向代理KilnProxy(1:33)

  • 結果(3:35)

  • 錯誤處理(5:20)

  • Channels(7:17)

  • 異常處理(8:09)

  • 避免競爭(10:00)

  • 超時的實現(11:20)

  • 內存分析(13:44)

  • 日志生成(21:20)

內容抄錄

簡介

Blake:  大家好。 這又是一個代碼重寫的案例,進行地非常順利,所以我想分享給每一個人。和其它的會談差不多, 會談到很多不同內容,但不會就每一個方面深入展開下去。我只能盡可能多的講講使用到的一些工具和技術了, 怎樣來幫忙我實現第一個上線的 Go 實現的服務。

背景

先說說背景吧。我去年在位于 New York 的 Fog Creek 工作。你有聽說過 Fog Creek 的話,就可能很熟悉 Kiwi 了,它是 FogBugz 的吉祥物。我之前工作的項目是 Kiln。 Kiln 是 Fog Creek 提供的 Git 和Mercurial 的源代碼托管服務,和 GitHub 類似的一個東東,不過同時支持 Git 和 Mercurial。 我在那里工作之前,連 Mercurial 都沒聽說過, 但是現在已經非常熟悉了。我的大部分工作是使用 C# 和 Python,想對底層的一些東西總是非常感興趣。

去年我也很幸運的參見了 Google I/O。當時我完全不知道 Go 是什么,實際上我還沒有聽說過它。現在你在哪兒都可以看到它,它哪里都有。我不需要想你解釋我為什么這么快就愛上它。只要有機會我就去參加所有的 Go 講座,我去見一些 Go 的作者,非常爽。我知道如果有什么問題出現,總有一天會出現相應的解決方案。

我想用 Go 實現些神奇的東西。我想在業余時間用它做些興趣相關的,有意思的事情。但是我想在工作中證明,Go 可以做些非常神奇的東西,讓 Kiln 變的更好。我找了下有什么東西可以重寫,你總可以找到些東西重寫。最后決定重寫我們的 SSH 反向代理。當我開始重寫它的時候,我還不知道反向代理是個什么東東。

關于 SSH 反向代理 KilnProxy

還是先說說背景,看它到底是來做什么的。當你使用 Git 或者 Mercurial 時,有兩種方式和遠程服務器交互, 使用 HTTP 或者 SSH。 SSH 使用到了公私密鑰對,相對的會更加安全一些。 假如你正在用 SSH 從Kiln 克隆代碼倉庫的話,那就肯定和 KilnProxy 打交道了。你是左邊這部分人中的其中一個。 和KilnProxy 交互時, KilnProxy 需要認證你的 Key,來確定正在交互的這個人就是你,而不是其它人;然后它會查找你的代碼到底放在哪里,我們的后臺服務器太多了, 所以需要確定你的代碼是在哪臺服務器上的。它和后臺服務器建立連接,同時也有一個到你那邊的連接,那所做的工作就是把交互的數據傳過來然后再傳回去。

它已經能夠很好的工作了,為什么還要重寫?當時的情況是,使用 SSH 來克隆比 HTTP 要慢好多, 應該不慢才對。無論怎么看,使用 SSH 應該要更快。同時還遇到了一些穩定性的問題,我們的系統管理員必需要每天重啟服務。可以想像一下,你有一個具大的代碼倉庫且你正在克隆著,20多分鐘已經耗進去了,而這時恰好撞到了我們重啟服務器,你耗的那些時間都白費了,全部得從頭再來。這是很讓人崩潰的。

事實證明這個項目很適合用 Go 來實現 ,因為有太多太多的并發了。 大概講下,以便大家有個概念。當你第一次連接的時候,會進入監聽循環,開啟了一個 Go routine。 這個 Go routine 會先負責驗證你公私密鑰對,或者是私鑰;然后再和后服務器建立連接。一旦連接建立起來了,相應的 Go routine 還會代理標準輸入、標準輸出和標準錯誤輸出 。

結果

結果怎么呢?非常的好,出人意料。我只是想讓它能正常工作,它卻運行的非常好。那到底有多好。在幾分鐘的時間內,我們就可以克隆一個小的代碼倉庫,跟蹤它所使用的時間。就在這點是我們從 python 實現轉換到了 Go 的實現。 先讓你們猜下是哪個點, 就是粉色的標記過去一點點。這是我們重寫后剛上線的點,從此以后, SSH 和 HTTP 同樣的快了。

可能你以前就注意到了,1MB 的倉庫之前花費的時間是 1.5s, 重寫后只需要 0.75s 了。速度差不多是以前的兩倍了。當倉庫變得越得越來越大時,效果就更加顯示了。同時遇到問題也少了。現在線上運行的就是一個更快、更穩定、很少出問題的服務了。

作為我的第一個用 Go 實現的服務,必須講講怎么用 Go 來實現一個穩定的服務了,也分享一些 Tips。現在講的主題是彈性吧?就先要來看看彈性服務的要求,不能崩潰,也不需要每天重啟;沒有內存泄漏,不會掛起,也不能卡住不動了。很顯示,這無疑將是個很長的流程,沒有銀彈。這個流程將覆蓋從在上線之前的開發、調試剖析,以及上線之后的服務監控。

錯誤處理

現在開始討論錯誤處理,還好,沒有太多的演講者說這個…不得不說我們必須要處理每一個錯誤。我感覺在座的每一位都知道要處理每個出現的錯誤。我是用 java 的,在 java 里只用代碼中用極簡短的異常代碼來處理錯誤,但我并不關心它們(異常) 被拋出,也不關心怎么處理它們。在 java 里我們根本不需要關心這些。

我們都明白這是一種模式。你從一個函數中獲取一個資源或者一個返回值,還有對應的錯誤信息,并且有錯誤的話你不能繼續使用返回值。之后我們需要檢查這個錯誤是否發生,如果有錯誤我們就跳出這個函數。并且需要執行清理或關閉資源。有一件我非常確定的事就是,在視覺上不把這一小塊代碼寫在新的一行里。我想看到的是這塊代碼是一個完整的單元,是不可分解的。

看起來這里有處理所有的錯誤,所以沒有問題。眾所周知,有些時候我們應該檢查下空值(Nil)。回到我們的例子,假如 OpenResourceA 能夠返回空值,并且這不是一個出錯狀態?也許這是一種不常見的情況:試圖打開某個資源的時候,由于某些原因,掉線了。從技術上來說,這并不是一個錯誤。在 defer 語句中,可能就要慌了。

不過倒也未必,我們當然有辦法避免這個,一種做法就是使用一個內聯函數、一個匿名函數。我們可以在那里完成我們的小檢查,判讀不是空值,再關閉。這種方法的一個問題就是它很拙劣。我不是很喜歡這種做法。是否所有人都能看得清楚這個?我不知道這是否足夠大了。


我喜歡用 defer 語句來處理需要推遲處理的方法,我喜歡簡潔的 defer 語句,希望它能簡單、整潔。有一件情況我最近差點忘記,如果一個 struct 方法需要傳入一個 struct 指針,但是如果這個指針為空,此方法仍然會接受這個指針并且被調用。這種情況下,我通常采用 defer 語句,并且在語句中清理資源。我通常會檢查空指針,回到前面提到的 DeferResourceA.close 方法,那么它實際上就是空指針的情況。我發現這種實踐非常好。

Channels

讓我們來聊聊 channels。我們熟悉 channels 并且知道它能給我帶來很多樂趣,它很棒,但是如果你不知道正在用它做什么,就很有可能給你帶來麻煩。我不準備在這里深入的探討 channels,因為往深了講需要半天的時間,但是我可以在這里給大家一個參考,那就是 Dave Cheney 的博客,它指引我度過一段很艱難的時間。每次我遇到 channel,我都會去看看這個叫“Channel 原理”的文章。我還把它記在了別處,因為我要確保不弄錯 channels。

有三點我想在這里著重說一說。如果對一個空 channel 進行寫入或者讀取操作,它將會永遠阻塞;當你在一個 go-routine 下遇到永遠阻塞,那么這個 go-routine 就不會退出,相應的資源(比如生成的局部變量等)也就得不到回收;當你對一個已經關閉的 channel 進行操作,你就會被 panic。

處理 Panic

讓我們來聊聊 panic。它們通常或者說大部分是由于編程錯誤引起的,如果遇到 panic,程序將會中斷服務。如果我說有時我甚至喜歡從 panic 當中處理恢復,這有可能會讓大概在座的一半的人感到困惑。這個問題富有爭議,人們會說“這個程序員的錯誤,如果你有一個程序錯誤,那就應該讓程序終止,并修復它”。

說的沒錯,但是我確實會犯錯,而且錯誤也會在生產環境下發生,我希望能夠盡可能的減輕它帶來的影響。不需要過多的深究問題所在,你就能從 panic 中恢復。你不應該把它當做異常,這就是我的處理方式。當我建立那個函數的時候,你們看到了出錯的地方,但是我會保證在函數返回之前,所有資源都會被妥善清理,采用這種方式,就算 panic 出現,所有清理工作也會得到進行。

我試著限制代碼的區域,我在邊上的一些區域設置了代碼,讓人驚訝的事情就在這些地方發生,我試著捕捉它們,我記錄日志,并非常認真地對待它,還試著修復那些 bug。讓我來舉個例子。SSH 代理是非常復雜的。如果我為人誠實,我不會特意在事前把 SSH 所有的內容讀一遍。我們有個客戶使用特定構建的服務器,并以某種方式使用 Git,這是后來我才知道的。因為我們成百上千的客戶都沒有這樣的問題,而他的崩潰了。

如果我讓這個恐慌蔓延到生產,就會著火,就會中斷,不得不一次又一次地中斷服務。在那個例子中,我處理了恐慌,在先前的例子中,僅處理一個客戶端的請求。在高的層次,頂級的層次上,如果在主循環中出現問題,那它就會崩潰。

避免競態條件

讓我們假設這里沒有競態檢測器。除了競態條件,我不需要描述太多,當你有并發性問題時,你就會遇到競態問題。此外,我說過我在 Java 中使用過所有并發的庫,我知道所有這些在 Java 中存在著的工具,在過去十年里我一直使用 Java 工作,但是我從來沒看到過。

在 Go 里面這變得非常簡單,因為它在 Go 里面是主要的套件。就像我們早先聽到的那樣,在一份報表中,一個變量的訪問并不是同步的,當它發生時,它會崩潰,并向您展示完整的堆棧跟蹤,包括讀和寫的確切位置,在單元測試和集成測試期間你還可以使用,開發。

同樣的,這里有輸出。能夠跟蹤到這種情況的發生是很棒的,因為這個 bug 有可能一直都不會發生。我們可以在 race.go 文件中看到錯誤的發生是在第 14 行代碼試圖讀取,而第 15 行正要寫入。這個迭代持續數分鐘才能得到解決。但是,你可以在測試、運行或者構建、安裝的時候在命令行加入 -race 來開啟跟蹤。

加入超時機制

實現超時,我們需要注意幾種情況,最重要的是網絡超時。在我們的軟件中,我們的程序需要經常連接遠程服務。因此,我們需要撥號、連接再傳輸數據之后才會完成任務。

最好的實踐是,如果你準備撥號,那么你應該設置一個超時時間。如果你看過標準庫,這種函數通常都會設置一個超時。假如說 2 秒內撥通另一個服務器是合理的,那么將其設置為 20 秒也沒關系,這樣可以保證不被永遠掛起。但是假如將 20 秒再提高到 100 秒,并且你一直嘗試撥號卻沒有得到響應,那么就有可能由于內存不足而導致程序崩潰。

一旦你撥通了,你應該連接過去,并且設置一個連接超時時間。假如你在連接超時時間內,比如說 10 秒或者 50 秒,沒有傳輸或者接收到任何數據,那么你就應該關閉這個連接并且記錄下來。假如說連接保持 1 分鐘比較合理,那么 5 分鐘之后才超時就顯得比較荒唐了。

下一個,不言而喻,但除了測試,其他的不想深入,因為測試相當重要。對我來說,在解決完一個問題之后,我不想再記住這些事情,在我測試完時,我喜歡設置一個后部斷點,然后我移交代碼給別人或者未來的我,以此來防止這些 bug. 在這里,我給你舉個例子,我十分熱衷于集成測試,如果你并沒有這些方面的測試經驗,在 Docker 中,你可以期待。

以 KilnProxy 為例,當我們談論 SSH 時,會有許多將使用 Mercurial 與 Git。我所設置的是在一個 Docker 容器中,以及我在 Docker 容器中運行 Docker 圖像的一個環境,實際上,通過我的  KilnProxy 代碼,我將獲取一個運行服務器的所有命令。因此,我正試著 git pool, git clone, git push,所有的那些東西。與 Mercurial 相同。好的方面是可能會有 Git 新版本要發布啦,剛好我們有一個單獨的使用 git 新版本的容器實例,并且我們并發運行所有的測試,以確保我們的代理不會被打破。

工具

前面講了開發。接下來講一下利用測試來觀察一下我們的服務正在做一些什么。讓我們從“程序是怎么利用內存的?”說起,我們會分析它,并且像很多在座的觀眾一樣,我們做了一個分析軟件,可以到 Fog Creek 的頁面查看。盯著它看很有趣,但是我沒有做很多動畫效果,只是每秒刷新一下,它可以顯示出與此服務有關的內存有多少,其中多少正在被使用以及多少正在被回收。藍紫色的線條代表已經準備好被回收的內存,有時系統會重新利用它,有時不會。

你能夠很快的察覺到那些重要信息的變化,你可能會觀察有多少內存在系統空閑的時候被占用,這取決于你自己,可能還想看看有多少內存在連接進來的時候被占用,以及內存被使用和釋放之后,系統是否回收了內存,這取決于系統本身以及內存的壓力。如果你想深入的了解一下,你可以在 GODEBUG 環境下運行程序,并設置 gctrace=1,來看看垃圾回收器在做些什么。觀察它的行為很有趣。

同樣的,你是不是也想看看內存是在哪里生成的?內存是怎么被用的?這就是 PPROF 的工作了。我們在會上已經看到了它,如果你還沒有使用它,那么嘗試一下,它會改變你的生活。它相當的棒,它可以讓你看到你的服務阻塞分析,goroutine 的數量,棧追蹤,堆分析以及線程創建工程中的棧追蹤。

聽起來挺底層的,但是使用它卻很簡單。你只要引入這個包,然后它就會自動幫你建立起一些 HTTP 節點,你需要選擇一個端口和 IP,接著就是監聽了。設置完成,打開網頁就可以看到真正的輸出結果了。我們已經看到很多有用的信息了,我們可以看到當前有 32 個 goroutine 正在被使用,這是最基本的,也就是說沒有任何用戶連接的情況下,系統需要 32 個 goroutine。

在前面提到的,不要讓 goroutine 產生泄漏十分重要。使用 PPROF,我們知道了在沒有連接情況下有多少個 goroutine,接著我們可以連接一個用戶,保持連接打開狀態來看一下又有多少個 goroutine 了,不妨再看看 100 或者 1000 個連接的情況,你可以讓它跑一個晚上,等所有的用戶斷開連接之后再看看有多少個 goroutine,如果現在是 33 個了,那么可能就有 bug 需要修復了。

假設我們現在有 33 個 goroutine,那么哪一個是多出來的?看一下最初的頁面,點擊 goroutine 可以看到全部的棧追蹤信息。我們都看過這些信息,但是悲劇的是,大多數情況下,我們的服務是崩潰的。這里我們看到的是正在運行的追蹤信息,刷新界面,下一個截圖信息會告訴我們正在發生什么。能夠看到 goroutine 的分配情況是很棒的。

從網頁上看PPROF的內容確實很不錯,但是它更厲害的地方在于命令行,你可以利用本地的命令行來執行。它會連接web節點,這些節點會產生一些額外的消耗,它也會解釋這個問題。在命令行,我們可以在服務器之外運行go tool PPROF的可執行程序,同時我們會為goroutine的頁面分配一個HTTP節點。

現在,我們進入操作終端。你可以輸入top five來查看最上面5個goroutine的等待位置。想看9個或10都是可以的。我想展示的更有意思的事情是,在你安裝了一些插件之后,你可以在web頁面來輸入。它會在瀏覽器內展示你的命令棧以及這些goroutine的來歷,這在你的服務器變得龐大的時候也會簡化你的工作,這是個SVG圖,所以你還可以在瀏覽器內縮放。你會跟蹤事情的發生,從而弄明白goroutine在響應哪些具體的請求。

同樣的,PPROF 還可以幫助你查看堆內存的情況。同樣的,在我可執行程序的服務器上運行go tool PPROF,然后查看內存。我運行top five,我最大的擔心是我的分析器,當我看到在全部2.3M內存的情況下,它只使用了1.8M的時候,我就不擔心了,因為這就是我所期待的。你還可以像以前一樣更深的挖掘一些內容,你可以在web內輸入命令,把SVG圖調出來等。這個用來展示的例子太簡單了,你實際的會更復雜一些,你可以看到調用棧,調用跟蹤以及相關的內存是如何分配的。

在很多情況下,你會看到大量的內存調用,它可能不是在你的程序內,而是在其它你引用的庫里面。你可以更深的挖掘一下,或者你可以給它打個補丁修復一下那個庫,不管怎么樣,知道它如何運行總是有收獲的。

現在,我們開發了系統,在測試的時候進行分析,我們知道它的具體行為,并且把它部署到了生產環境。知道我們部署了什么是當務之急,我有時不太信任的部署的過程,也不太信任我們正在跟蹤的線上代碼。所以我做了一個只暴露給內網的節點,它會顯示當前版本等一些信息,我讓它以 unix time 的形式顯示啟動時間和服務器的當前時間,這樣就不用擔心時區問題了。你可以計算一下運行時間,出于閱讀的方便,我已經把這個格式化好了,當前運行了 167 小時 10 分鐘 2 秒,看著這個數字逐漸增加,那心情老好了。

接下來簡單說一下版本號,我認為這是一個小技巧。我喜歡用多樣化的版本號,因為版本號可以代表很多東西。你可以發布主、次版本,但是版本號要隨著每次部署變得越來越大。你還可以拿到 Git 提交時的 SHA 碼,如果代碼已經部署上去,然后有一個人過來問你“那個 bug 修復了嗎?是不是已經用于生產環境?”,那么你就可以通過 SHA 碼揀出代碼,然后看看 Git 的 log,就可以知道 bug 是否被修復了。不僅如此,我還可以知道代碼構建的時間。

來講一講我們是怎樣生成版本號的,顯然我們不能期待人們能夠看懂提交上來的 Git SHA 碼,這也是不合理的。我們在這里利用 Go 的全局變量,變量名起個與服務版本號相關的任意名字,之后就可以在編譯的時候從命令行或者編譯腳本里面設置這個變量。我們代碼提交之后,SHA 碼就已經生成,我們可以在編譯腳本中利用 -ld 標記來將這個字符串設置到主版本號中。

當你要部署到10臺、100臺甚至1000臺實例上的時候,這將會非常有用,你可以保證它們都運行在正確的版本。現在我們再看一下服務的運行情況,再監控一下服務的狀態。

日志

明顯地,保持好的日志。我們都喜歡日志并且我在這分享一個小提示,我確定這不是我的發明,當收到一個新的請求,拿出一個半隨機數字符串,通過所有方式傳遞給所有函數或者使用的上下文或者一些需要傳遞它的東西。以至于你可以把它作為每個日志條目的前綴。這是很有用的因為當你有成千的用戶同時連接上來的話,再看日志,那是非常混亂的。可以使用 Grep很容易地找到一個連接上發生了什么。

接下來,我們想知道當前都誰連接上了。這可能對很多在 50 毫秒內響應的服務沒什么用。當你連接到一個很大的庫的時候接可能會用幾分鐘。我關注了很多通信,我想知道誰連接上了。和另外一個調用過連接的終端。假如我有一個用戶,它是 Aviato 賬戶。Erlich Bachman 連上了。看起來好像它說了構建服務器,人們趨向于命名構建服務器筆記本的鑰匙。他已經連接上來 25 分鐘 4 秒了,以至于看起來有點像呆滯的。可能最近我已經注意到這些問題了。我將使用會話主鍵,這是我的日志字符串,我要去瀏覽日志并且精確找到整個會話在做啥。

自從我開始跟蹤有多少用戶在連接,我就可以實現等待連接完成,然后關閉連接。我以前從沒想過這個,我曾經跟系統管理員聊過,他們告訴過我這個。這也就是說當系統管理員由于系統升級或者重啟想停用 App、終止服務或者重啟服務,我們需要先把當前的連接服務完,然后再關閉。在 Go 語言上,需要監聽終止信號(sigterm),一旦終止信號被捕獲,就停止接收新連接的請求,只等待所有當前請求執行完。

開搞!然后為了實現這種響應式的服務,很快就遇到了一個問題...起初,一切都還不錯,但是系統管理員得到一個來自 Nagios 的警告,他們告訴我說“Blake,KilnProxy 崩了,它比平時多用了 40M 的內存”。我們都認為 kilnProxy 不會崩,我馬上看了一下分析工具,但是確實是崩了。我能夠看到內存一直在飆升,而且內存仍然在使用。

讓我們更深入的來挖掘一下這個問題所在,我從我的節點察看了連接頁。可以看到 Initech 被連接了十次。Peter Gibbons 在做了他不該做了,犯了一個錯誤,其實也是我們的問題。退一步講,在開發時,我知道每個連接大概需要4M的內存。使用 PPROF 之后,我知道了4M內存已經無法在掌控之中了,我不想再更深入的挖掘了,但是這基本上就是 SSH 庫內部的問題,加密那一部分。

Wolfram Alpha 的人告訴我,粗略算一下,40M是4M的十倍。客戶服務超出了 Initech 的預期,確實是這樣,他們的構建服務器出現了問題。他們關閉并重啟了構建服務器,我們跟他們一起做了這個事情,他們很高興我們在他們之前發現了他們的問題。因為我加了超時機制,所以我知道這十個連接會被慢慢的關掉,因為我的線上分析工具,我才知道系統在內存壓力之下,所有多余的內存也將被回收再利用。

運行時間:保存。就我所知,服務已經差不多運行了六個月,期間重啟了三到四次。我還沒有檢查過Wolfram Alpha,我認為一天至少一次。它運行得很好,這個一個很好的經驗。基于此,這是我們第一個原型,使用 Go 和 Fog Creek 開發的第一個產品。我們曾經有過很多懷疑。原為它運行了很長時間,一次運行幾個月不用重啟,這使用每一個人都相信這是一個值得探索的技術。對我來說這是一個很好的經歷,我要感謝你們的聆聽。 

 本文由用戶 碼頭工人 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!