在Reddit中程式碼部署的演進

玄學醬發表於2017-10-17
本文講的是在 Reddit 中程式碼部署的演進,

“留意你所演進的方向是重要的,這樣你才能持續不斷向有用的方向發展。”

在 Reddit 我們仍然不斷地部署程式碼。每個工程師都會編寫程式碼,再讓其他人審查這份程式碼,合併程式碼之後再定期把程式碼推到生產環境。這種情形每週經常會發生 200 次而且每次部署從開始到結束都不會超過 10 分鐘。

支援所有這些的系統在這些年不斷演進。讓我們看看在這段時間它是如何改變的(包括沒有改變的部分)。

故事最開始的地方:一致和可重複的部署(2007-2010)

現在系統起源於一個叫做 push 的 Perl 指令碼。在 Reddit 的歷史上,寫這個指令碼的時候和現在是大相徑庭。當時 Reddit 只有一群小會議室就可以容納的工程師隊伍。Reddit 也沒有部署在 AWS。站點執行在固定數目的伺服器上,如果要增加站點處理能力就需要手動新增機器,整個站點是由一個叫做 r2 的大型單體 Python 應用組成。

一直到現在都沒有改變的事是請求在負載均衡器上會被分類並被分配到其它獨立應用伺服器特殊的 “請求池” 中。比如說,listing 和 comment 頁面是在不同的請求池裡處理。雖然任何給定的 r2 程式都可以處理任何型別的請求,單個池與其他池的請求高峰是隔離的,並且當它們有不同的依賴關係時,每個池的失敗也是隔離的。

push 工具在程式碼裡有一個硬編碼的伺服器列表並且它是圍繞單個應用部署的過程所構建的。它將會遍歷所有的應用伺服器,使用 SSH 登入到那臺機器,執行一系列預設的命令來通過 git 更新伺服器上的程式碼副本,然後重啟所有的應用程式。實際上過程如下(大量簡化,不是真實的程式碼):

# build the static files and put them on the static server
`make -C /home/reddit/reddit static`
`rsync /home/reddit/reddit/static public:/var/www/`

# iterate through the app servers and update their copy
# of the code, restarting once done.
foreach $h (@hostlist) {
    `git push $h:/home/reddit/reddit master`
    `ssh $h make -C /home/reddit/reddit`
    `ssh $h /bin/restart-reddit.sh`
}

整個部署過程是順序的。它一個接一個的在伺服器上完成它的工作。就像聽起來那麼簡單,這實際上是一件很棒的事情:它允許一定形式的金絲雀部署。如果你部署了少數伺服器的時候一個新的異常突然出現,這時你知道引入了一個 bug 就可以馬上中斷(Ctrl-C)部署並且回滾之前已經部署的伺服器,這樣就不會影響全部的請求。因為部署的簡單性,我們可以很輕易的在生產環境嘗試新事物並且在它不工作的情況下也可以很輕鬆的還原到之前狀態。這也意味著在同一時間內只執行一次部署是很有必要的,這可以保證新的錯誤是源自於的部署而不是其他人的部署,從而可以很簡單的知道何時以及哪裡需要回滾。

這些對於確保確保一致和可重複的部署都非常重要。它執行的很快。一切都很美好。

一大批新的人(2011)

然後我們僱傭了一批工程師,成長到有六個全職工程師的隊伍,現在這個隊伍適合進入一個更大點的會議室。我們開始覺得在部署中需要有更好的協調性,特別是當有個人是在家裡工作的情況。我們修改了 push 工具讓它通過一個 IRC 聊天機器人來宣告部署是什麼時候開始和結束的。這個機器人就存在 IRC 中並宣告部署的事項。部署的過程看起來和以前一樣,只是現在由系統為我們做這個工作並通知每一個人你正在做什麼。

這是我們在部署工作流中第一次使用聊天機器人。在這段時間裡,有很多管理部署系統的會話都源自於聊天機器人,但是因為我們使用的是第三方的 IRC 伺服器,所以我們在生產環境的控制中並不能完全信任這個聊天室,所以它仍然只是單向的資訊流。

在站點流量增加的同時,我們也保證了相應基礎設施的增長。我們偶爾需要啟動新的一系列新的應用伺服器並把它們放到服務中。這仍然是非常手工化的過程,包括更新 push 程式碼中的主機列表。

當我們需要給伺服器擴容時,我們經常會一次增加數個伺服器來增大一個池。其結果是,順序地遍歷伺服器列表會快速地接觸同一個池中的多個伺服器,而不是不同池中的伺服器。

