英國《衛報》是如何不停機從MongoDB遷移到Postgres?

banq發表於2018-12-21

這篇文章介紹了英國《衛報Guardian》為什麼和如何從Mongo遷移到Postgres,英國衛報大部分內容 - 包括文章,實時部落格,畫廊和影片內容 - 都是內部CMS工具Composer中製作的。直到最近一直得到了在AWS上執行的Mongo DB資料庫的支援。這個Mongo DB資料庫是Guardian所有線上釋出內容的“真實來源” - 大約230萬內容項。
當初為了遷移到AWS,決定購買OpsManager- Mongo的資料庫管理軟體,使用OpsManager來管理備份,處理編排併為我們的資料庫叢集提供監控。
因為Mongo沒有提供任何工具來輕鬆在AWS上進行設定 - 我們需要手工編寫cloudformation來定義所有基礎架構,最重要的是我們編寫了數百行ruby指令碼來處理安裝監視/自動化代理和新資料庫例項的編排。
自從遷移到AWS 以來,由於Mongo DB資料庫問題,我們發生了兩次嚴重的中斷,每次在theguardian.com上被阻止釋出內容至少一個小時。在這兩種情況下,OpsManager和Mongo的支援服務人員都沒有能夠幫助我們,我們最終解決了這個問題 :
  • 時鐘非常重要 - 不要將VPC鎖定到NTP停止工作的程度。
  • 在應用程式啟動時自動生成資料庫索引可能是一個壞主意。
  • 資料庫管理很重要而且很難 - 我們寧願不自己做。

OpsManager並沒有真正兌現其無障礙資料庫管理的承諾。例如,實際管理OpsManager本身 - 特別是從OpsManager 1升級到2 - 非常耗時,並且需要有關我們的OpsManager設定的專業知識。
由於不同版本的Mongo DB之間的身份驗證架構發生了變化,它也沒有實現其“一鍵升級”承諾。我們每年至少花費兩個月的工程時間來完成這項資料庫管理工作。
所有這些問題,加上我們為支援合同和OpsManager支付的高額年費,讓我們尋找替代資料庫選項,具有以下要求:
  • 需要最少的資料庫管理。
  • 支援靜態加密。
  • Mongo的可行遷移路徑。

由於我們所有其他服務都在AWS中執行,因此顯而易見的選擇是DynamoDB - 亞馬遜的NoSQL資料庫產品。不幸的是,當時Dynamo不支援靜態加密。在等待大約9個月後才新增此功能,我們最終放棄並尋找其他東西,最終選擇在AWS RDS上使用Postgres。

“但是postgres不是檔案儲存!”我聽到你哭了。嗯,不,它不是,但它確實有一個JSONB列型別,支援JSON blob中欄位的索引。
我們希望透過使用JSONB型別,我們可以將Mongo遷移到Postgres,只需對我們的資料模型進行最小的更改。此外,如果我們希望將來轉向更多的關係模型,我們就有了這個選擇。關於Postgres的另一個好處是它有多成熟:在大多數情況下,我們想要提出的每個問題都已經在Stack Overflow上得到了解答。
從效能的角度來看,我們有信心Postgres可以應對 - 而Composer是一個寫作繁重的工具(每次記者停止打字時都會寫入資料庫) - 通常只有幾百個併發使用者 - 而不是高效能運算!

第二部分 - 二十年的內容遷移,沒有停機時間

以下是我們遷移資料庫的步驟:

  • 建立新資料庫。
  • 建立一種寫入新資料庫的方法(新API)。
  • 建立一個代理,使用舊的資料庫作為主資料庫,將流量傳送到舊資料庫和新資料庫。
  • 將記錄從舊資料庫遷移到新資料庫。
  • 使新資料庫成為主資料庫。
  • 刪除舊資料庫。

鑑於我們正在遷移的資料庫為我們的CMS提供支援,因此遷移對我們的記者造成的影響很小。畢竟,新聞永遠不會停止。

