英國(guó)《衛(wèi)報(bào)》是如何不停機(jī)從MongoDB遷移到Postgres?
這篇文章介紹了英國(guó)《衛(wèi)報(bào)Guardian》為什么和如何從Mongo遷移到Postgres,英國(guó)衛(wèi)報(bào)大部分內(nèi)容 - 包括文章,實(shí)時(shí)博客,畫(huà)廊和視頻內(nèi)容 - 都是內(nèi)部CMS工具Composer中制作的。直到最近一直得到了在AWS上運(yùn)行的Mongo DB數(shù)據(jù)庫(kù)的支持。這個(gè)Mongo DB數(shù)據(jù)庫(kù)是Guardian所有在線發(fā)布內(nèi)容的“真實(shí)來(lái)源” - 大約230萬(wàn)內(nèi)容項(xiàng)。
當(dāng)初為了遷移到AWS,決定購(gòu)買OpsManager- Mongo的數(shù)據(jù)庫(kù)管理軟件,使用OpsManager來(lái)管理備份,處理編排并為我們的數(shù)據(jù)庫(kù)集群提供監(jiān)控。
因?yàn)镸ongo沒(méi)有提供任何工具來(lái)輕松在AWS上進(jìn)行設(shè)置 - 我們需要手工編寫cloudformation來(lái)定義所有基礎(chǔ)架構(gòu),最重要的是我們編寫了數(shù)百行ruby腳本來(lái)處理安裝監(jiān)視/自動(dòng)化代理和新數(shù)據(jù)庫(kù)實(shí)例的編排。
自從遷移到AWS 以來(lái),由于Mongo DB數(shù)據(jù)庫(kù)問(wèn)題,我們發(fā)生了兩次嚴(yán)重的中斷,每次在theguardian.com上被阻止發(fā)布內(nèi)容至少一個(gè)小時(shí)。在這兩種情況下,OpsManager和Mongo的支持服務(wù)人員都沒(méi)有能夠幫助我們,我們最終解決了這個(gè)問(wèn)題 :
- 時(shí)鐘非常重要 - 不要將VPC鎖定到NTP停止工作的程度。
- 在應(yīng)用程序啟動(dòng)時(shí)自動(dòng)生成數(shù)據(jù)庫(kù)索引可能是一個(gè)壞主意。
- 數(shù)據(jù)庫(kù)管理很重要而且很難 - 我們寧愿不自己做。
OpsManager并沒(méi)有真正兌現(xiàn)其無(wú)障礙數(shù)據(jù)庫(kù)管理的承諾。例如,實(shí)際管理OpsManager本身 - 特別是從OpsManager 1升級(jí)到2 - 非常耗時(shí),并且需要有關(guān)我們的OpsManager設(shè)置的專業(yè)知識(shí)。
由于不同版本的Mongo DB之間的身份驗(yàn)證架構(gòu)發(fā)生了變化,它也沒(méi)有實(shí)現(xiàn)其“一鍵升級(jí)”承諾。我們每年至少花費(fèi)兩個(gè)月的工程時(shí)間來(lái)完成這項(xiàng)數(shù)據(jù)庫(kù)管理工作。
所有這些問(wèn)題,加上我們?yōu)橹С趾贤蚈psManager支付的高額年費(fèi),讓我們尋找替代數(shù)據(jù)庫(kù)選項(xiàng),具有以下要求:
- 需要最少的數(shù)據(jù)庫(kù)管理。
- 支持靜態(tài)加密。
- Mongo的可行遷移路徑。
由于我們所有其他服務(wù)都在AWS中運(yùn)行,因此顯而易見(jiàn)的選擇是DynamoDB - 亞馬遜的NoSQL數(shù)據(jù)庫(kù)產(chǎn)品。不幸的是,當(dāng)時(shí)Dynamo不支持靜態(tài)加密。在等待大約9個(gè)月后才添加此功能,我們最終放棄并尋找其他東西,最終選擇在AWS RDS上使用Postgres。
“但是postgres不是文件存儲(chǔ)!”我聽(tīng)到你哭了。嗯,不,它不是,但它確實(shí)有一個(gè)JSONB列類型,支持JSON blob中字段的索引。
我們希望通過(guò)使用JSONB類型,我們可以將Mongo遷移到Postgres,只需對(duì)我們的數(shù)據(jù)模型進(jìn)行最小的更改。此外,如果我們希望將來(lái)轉(zhuǎn)向更多的關(guān)系模型,我們就有了這個(gè)選擇。關(guān)于Postgres的另一個(gè)好處是它有多成熟:在大多數(shù)情況下,我們想要提出的每個(gè)問(wèn)題都已經(jīng)在Stack Overflow上得到了解答。
從性能的角度來(lái)看,我們有信心Postgres可以應(yīng)對(duì) - 而Composer是一個(gè)寫作繁重的工具(每次記者停止打字時(shí)都會(huì)寫入數(shù)據(jù)庫(kù)) - 通常只有幾百個(gè)并發(fā)用戶 - 而不是高性能計(jì)算!
第二部分 - 二十年的內(nèi)容遷移,沒(méi)有停機(jī)時(shí)間
以下是我們遷移數(shù)據(jù)庫(kù)的步驟:
- 創(chuàng)建新數(shù)據(jù)庫(kù)。
- 創(chuàng)建一種寫入新數(shù)據(jù)庫(kù)的方法(新API)。
- 創(chuàng)建一個(gè)代理,使用舊的數(shù)據(jù)庫(kù)作為主數(shù)據(jù)庫(kù),將流量發(fā)送到舊數(shù)據(jù)庫(kù)和新數(shù)據(jù)庫(kù)。
- 將記錄從舊數(shù)據(jù)庫(kù)遷移到新數(shù)據(jù)庫(kù)。
- 使新數(shù)據(jù)庫(kù)成為主數(shù)據(jù)庫(kù)。
- 刪除舊數(shù)據(jù)庫(kù)。
鑒于我們正在遷移的數(shù)據(jù)庫(kù)為我們的CMS提供支持,因此遷移對(duì)我們的記者造成的影響很小。畢竟,新聞?dòng)肋h(yuǎn)不會(huì)停止。
新API:
2017年7月底,新的Postgres驅(qū)動(dòng)的API開(kāi)始工作。所以我們的旅程開(kāi)始了。
我們簡(jiǎn)化的CMS架構(gòu)是這樣的:數(shù)據(jù)庫(kù),API和與之交互的幾個(gè)應(yīng)用程序(例如Web前端)。堆棧是,現(xiàn)在仍然是使用Scala,Scalatra Framework和Angular.js構(gòu)建的,它大約有四年的歷史。
經(jīng)過(guò)一些調(diào)查,我們得出結(jié)論,在我們可以遷移現(xiàn)有內(nèi)容之前,我們需要一種方法來(lái)與新的PostgreSQL數(shù)據(jù)庫(kù)進(jìn)行對(duì)話,并且仍然像往常一樣運(yùn)行舊的API。畢竟,Mongo數(shù)據(jù)庫(kù)是我們的真相來(lái)源。它在試驗(yàn)新API時(shí)為我們提供了安全毯。
這是為什么在舊API之上構(gòu)建不是一個(gè)選項(xiàng)的原因之一。在原始API中幾乎沒(méi)有關(guān)注點(diǎn)分離,甚至在控制器級(jí)別也可以找到MongoDB細(xì)節(jié)。因此,在現(xiàn)有API中添加另一個(gè)數(shù)據(jù)庫(kù)類型的任務(wù)風(fēng)險(xiǎn)太大。
我們采用了不同的路線,并復(fù)制了舊的API。這就是APIV2誕生的方式。它或多或少是Mongo的復(fù)制品,包含相同的端點(diǎn)和功能。我們使用doobie,一個(gè)用于Scala的純功能JDBC層,添加了Docker以便在本地運(yùn)行和測(cè)試,并改進(jìn)了日志記錄和關(guān)注點(diǎn)分離。APIV2將成為一個(gè)快速而現(xiàn)代的API。
到2017年8月底,我們部署了一個(gè)使用PostgreSQL作為其數(shù)據(jù)庫(kù)的新API。但這只是一個(gè)開(kāi)始。Mongo數(shù)據(jù)庫(kù)中的文章最初是在二十年前創(chuàng)建的,所有這些文章都需要移到Postgres數(shù)據(jù)庫(kù)中。
遷移
我們需要能夠編輯網(wǎng)站上的任何文章,無(wú)論它們何時(shí)發(fā)布,因此所有文章都作為單一的“事實(shí)來(lái)源”存在于我們的數(shù)據(jù)庫(kù)中。
雖然所有文章都存在于Guardian的內(nèi)容API(CAPI)中,它為應(yīng)用程序和網(wǎng)站提供支持,但正確的遷移是關(guān)鍵,因?yàn)槲覀兊臄?shù)據(jù)庫(kù)是“真相的來(lái)源”。如果CAPI的Elasticsearch集群發(fā)生任何事情,那么我們將從Composer的數(shù)據(jù)庫(kù)中重新索引它。
因此,在關(guān)閉Mongo之前,我們必須確信在Postgres驅(qū)動(dòng)的API和Mongo驅(qū)動(dòng)的API上的相同請(qǐng)求將返回相同的響應(yīng)。
為此,我們需要將所有內(nèi)容復(fù)制到新的Postgres數(shù)據(jù)庫(kù)。這是使用直接與新舊API對(duì)話的腳本完成的。這樣做的好處是,API已經(jīng)提供了一個(gè)經(jīng)過(guò)良好測(cè)試的界面,用于從數(shù)據(jù)庫(kù)讀取和寫入文章,而不是直接編寫訪問(wèn)相關(guān)數(shù)據(jù)庫(kù)的內(nèi)容。
遷移的基本流程是:
- 從Mongo獲取內(nèi)容。
- 將內(nèi)容發(fā)布到Postgres。
- 從Postgres獲取內(nèi)容。
- 檢查一個(gè)和三個(gè)的響應(yīng)是否相同
如果您的最終用戶完全沒(méi)有意識(shí)到它已經(jīng)發(fā)生并且一個(gè)好的遷移腳本始終是這個(gè)的重要組成部分,那么數(shù)據(jù)庫(kù)遷移真的很順利。
考慮到這一點(diǎn),我們需要一個(gè)腳本,可以:
- 發(fā)出HTTP請(qǐng)求。
- 確保在遷移一段內(nèi)容后,兩個(gè)API的響應(yīng)都匹配。
- 如果出現(xiàn)錯(cuò)誤則停止。
- 生成詳細(xì)日志以幫助診斷問(wèn)題。
- 發(fā)生錯(cuò)誤后從正確的點(diǎn)重新啟動(dòng)。
我們開(kāi)始使用Ammonite,Ammonite允許您在Scala中編寫腳本,Scala是我們團(tuán)隊(duì)的主要語(yǔ)言。這是一個(gè)很好的機(jī)會(huì),可以嘗試我們之前沒(méi)有用過(guò)的東西,看看它對(duì)我們是否有用。雖然Ammonite允許我們使用熟悉的語(yǔ)言,但也有缺點(diǎn)。雖然Intellij現(xiàn)在支持Ammonite,但當(dāng)時(shí)它沒(méi)有,這意味著我們失去了自動(dòng)完成和自動(dòng)導(dǎo)入。也不可能長(zhǎng)時(shí)間運(yùn)行Ammonite腳本。
最終,Ammonite不是正確的工具,我們使用sbt項(xiàng)目來(lái)執(zhí)行遷移。我們采用的方法允許我們使用我們自信的語(yǔ)言工作并執(zhí)行多次“測(cè)試遷移”,直到我們有信心在生產(chǎn)中運(yùn)行它。
快進(jìn)到2017年1月,是時(shí)候在我們的預(yù)生產(chǎn)環(huán)境CODE中測(cè)試完整的遷移了。
與我們的大多數(shù)系統(tǒng)類似,CODE和PROD之間***的相似之處是它們運(yùn)行的應(yīng)用程序的版本。支持CODE環(huán)境的AWS基礎(chǔ)架構(gòu)遠(yuǎn)沒(méi)有PROD那么強(qiáng)大,因?yàn)樗氖褂寐室偷枚唷?/p>
在CODE上運(yùn)行遷移將有助于我們:
- 估計(jì)PROD上的遷移需要多長(zhǎng)時(shí)間。
- 評(píng)估遷移對(duì)性能的影響(如果有的話)。
為了準(zhǔn)確衡量這些指標(biāo),我們必須匹配這兩個(gè)環(huán)境。這包括將PROD mongo數(shù)據(jù)庫(kù)的備份還原到CODE并更新AWS支持的基礎(chǔ)架構(gòu)。
遷移超過(guò)200萬(wàn)項(xiàng)內(nèi)容需要很長(zhǎng)時(shí)間,當(dāng)然比辦公時(shí)間更長(zhǎng)。所以我們一夜之間在屏幕上運(yùn)行腳本。
為了衡量遷移的進(jìn)度,我們將結(jié)構(gòu)化日志(使用標(biāo)記)發(fā)送到ELK堆棧。從這里,我們可以創(chuàng)建詳細(xì)的儀表板,跟蹤成功遷移的文章數(shù)量,失敗次數(shù)和總體進(jìn)度。此外,這些顯示在團(tuán)隊(duì)附近的大屏幕上,以提供更大的可見(jiàn)性。
遷移完成后,我們采用相同的技術(shù)檢查Postgres匹配的Mongo中的每個(gè)文檔。
第三部分 - 代理和生產(chǎn)中的運(yùn)行
現(xiàn)在,新的Postgres驅(qū)動(dòng)的API正在運(yùn)行,我們需要使用實(shí)際流量和數(shù)據(jù)訪問(wèn)模式對(duì)其進(jìn)行測(cè)試,以確保其可靠和穩(wěn)定。有兩種可能的方法可以實(shí)現(xiàn)這一點(diǎn):更新與Mongo API通信的每個(gè)客戶端以與兩個(gè)API通信; 或運(yùn)行代理,這樣做。我們使用Akka Streams在Scala中編寫了一個(gè)代理。
代理操作相當(dāng)簡(jiǎn)單:
- 接受來(lái)自負(fù)載均衡器的流量。
- 將流量轉(zhuǎn)發(fā)到主api并返回。
- 異步將相同的流量轉(zhuǎn)發(fā)到輔助api。
- 計(jì)算兩個(gè)響應(yīng)之間的任何差異并記錄它們。
一開(kāi)始,代理在兩個(gè)API的響應(yīng)之間記錄了很多差異,在需要修復(fù)的API中出現(xiàn)了一些非常微妙但重要的行為差異。
結(jié)構(gòu)化日志記錄
我們?cè)贕uardian上進(jìn)行日志記錄的方式是使用ELK堆棧。使用Kibana使我們能夠靈活地以對(duì)我們最有用的方式顯示日志。Kibana使用相當(dāng)容易學(xué)習(xí)的lucene查詢語(yǔ)法。但我們很快意識(shí)到,在當(dāng)前設(shè)置中無(wú)法過(guò)濾掉日志或?qū)ζ溥M(jìn)行分組。例如,我們無(wú)法過(guò)濾掉因GET請(qǐng)求而發(fā)送的日志。
我們的解決方案是向Kibana發(fā)送更多結(jié)構(gòu)化日志,而不是僅發(fā)送消息。一個(gè)日志條目包含多個(gè)字段,例如時(shí)間戳,發(fā)送日志或堆棧的應(yīng)用程序的名稱。以編程方式添加新字段非常簡(jiǎn)單。這些結(jié)構(gòu)化字段稱為標(biāo)記,可以使用logstash-logback-encoder庫(kù)實(shí)現(xiàn)。對(duì)于每個(gè)請(qǐng)求,我們提取了有用的信息(例如路徑,方法,狀態(tài)代碼),并創(chuàng)建了一個(gè)包含我們記錄所需的附加信息的地圖。看看下面的例子。
- 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)
- }
我們?nèi)罩局械母郊咏Y(jié)構(gòu)允許我們構(gòu)建有用的儀表板并在我們的差異中添加更多上下文,這有助于我們識(shí)別API之間的一些較小的不一致。
復(fù)制流量和代理重構(gòu):
將內(nèi)容遷移到CODE數(shù)據(jù)庫(kù)后,我們最終得到了幾乎完全相同的PROD數(shù)據(jù)庫(kù)副本。主要區(qū)別是CODE沒(méi)有流量。為了將實(shí)際流量復(fù)制到CODE環(huán)境中,我們使用了一個(gè)名為GoReplay(gor)的開(kāi)源工具。它的設(shè)置非常簡(jiǎn)單,可根據(jù)您的要求進(jìn)行定制。
由于進(jìn)入我們的API的所有流量首先達(dá)到了代理,因此在代理服務(wù)器上安裝gor是有意義的。請(qǐng)參閱下文,了解如何在您的盒子上下載gor以及如何開(kāi)始捕獲端口80上的流量并將其發(fā)送到另一臺(tái)服務(wù)器。
- 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
一切都運(yùn)行良好一段時(shí)間,但很快我們的代理幾分鐘時(shí)就遇到了生產(chǎn)中斷。經(jīng)過(guò)調(diào)查,我們發(fā)現(xiàn)代理運(yùn)行的所有三個(gè)盒子同時(shí)循環(huán)。我們懷疑gor使用了太多資源并導(dǎo)致代理失敗。在進(jìn)一步調(diào)查中,我們?cè)贏WS控制臺(tái)中發(fā)現(xiàn)這些盒子已經(jīng)定期循環(huán),但不是在同一時(shí)間。
在深入研究之前,我們?cè)噲D找到一種方法來(lái)繼續(xù)運(yùn)行g(shù)or,但這次沒(méi)有對(duì)代理施加任何壓力。解決方案來(lái)自Composer的二級(jí)堆棧。該堆棧僅用于緊急情況,并且我們的生產(chǎn)監(jiān)控工具會(huì)不斷對(duì)其進(jìn)行測(cè)試。將流量從此堆棧重新映射到CODE,速度加倍,這次沒(méi)有任何問(wèn)題。
新發(fā)現(xiàn)提出了很多問(wèn)題。該代理的構(gòu)建可能沒(méi)有像其他應(yīng)用程序那樣精心設(shè)計(jì)。此外,它是使用Akka Http構(gòu)建的,之前沒(méi)有任何團(tuán)隊(duì)成員使用過(guò)。代碼很混亂,并且快速修復(fù)。我們決定開(kāi)始一項(xiàng)重大的重構(gòu)工作,以提高可讀性,包括使用理解而不是我們之前增長(zhǎng)的嵌套邏輯,并添加更多的日志記錄標(biāo)記。
我們希望通過(guò)花時(shí)間了解一切是如何運(yùn)作的,并通過(guò)簡(jiǎn)化邏輯,我們能夠阻止騎行。但這沒(méi)效果。經(jīng)過(guò)大約兩個(gè)星期的嘗試使代理更可靠,我們開(kāi)始覺(jué)得我們?cè)絹?lái)越深入兔子洞了。必須作出決定。我們同意冒險(xiǎn)并留下風(fēng)險(xiǎn),因?yàn)榛ㄔ趯?shí)際遷移上的時(shí)間比試圖修復(fù)一個(gè)月內(nèi)將會(huì)消失的軟件更好。我們通過(guò)再經(jīng)歷兩次生產(chǎn)中斷來(lái)驗(yàn)證這個(gè)決定,每次中斷持續(xù)大約兩分鐘,但總體來(lái)說(shuō)這是正確的做法。
快進(jìn)到2017年3月,我們現(xiàn)在已經(jīng)完成了遷移CODE,對(duì)API的性能或CMS中的用戶體驗(yàn)沒(méi)有任何不利影響。我們現(xiàn)在可以開(kāi)始考慮在CODE中停用代理。
***階段是改變API的優(yōu)先級(jí),以便代理首先與Postgres交談。如前所述,這是基于配置的。然而,有一個(gè)復(fù)雜性。
更新文檔后,Composer會(huì)在Kinesis流上發(fā)送消息。為了避免重復(fù)消息,只有一個(gè)API應(yīng)該發(fā)送這些消息。API為此配置了一個(gè)標(biāo)志; 對(duì)于Mongo支持的API,該值為true,對(duì)于Postgres支持的API,該值為false。簡(jiǎn)單地更改代理與Postgres交談是不夠的,因?yàn)樵谡?qǐng)求到達(dá)Mongo之前,消息不會(huì)在Kinesis流上發(fā)送。這太遲了。
為了解決這個(gè)問(wèn)題,我們創(chuàng)建了HTTP端點(diǎn),以便瞬時(shí)更改負(fù)載均衡器中所有實(shí)例的內(nèi)存配置。這使我們能夠非常快速地切換哪個(gè)API是主要的,而無(wú)需編輯配置文件并重新部署。此外,這可以編寫腳本,減少人為干預(yù)和錯(cuò)誤。
現(xiàn)在所有的請(qǐng)求都是Postgres,而API2正在與Kinesis交談,可以通過(guò)配置和重新部署來(lái)***更改。
下一步是完全刪除代理,讓客戶單獨(dú)與Postgres API交談。由于有許多客戶,單獨(dú)更新每個(gè)客戶并不是真的可行。因此,我們將其推向了DNS。也就是說(shuō),我們?cè)贒NS中創(chuàng)建了一個(gè)CNAME,它首先指向代理的ELB,然后更改為指向API ELB。這允許進(jìn)行單個(gè)更改,而不是更新API的每個(gè)單獨(dú)客戶端。
現(xiàn)在是遷移PROD的時(shí)候了。雖然有點(diǎn)可怕,因?yàn)樗巧a(chǎn)。這個(gè)過(guò)程相對(duì)簡(jiǎn)單,因?yàn)橐磺卸蓟谂渲谩4送猓?dāng)我們向日志添加舞臺(tái)標(biāo)記時(shí),還可以通過(guò)更新Kibana過(guò)濾器來(lái)重新調(diào)整先前構(gòu)建的儀表板。
關(guān)閉代理和MongoDB
在10個(gè)月和240萬(wàn)個(gè)遷移文章之后,我們終于可以關(guān)閉所有與Mongo相關(guān)的基礎(chǔ)設(shè)施。但首先,我們一直在等待的那一刻:殺死代理
這個(gè)小軟件給我們帶來(lái)了很多問(wèn)題,我們迫不及待想要把它關(guān)掉!我們需要做的就是更新CNAME記錄以直接指向APIV2負(fù)載均衡器。
該團(tuán)隊(duì)聚集在一臺(tái)電腦周圍。只需點(diǎn)擊一下即可切換開(kāi)關(guān)。沒(méi)有人再呼吸了。完全沉默。點(diǎn)擊!而且改變了。什么都沒(méi)發(fā)生!我們都放松了。
出乎意料的是,刪除舊的MongoDB API是另一項(xiàng)挑戰(zhàn)。在瘋狂刪除舊代碼時(shí),我們發(fā)現(xiàn)我們的集成測(cè)試從未更改為使用新API。一切都很快變紅了。幸運(yùn)的是,大多數(shù)問(wèn)題都與配置相關(guān),因此很容易修復(fù)。但是測(cè)試捕獲的PostgreSQL查詢存在一些問(wèn)題。為了避免這個(gè)錯(cuò)誤,我們想到了我們可以做的事情,我們意識(shí)到,在開(kāi)始大量工作時(shí)你也必須接受你會(huì)犯錯(cuò)誤。
之后發(fā)生的一切都很順利。我們從OpsManager中分離了所有Mongo實(shí)例,然后終止它們。剩下要做的***事情就是慶祝。