我們使用 uWSGI 來管理工作程式,當我們通知這個應用重啟時,它將會關閉已經存在的程式並且生成新的程式。這個新的程式需要一段時間才能準備好處理請求,並且我們是在同一時間內處理一個池,這將會影響池處理請求的能力。所以我們把部署速度限制到可以保證安全的速度。當伺服器數量增多時,部署的時間也會變長。

一個重構的部署工具(2012)

我們對部署工具進行了一次改革,改成使用 Python 編寫,讓人困惑的是它仍然被叫做 push。這個新版本有一些主要的提升。

首先,它是從 DNS 中獲取它的主機列表而不像之前那樣硬編碼到程式碼中。這讓我們可以在更新主機列表時不用擔心忘記更新部署工具 — 一個基本的服務發現系統。

為了處理順序重啟的問題,我們在部署前打亂了主機列表的順序。因為它把所有伺服器池的部署順序都打亂了,這讓我們可以在更快的速度下安全的切換版本,從而部署地更快。

這個最初的實現只是每次都隨機的打亂順序,但是這樣做的話很難快速的回滾程式碼,因為你不會每次都部署到和之前一樣的前幾臺機器上。所以我們修改了打亂的策略使用了種子(譯者注:即隨機數生成器的種子),當你需要回滾的時候,這個種子可以第二次重新使用。

另一個小而重要的變化是始終部署指定版本的程式碼。先前版本的部署工具會在給定的主機上更新 master 分支,但是如果因為有人不小心推了程式碼導致 master 分支在部署中改變了呢?通過部署特定的 git 版本而不是分支名,我們可以確保部署在生產環境的任何地方程式碼都是同樣的版本。

最後,新工具區分了它的程式碼(主要關注主機列表和用 ssh 登入到這些主機)和被執行的命令。它仍然非常偏向於滿足處理 r2 的需求,但是它有了一個多樣的原型 API。這讓 r2 可以控制自己的部署步驟,從而更簡單把程式碼的改變推到構建和釋出流。例如,以下是可能在單個伺服器上執行的指令。確切的命令並沒有顯示出來但是這個命令序列仍然是特定於 r2 的工作流。

sudo /opt/reddit/deploy.py fetch reddit
sudo /opt/reddit/deploy.py deploy reddit f3bbbd66a6
sudo /opt/reddit/deploy.py fetch-names
sudo /opt/reddit/deploy.py restart all

那個叫做 fetch-names 的命令是隻針對 r2 處理的。

自動伸縮器(2013)

然後我們決定開始使用雲端的設施和自動伸縮(這是另一篇部落格文章的主題)。這讓我們在網站不怎麼忙時省下一大筆錢,遭遇到預料不及的請求量時自動增加設施。

之前所做的自動從 DNS 獲取主機列表的功能使這個變成了一個很自然的過渡。主機列表的更改頻率比以前更加頻繁,但是這對於工具來說並沒有什麼不同。這個一開始只是為了提高生活質量的東西現在成為了自動伸縮的必要部分。

然而,自動伸縮確實帶來了一些有趣的邊界條件。天下沒有免費的午餐,如果在部署進行的期間啟動了新的伺服器,那會發生什麼?我們必須確保所有新啟動的伺服器都能切換到新的程式碼(如果有的話)。如果伺服器在部署中途退出了怎麼辦?這個工具必須做得更聰明,從而可以檢測伺服器何時可以合法地被移除,而不是成為部署過程中的一個應該被提醒的問題。

意外的,這段時間我們也因為各種各樣的原因從 uWSGI 切換到 Gunicorn。對於部署來說,這並沒有真正的區別。

事情仍在繼續。

太多伺服器了(2014)

隨著時間推移,需要處理峰值流量的伺服器不斷增長。這意味著部署所花的時間越來越長。在最壞的情況下,一個普通的部署會花掉將近一個小時。這看起來不對啊。

我們重寫了部署工具來並行處理主機。這個新版本叫做 rollingpin。老的部署工具所花的大量時間都是初始化 ssh 連線並且等待命令完成,所以在可允許的安全數量並行化部署可以加快部署。這馬上又把部署的時間降低到了 5 分鐘。