新API:
2017年7月底,新的Postgres驅動的API開始工作。所以我們的旅程開始了。
我們簡化的CMS架構是這樣的:資料庫,API和與之互動的幾個應用程式(例如Web前端)。堆疊是,現在仍然是使用ScalaScalatra FrameworkAngular.js構建的,它大約有四年的歷史。
經過一些調查,我們得出結論,在我們可以遷移現有內容之前,我們需要一種方法來與新的PostgreSQL資料庫進行對話,並且仍然像往常一樣執行舊的API。畢竟,Mongo資料庫是我們的真相來源。它在試驗新API時為我們提供了安全毯。
這是為什麼在舊API之上構建不是一個選項的原因之一。在原始API中幾乎沒有關注點分離,甚至在控制器級別也可以找到MongoDB細節。因此,在現有API中新增另一個資料庫型別的任務風險太大。
我們採用了不同的路線,並複製了舊的API。這就是APIV2誕生的方式。它或多或少是Mongo的複製品,包含相同的端點和功能。我們使用doobie,一個用於Scala的純功能JDBC層,新增了Docker以便在本地執行和測試,並改進了日誌記錄和關注點分離。APIV2將成為一個快速而現代的API。
到2017年8月底,我們部署了一個使用PostgreSQL作為其資料庫的新API。但這只是一個開始。Mongo資料庫中的文章最初是在二十年前建立的,所有這些文章都需要移到Postgres資料庫中。

遷移
我們需要能夠編輯網站上的任何文章,無論它們何時釋出,因此所有文章都作為單一的“事實來源”存在於我們的資料庫中。
雖然所有文章都存在於Guardian的內容API(CAPI)中,它為應用程式和網站提供支援,但正確的遷移是關鍵,因為我們的資料庫是“真相的來源”。如果CAPI的Elasticsearch叢集發生任何事情,那麼我們將從Composer的資料庫中重新索引它。
因此,在關閉Mongo之前,我們必須確信在Postgres驅動的API和Mongo驅動的API上的相同請求將返回相同的響應。
為此,我們需要將所有內容複製到新的Postgres資料庫。這是使用直接與新舊API對話的指令碼完成的。這樣做的好處是,API已經提供了一個經過良好測試的介面,用於從資料庫讀取和寫入文章,而不是直接編寫訪問相關資料庫的內容。
遷移的基本流程是:

  • 從Mongo獲取內容。
  • 將內容釋出到Postgres。
  • 從Postgres獲取內容。
  • 檢查一個和三個的響應是否相同

如果您的終端使用者完全沒有意識到它已經發生並且一個好的遷移指令碼始終是這個的重要組成部分,那麼資料庫遷移真的很順利。
考慮到這一點,我們需要一個指令碼,可以:
  • 發出HTTP請求。
  • 確保在遷移一段內容後,兩個API的響應都匹配。
  • 如果出現錯誤則停止。
  • 生成詳細日誌以幫助診斷問題。
  • 發生錯誤後從正確的點重新啟動。

我們開始使用Ammonite
Ammonite允許您在Scala中編寫指令碼,Scala是我們團隊的主要語言。這是一個很好的機會,可以嘗試我們之前沒有用過的東西,看看它對我們是否有用。雖然Ammonite允許我們使用熟悉的語言,但也有缺點。雖然Intellij現在支援Ammonite,但當時它沒有,這意味著我們失去了自動完成和自動匯入。也不可能長時間執行Ammonite指令碼。
最終,Ammonite不是正確的工具,我們使用sbt專案來執行遷移。我們採用的方法允許我們使用我們自信的語言工作並執行多次“測試遷移”,直到我們有信心在生產中執行它。
快進到2017年1月,是時候在我們的預生產環境CODE中測試完整的遷移了。
與我們的大多數系統類似,CODE和PROD之間唯一的相似之處是它們執行的​​應用程式的版本。支援CODE環境的AWS基礎架構遠沒有PROD那麼強大,因為它的使用率要低得多。
在CODE上執行遷移將有助於我們:
  • 估計PROD上的遷移需要多長時間。
  • 評估遷移對效能的影響(如果有的話)。

為了準確衡量這些指標,我們必須匹配這兩個環境。這包括將PROD mongo資料庫的備份還原到CODE並更新AWS支援的基礎架構。
遷移超過200萬項內容需要很長時間,當然比辦公時間更長。所以我們一夜之間在螢幕上執行指令碼。
為了衡量遷移的進度,我們將結構化日誌(使用標記)傳送到ELK堆疊。從這裡,我們可以建立詳細的儀表板,跟蹤成功遷移的文章數量,失敗次數和總體進度。此外,這些顯示在團隊附近的大螢幕上,以提供更大的可見性。
遷移完成後,我們採用相同的技術檢查Postgres匹配的Mongo中的每個文件。

第三部分 - 代理和生產中的執行
現在,新的Postgres驅動的API正在執行,我們需要使用實際流量和資料訪問模式對其進行測試,以確保其可靠和穩定。有兩種可能的方法可以實現這一點:更新與Mongo API通訊的每個客戶端以與兩個API通訊; 或執行代理,這樣做。我們使用Akka Streams在Scala中編寫了一個代理。
代理操作相當簡單:

  • 接受來自負載均衡器的流量。
  • 將流量轉發到主api並返回。
  • 非同步將相同的流量轉發到輔助api。
  • 計算兩個響應之間的任何差異並記錄它們。

一開始,代理在兩個API的響應之間記錄了很多差異,在需要修復的API中出現了一些非常微妙但重要的行為差異。

結構化日誌記錄
我們在Guardian上進行日誌記錄的方式是使用ELK堆疊。使用Kibana使我們能夠靈活地以對我們最有用的方式顯示日誌。Kibana使用相當容易學習的lucene查詢語法。但我們很快意識到,在當前設定中無法過濾掉日誌或對其進行分組。例如,我們無法過濾掉因GET請求而傳送的日誌。
我們的解決方案是向Kibana傳送更多結構化日誌,而不是僅傳送訊息。一個日誌條目包含多個欄位,例如時間戳,傳送日誌或堆疊的應用程式的名稱。以程式設計方式新增新欄位非常簡單。這些結構化欄位稱為標記,可以使用logstash-logback-encoder庫實現。對於每個請求,我們提取了有用的資訊(例如路徑,方法,狀態程式碼),並建立了一個包含我們記錄所需的附加資訊的地圖。看看下面的例子。


object Logging {
 val rootLogger: LogbackLogger = LoggerFactory.getLogger(SLFLogger.ROOT_LOGGER_NAME).asInstanceOf[LogbackLogger]

 private def setMarkers(request: HttpRequest) = {
   val markers = Map(
     "path" -> request.uri.path.toString(),
     "method" -> request.method.value
   )
   Markers.appendEntries(markers.asJava)
 }

 def infoWithMarkers(message: String, akkaRequest: HttpRequest) =
   rootLogger.info(setMarkers(akkaRequest), message)
}


我們日誌中的附加結構允許我們構建有用的儀表板並在我們的差異中新增更多上下文,這有助於我們識別API之間的一些較小的不一致。

複製流量和代理重構:
將內容遷移到CODE資料庫後,我們最終得到了幾乎完全相同的PROD資料庫副本。主要區別是CODE沒有流量。為了將實際流量複製到CODE環境中,我們使用了一個名為GoReplay(gor)的開源工具。它的設定非常簡單,可根據您的要求進行定製。
由於進入我們的API的所有流量首先達到了代理,因此在代理伺服器上安裝gor是有意義的。請參閱下文,瞭解如何在您的盒子上下載gor以及如何開始捕獲埠80上的流量並將其傳送到另一臺伺服器。

wget https://github.com/buger/goreplay/releases/download/v0.16.0.2/gor_0.16.0_x64.tar.gz

tar -xzf gor_0.16.0_x64.tar.gz gor

sudo gor --input-raw :80 --output-http http://apiv2.code.co.uk



一切都執行良好一段時間,但很快我們的代理幾分鐘時就遇到了生產中斷。經過調查,我們發現代理執行的所有三個盒子同時迴圈。我們懷疑gor使用了太多資源並導致代理失敗。在進一步調查中,我們在AWS控制檯中發現這些盒子已經定期迴圈,但不是在同一時間。