為了減少同時重新啟動多臺伺服器的影響,部署工具的隨機打亂程式也變得越來越智慧。它不會隨便的打亂伺服器列表,而是[通過最大限度的分割每個池的服務來交錯的部署伺服器]((https://github.com/reddit/rollingpin/blob/master/rollingpin/utils.py#L94-L110)。更加顯著的減少了部署對網站的影響。

新工具最重要的變化是部署工具和每個伺服器上的工具之前的 API定義的更加清晰並且和 r2 的需求解耦。這最初是為了讓原始碼更加易讀,但不久之後變的非常有用。下面是一個部署示例,高亮顯示的命令是被遠端執行的 API。

太多人了(2015)

突然,似乎有太多人在同一時間在 r2 上工作了。這很棒但也意味著更多的部署。維持在同一時間只部署一次程式碼慢慢變得更加困難,個別的工程師必須先口頭上協調好他們釋出程式碼的順序。為了解決這個問題,我們向聊天機器人新增了協調部署佇列的功能。工程師將先申請申請將部署鎖並且將其部署或放入等待佇列。這有助於維護部署的順序並且讓人在等待鎖解開的時候可以休息一下。

在團隊增長中增加的另一個重要功能是集中化追蹤部署。我們修改了部署工具來將部署過程中的指標傳送到 Graphite,這樣就可以簡單地將指標的變化和部署相關聯。

第二次(太多)服務了(也是 2015)

恍如隔世,我們有第二個服務要上線了。這個網站新的移動版要上線。這是一個完全不同的技術棧,而且它有自己的伺服器和構建過程。這是部署工具解耦 API 的第一次實戰測試。通過增加在每個專案不同位置增加構建步驟的能力,新服務成功啟動了而且我們能夠在同一個系統下管理這兩個服務。

太多服務了(2016)

在下一年的開發過程中,我們看到了 Reddit 團隊的爆炸式增長。我們從這兩個服務增長到十幾個服務並且從兩個團隊增長為幾打。我們的主要服務要麼都是建立在我們的後端服務框架 Baseplate 上,要麼就是類似於行動網路的 node 應用。這個部署的基礎設施在所有的服務中都很常見,而且因為 rollingpin 並不關心它部署的是什麼,越來越多的服務可以更快的上線。這就可以很輕鬆地用我們熟悉的工具來部署新的服務。

安全的網路(2017)

隨著專用於單體應用的伺服器數量增加,部署的時間也增長了。我們希望通過提高同時部署的並行數量來解決這個問題,但是這樣做會導致過多同時重新啟動的應用伺服器。這樣我們可用伺服器的容量就會不足,導致不足以處理接受的請求,使其它的應用伺服器過載。

Gunicorn 的主程式使用的是和 uWSGI 相同的模式,它將會同時重啟所有的工作程式。在新的工作程式啟動階段,你都不能處理任何請求。我們單體應用的工作程式啟動時間為 10-30 秒,這意味著在這段時間內,我們將無法處理任何請求。為了解決這個問題,我們用 Stripe 的 worker 管理器 Einhorn 取代了 gunicorn 的主程式,但是仍然儲存 gunicorn 的 HTTP 堆疊和 WSGI 容器。用 Einhorn 來重啟工作程式的方式是:先產生一個新的工作程式,等到這個新的程式宣告已經準備好處理請求之後關閉舊的工作程式,重複前面的步驟直到全部伺服器都升級好。這樣建立了一個安全的網路可以讓我們在部署期間仍能保證伺服器的處理能力。

這個新模式引入了另一個問題。如前所述,一個工作程式可能需要長達 30 秒的時間來替換和啟動。這意味著如果你的程式碼有一個 bug,它將不會立刻顯露出來而且你繼續會在很多伺服器上做版本變更。為了防止這種情況,我們引入了一個部署方式,部署過程會阻塞一直到工作程式已經完成重啟,之後才會在另一臺伺服器開始部署。這是通過簡單的定時查詢 Einhorn 狀態,一直到所有的新工作程式都準備好。為了保持部署的速度,我們只是增加了並行量,至少現在看這樣做是安全的。

這個新的機制讓我們可以並行地部署更多的伺服器,無視因為安全而等待的額外時間,對於將近 800 臺伺服器部署的時間降至 7 分鐘。

憶古思今

這個部署的基礎設施是多年來逐步提升的結果,而不是任何單一專門的開發過程。歷史中的問題和每一步的權衡無論是在現在的系統還是過去任何時候的都看得到。這種演進的方式有利有弊:在任何時間我們所需要付出的努力都會更少,但是在這個過程中我們可能會遇到死衚衕。重要的是要關注你正在演進的地方,這樣你才能不斷朝著有用的方向前進。

未來

Reddit 的基礎設施需要支援團隊的擴大和新專案的構建。現在 Reddit 這家公司的發展速度比歷史上的任何時候都要快,而且我們正在開發比以前更大,更有趣的專案。我們今天遇到的大問題有兩個方面:首先要在保持生產環境基礎設施安全的情況下提高工程師的自主權,還要逐步建立一個可以讓工程師可以放心地快速部署的安全網路。





原文釋出時間為:2017年6月22日

本文來自雲棲社群合作伙伴掘金,瞭解相關資訊可以關注掘金網站。


相關文章