在深入研究之前,我們試圖找到一種方法來繼續執行gor,但這次沒有對代理施加任何壓力。解決方案來自Composer的二級堆疊。該堆疊僅用於緊急情況,並且我們的生產監控工具會不斷對其進行測試。將流量從此堆疊重新對映到CODE,速度加倍,這次沒有任何問題。

新發現提出了很多問題。該代理的構建可能沒有像其他應用程式那樣精心設計。此外,它是使用Akka Http構建的,之前沒有任何團隊成員使用過。程式碼很混亂,並且快速修復。我們決定開始一項重大的重構工作,以提高可讀性,包括使用理解而不是我們之前增長的巢狀邏輯,並新增更多的日誌記錄標記。

我們希望透過花時間瞭解一切是如何運作的,並透過簡化邏輯,我們能夠阻止騎行。但這沒效果。經過大約兩個星期的嘗試使代理更可靠,我們開始覺得我們越來越深入兔子洞了。必須作出決定。我們同意冒險並留下風險,因為花在實際遷移上的時間比試圖修復一個月內將會消失的軟體更好。我們透過再經歷兩次生產中斷來驗證這個決定,每次中斷持續大約兩分鐘,但總體來說這是正確的做法。

快進到2017年3月,我們現在已經完成了遷移CODE,對API的效能或CMS中的使用者體驗沒有任何不利影響。我們現在可以開始考慮在CODE中停用代理。
第一階段是改變API的優先順序,以便代理首先與Postgres交談。如前所述,這是基於配置的。然而,有一個複雜性。
更新文件後,Composer會在Kinesis流上傳送訊息。為了避免重複訊息,只有一個API應該傳送這些訊息。API為此配置了一個標誌; 對於Mongo支援的API,該值為true,對於Postgres支援的API,該值為false。簡單地更改代理與Postgres交談是不夠的,因為在請求到達Mongo之前,訊息不會在Kinesis流上傳送。這太遲了。
為了解決這個問題,我們建立了HTTP端點,以便瞬時更改負載均衡器中所有例項的記憶體配置。這使我們能夠非常快速地切換哪個API是主要的,而無需編輯配置檔案並重新部署。此外,這可以編寫指令碼,減少人為干預和錯誤。
現在所有的請求都是Postgres,而API2正在與Kinesis交談,可以透過配置和重新部署來永久更改。
下一步是完全刪除代理,讓客戶單獨與Postgres API交談。由於有許多客戶,單獨更新每個客戶並不是真的可行。因此,我們將其推向了DNS。也就是說,我們在DNS中建立了一個CNAME,它首先指向代理的ELB,然後更改為指向API ELB。這允許進行單個更改,而不是更新API的每個單獨客戶端。
現在是遷移PROD的時候了。雖然有點可怕,因為它是生產。這個過程相對簡單,因為一切都基於配置。此外,當我們向日志新增舞臺標記時,還可以透過更新Kibana過濾器來重新調整先前構建的儀表板。

關閉代理和MongoDB
在10個月和240萬個遷移文章之後,我們終於可以關閉所有與Mongo相關的基礎設施。但首先,我們一直在等待的那一刻:殺死代理
這個小軟體給我們帶來了很多問題,我們迫不及待想要把它關掉!我們需要做的就是更新CNAME記錄以直接指向APIV2負載均衡器。
該團隊聚集在一臺電腦周圍。只需點選一下即可切換開關。沒有人再呼吸了。完全沉默。點選!而且改變了。什麼都沒發生!我們都放鬆了。
出乎意料的是,刪除舊的MongoDB API是另一項挑戰。在瘋狂刪除舊程式碼時,我們發現我們的整合測試從未更改為使用新API。一切都很快變紅了。幸運的是,大多數問題都與配置相關,因此很容易修復。但是測試捕獲的PostgreSQL查詢存在一些問題。為了避免這個錯誤,我們想到了我們可以做的事情,我們意識到,在開始大量工作時你也必須接受你會犯錯誤。
之後發生的一切都很順利。我們從OpsManager中分離了所有Mongo例項,然後終止它們。剩下要做的唯一事情就是慶祝。睡個好覺。



 

相關文章