對于無狀態的應用服務而言,容器是一個相當完美的開發運維解決方案。然而對于帶持久狀態的服務 —— 數據庫來說,事情就沒有那么簡單了。生產環境的數據庫是否應當放入容器中,仍然是一個充滿爭議的問題。
站在開發者的角度上,我非常喜歡Docker,并相信容器也許是未來軟件開發部署運維的標準方式。但站在DBA的立場上,我認為就目前而言,將生產環境數據庫放入Docker / K8S 中仍然是一個餿主意。
Docker解決什么問題?
讓我們先來看一看Docker對自己的描述。
Docker用于形容自己的詞匯包括:輕量,標準化,可移植,節約成本,提高效率,自動,集成,高效運維。這些說法并沒有問題,Docker在整體意義上確實讓開發和運維都變得更容易了。因而可以看到很多公司都熱切地希望將自己的軟件與服務容器化。但有時候這種熱情會走向另一個極端:將一切軟件服務都容器化,甚至是生產環境的數據庫。
容器最初是針對無狀態的應用而設計的,在邏輯上,容器內應用產生的臨時數據也屬于該容器的一部分。用容器創建起一個服務,用完之后銷毀它。這些應用本身沒有狀態,狀態通常保存在容器外部的數據庫里,這是經典的架構與用法,也是容器的設計哲學。
但當用戶想把數據庫本身也放到容器中時,事情就變得不一樣了:數據庫是有狀態的,為了維持這個狀態不隨容器停止而銷毀,數據庫容器需要在容器上打一個洞,與底層操作系統上的數據卷相聯通。這樣的容器,不再是一個能夠隨意創建,銷毀,搬運,轉移的對象,而是與底層環境相綁定的對象。因此,傳統應用使用容器的諸多優勢,對于數據庫容器來說都不復存在。
可靠性
讓軟件跑起來,和讓軟件可靠地運行是兩回事。數據庫是信息系統的核心,在絕大多數場景下屬于關鍵(Critical)應用,Critical Application可按字面解釋,就是出了問題會要命的應用。這與我們的日常經驗相符:word/Excel/PPT這些辦公軟件如果崩了強制重啟即可,沒什么大不了的;但正在編輯的文檔如果丟了、臟了、亂了,那才是真的災難。數據庫亦然,對于不少公司,特別是互聯網公司來說,如果數據庫被刪了又沒有可用備份,基本上可以宣告關門大吉了。
可靠性(Reliability)是數據庫最重要的屬性。可靠性是系統在困境(adversity)(硬件故障、軟件故障、人為錯誤)中仍可正常工作(正確完成功能,并能達到期望的性能水準)的能力。可靠性意味著容錯(fault-tolerant)與韌性(resilient),它是一種安全屬性,并不像性能與可維護性那樣的活性屬性直觀可衡量。它只能通過長時間的正常運行來證明,或者某一次故障來否證。很多人往往會在平時忽視安全屬性,而在生病后,車禍后,被搶劫后才追悔莫及。安全生產重于泰山,數據庫被刪,被攪亂,被脫庫后再捶胸頓足是沒有意義的。
回頭再看一看Docker對自己的特性描述中,并沒有包含“可靠”這個對于數據庫至關重要的屬性。
可靠性證明與社區知識
如前所述,可靠性并沒有一個很好的衡量方式。只有通過長時間的正確運行,我們才能對一個系統的可靠性逐漸建立信心。在裸機上部署數據庫可謂自古以來的實踐,通過幾十年的持續工作,它很好地證明了自己的可靠性。Docker雖為DevOps帶來一場革命,但僅僅五年的歷史對于可靠性證明而言仍然是圖樣圖森破。對關乎身家性命的生產數據庫而言還遠遠不夠:因為還沒有足夠的小白鼠去趟雷。
想要提高可靠性,最重要的就是從故障中吸取經驗。故障是寶貴的經驗財富:它將未知問題變為已知問題,是運維知識的表現形式。社區的故障經驗絕大多都基于裸機部署的假設,各式各樣的故障在幾十年里都已經被人們踩了個遍。如果你遇到一些問題,大概率是別人已經踩過的坑,可以比較方便地處理與解決。同樣的故障如果加上一個“Docker”關鍵字,能找到的有用信息就要少得多。這也意味著當疑難雜癥出現時,成功搶救恢復數據的概率要更低,處理緊急故障所需的時間會更長。
額外失效點微妙的現實是,如果沒有特殊理由,企業與個人通常并不愿意分享故障方面的經驗。故障有損企業的聲譽:可能暴露一些敏感信息,或者是企業與團隊的垃圾程度。另一方面,故障經驗幾乎都是真金白銀的損失與學費換來的,是運維人員的核心價值所在,因此有關故障方面的公開資料并不多。
開發關心Feature,而運維關注Bug。相比裸機部署而言,將數據庫放入Docker中并不能降低硬件故障、軟件錯誤、人為失誤的發生概率。用裸機會有的硬件故障,用Docker一個也不會少。軟件缺陷主要是應用Bug,也不會因為采用容器與否而降低,人為失誤同理。相反,引入Docker會因為引入了額外的組件,額外的復雜度,額外的失效點,導致系統整體可靠性下降。
舉個最簡單的例子,dockerd守護進程崩了怎么辦,數據庫進程就直接歇菜了。盡管這種事情發生的概率并不高,但它們在裸機上 —— 壓根不會發生。
此外,一個額外組件引入的失效點可能并不止一個:Docker產生的問題并不僅僅是Docker本身的問題。當故障發生時,可能是單純Docker的問題,或者是Docker與數據庫相互作用產生的問題,還可能是Docker與操作系統,編排系統,虛擬機,網絡,磁盤相互作用產生的問題。可以參見官方PostgreSQL Docker鏡像的Issue列表:https://Github.com/docker-library/postgres/issues?q=。
正如《從降本增笑到降本增效》中所說,智力功率很難在空間上累加—— 團隊的智力功率往往取決于最資深幾個靈魂人物的水平以及他們的溝通成本。當數據庫出現問題時需要數據庫專家來解決;當容器出現問題時需要容器專家來看問題;然而當你把數據庫放入 Kube.NETes 時,單獨的數據庫專家和 K8S 專家的智力帶寬是很難疊加的 —— 你需要一個雙料專家才能解決問題。而同時精通這兩者的軟件肯定要比單獨的數據庫專家少得多。
此外,彼之蜜糖,吾之砒霜。某些Docker的Feature,在特定的環境下也可能會變為Bug。
隔離性
Docker提供了進程級別的隔離性,通常來說隔離性對應用來說是個好屬性。應用看不見別的進程,自然也不會有很多相互作用導致的問題,進而提高了系統的可靠性。但隔離性對于數據庫而言不一定完全是好事。
一個微妙的真實案例是在同一個數據目錄上啟動兩個PostgreSQL實例,或者在宿主機和容器內同時啟動了兩個數據庫實例。在裸機上第二次啟動嘗試會失敗,因為PostgreSQL能意識到另一個實例的存在而拒絕啟動;但在使用Docker的情況下因其隔離性,第二個實例無法意識到宿主機或其他數據庫容器中的另一個實例。如果沒有配置合理的Fencing機制(例如通過宿主機端口互斥,pid文件互斥),兩個運行在同一數據目錄上的數據庫進程能把數據文件攪成一團漿糊。
數據庫需不需要隔離性?當然需要, 但不是這種隔離性。數據庫的性能很重要,因此往往是獨占物理機部署。除了數據庫進程和必要的工具,不會有其他應用。即使放在容器中,也往往采用獨占綁定物理機的模式運行。因此Docker提供的隔離性對于這種數據庫部署方案而言并沒有什么意義;不過對云數據庫廠商來說,這倒真是一個實用的Feature,用來搞多租戶超賣妙用無窮。
工具
數據庫需要工具來維護,包括各式各樣的運維腳本,部署,備份,歸檔,故障切換,大小版本升級,插件安裝,連接池,性能分析,監控,調優,巡檢,修復。這些工具,也大多針對裸機部署而設計。這些工具與數據庫一樣,都需要精心而充分的測試。讓一個東西跑起來,與確信這個東西能持久穩定正確的運行,是完全不同的可靠性水準。
一個簡單的例子是插件與包管理,PostgreSQL提供了很多實用的插件,譬如PostGIS。假如想為數據庫安裝該插件,在裸機上只要yum install然后create extension postgis兩條命令就可以。但如果是在Docker里,按照Docker的實踐原則,用戶需要在鏡像層次進行這個變更,否則下次容器重啟時這個擴展就沒了。因而需要修改Dockerfile,重新構建新鏡像并推送到服務器上,最后重啟數據庫容器,毫無疑問,要麻煩得多。
包管理是操作系統發行版的核心問題。然而 Docker 攪亂了這一切,例如,許多 PostgreSQL 不再以 RPM/DEB 包的形式發布二進制,而是以加裝擴展的 Postgres Docker 鏡像分發。這就會立即產生一個顯著的問題,如果我想同時使用兩種,三種,或者PG生態的一百多種擴展,那么應該如何把這些散碎的鏡像整合到一起呢?相比可靠的操作系統包管理,構建Docker鏡像總是需要耗費更多時間與精力才能正常起效。
再比如說監控,在傳統的裸機部署模式下,機器的各項指標是數據庫指標的重要組成部分。容器中的監控與裸機上的監控有很多微妙的區別。不注意可能會掉到坑里。例如,CPU各種模式的時長之和,在裸機上始終會是100%,但這樣的假設在容器中就不一定總是成立了。再比方說依賴/proc文件系統的監控程序可能在容器中獲得與裸機上涵義完全不同的指標。雖然這類問題最終都是可解的(例如把Proc文件系統掛載到容器內),但相比簡潔明了的方案,沒人喜歡復雜丑陋的work around。
類似的問題包括一些故障檢測工具與系統常用命令,雖然理論上可以直接在宿主機上執行,但誰能保證容器里的結果和裸機上的結果有著相同的涵義?更為棘手的是緊急故障處理時,一些需要臨時安裝使用的工具在容器里沒有,外網不通,如果再走Dockerfile→Image→重啟這種路徑毫無疑問會讓人抓狂。
把Docker當成虛擬機來用的話,很多工具大抵上還是可以正常工作的,不過這樣就喪失了使用的Docker的大部分意義,不過是把它當成了另一個包管理器用而已。有人覺得Docker通過標準化的部署方式增加了系統的可靠性,因為環境更為標準化更為可控。這一點不能否認。私以為,標準化的部署方式雖然很不錯,但如果運維管理數據庫的人本身了解如何配置數據庫環境,將環境初始化命令寫在Shell腳本里和寫在Dockerfile里并沒有本質上的區別。
可維護性
軟件的大部分開銷并不在最初的開發階段,而是在持續的維護階段,包括修復漏洞、保持系統正常運行、處理故障、版本升級,償還技術債、添加新的功能等等。可維護性對于運維人員的工作生活質量非常重要。
應該說可維護性是Docker最討喜的地方:Infrastructure as code。可以認為Docker的最大價值就在于它能夠把軟件的運維經驗沉淀成可復用的代碼,以一種簡便的方式積累起來,而不再是散落在各個角落的install/setup文檔。
在這一點上Docker做的相當出色,尤其是對于邏輯經常變化的無狀態應用而言。Docker和K8s能讓用戶輕松部署,完成擴容,縮容,發布,滾動升級等工作,讓Dev也能干Ops的活,讓Ops也能干DBA的活(迫真)。
環境配置
如果說Docker最大的優點是什么,那也許就是環境配置的標準化了。標準化的環境有助于交付變更,交流問題,復現Bug。使用二進制鏡像(本質是物化了的Dockerfile安裝腳本)相比執行安裝腳本而言更為快捷,管理更方便。一些編譯復雜,依賴如山的擴展也不用每次都重新構建了,這些都是很不錯的特性。
不幸的是,數據庫并不像通常的業務應用一樣來來去去更新頻繁,創建新實例或者交付環境本身是一個極低頻的操作。同時DBA們通常都會積累下各種安裝配置維護腳本,一鍵配置環境也并不會比Docker慢多少。因此在環境配置上Docker的優勢就沒有那么顯著了,只能說是 Nice to have。當然,在沒有專職DBA時,使用Docker鏡像可能還是要比自己瞎折騰要好一些,因為起碼鏡像中多少沉淀了一些運維經驗。
通常來說,數據庫初始化之后連續運行幾個月幾年也并不稀奇。占據數據庫管理工作主要內容的并不是創建新實例與交付環境,主要還是日常運維的部分 —— Day2 Operation。不幸的是,在這一點上Docker并沒有什么優勢,反而會產生不少的額外麻煩。
Day2 Operation
Docker確實能極大地簡化無狀態應用的日常維護工作,諸如創建銷毀,版本升級,擴容等,但同樣的結論能延伸到數據庫上嗎?
數據庫容器不可能像應用容器一樣隨意銷毀創建,重啟遷移。因而Docker并不能對數據庫的日常運維的體驗有什么提升,真正有幫助的倒是諸如 ansible 之類的工具。而對于日常運維而言,很多操作都需要通過docker exec的方式將腳本透傳至容器內執行。底下跑的還是一樣的腳本,只不過用docker-exec來執行又額外多了一層包裝,這就有點脫褲子放屁的意味了。
此外,很多命令行工具在和Docker配合使用時都相當尷尬。譬如docker exec會將stderr和stdout混在一起,讓很多依賴管道的命令無法正常工作。以PostgreSQL為例,在裸機部署模式下,某些日常ETL任務可以用一行bash輕松搞定:
??????psql <src-url> -c 'COPY tbl TO STDOUT' | psql <dst-url> -c 'COPY tdb FROM STDIN'但如果宿主機上沒有合適的客戶端二進制程序,那就只能這樣用Docker容器中的二進制:
docker exec -it srcpg gosu postgres bash -c "psql -c "COPY tbl TO STDOUT" 2>/dev/null" | docker exec -i dstpg gosu postgres psql -c 'COPY tbl FROM STDIN;'當用戶想為容器里的數據庫做一個物理備份時,原本很簡單的一條命令現在需要很多額外的包裝:docker套gosu套bash套pg_basebackup:
docker exec -i postgres_pg_1 gosu postgres bash -c 'pg_basebackup -Xf -Ft -c fast -D - 2>/dev/null' | tar -xC /tmp/backup/basebackup如果說客戶端應用psql|pg_basebackup|pg_dump還可以通過在宿主機上安裝對應版本的客戶端工具來繞開這個問題,那么服務端的應用就真的無解了。總不能在不斷升級容器內數據庫軟件的版本時每次都一并把宿主機上的服務器端二進制版本升級了吧?
另一個Docker喜歡講的例子是軟件版本升級:例如用Docker升級數據庫小版本,只要簡單地修改Dockerfile里的版本號,重新構建鏡像然后重啟數據庫容器就可以了。沒錯,至少對于無狀態的應用來說這是成立的。但當需要進行數據庫原地大版本升級時問題就來了,用戶還需要同時修改數據庫狀態。在裸機上一行bash命令就可以解決的問題,在Docker下可能就會變成這樣的東西:https://github.com/tianon/docker-postgres-upgrade。
如果數據庫容器不能像AppServer一樣隨意地調度,快速地擴展,也無法在初始配置,日常運維,以及緊急故障處理時相比普通腳本的方式帶來更多便利性,我們又為什么要把生產環境的數據庫塞進容器里呢?
Docker和K8s一個很討喜的地方是很容易進行擴容,至少對于無狀態的應用而言是這樣:一鍵拉起起幾個新容器,隨意調度到哪個節點都無所謂。但數據庫不一樣,作為一個有狀態的應用,數據庫并不能像普通AppServer一樣隨意創建,銷毀,水平擴展。譬如,用戶創建一個新從庫,即使使用容器,也得從主庫上重新拉取基礎備份。生產環境中動輒幾TB的數據庫,創建副本也需要個把鐘頭才能完成,也需要人工介入與檢查,并逐漸放量預熱緩存才能上線承載流量。相比之下,在同樣的操作系統初始環境下,運行現成的拉從庫腳本與跑docker run在本質上又能有什么區別 —— 時間都花在拖從庫上了。
使用Docker盛放生產數據庫的一個尷尬之處就在于,數據庫是有狀態的,而且為了建立這個狀態需要額外的工序。通常來說設置一個新PostgreSQL從庫的流程是,先通過pg_baseback建立本地的數據目錄副本,然后再在本地數據目錄上啟動postmaster進程。然而容器是和進程綁定的,一旦進程退出容器也隨之停止。因此為了在Docker中擴容一個新從庫:要么需要先后啟動pg_baseback容器拉取數據目錄,再在同一個數據卷上啟動postgres兩個容器;要么需要在創建容器的過程中就指定定好復制目標并等待幾個小時的復制完成;要么在postgres容器中再使用pg_basebackup偷天換日替換數據目錄。無論哪一種方案都是既不優雅也不簡潔。因為容器的這種進程隔離抽象,對于數據庫這種充滿狀態的多進程,多任務,多實例協作的應用存在抽象泄漏,它很難優雅地覆蓋這些場景。當然有很多折衷的辦法可以打補丁來解決這類問題,然而其代價就是大量額外復雜度,最終受傷的還是系統的可維護性。
總的來說,Docker 在某些層面上可以提高系統的可維護性,比如簡化創建新實例的操作,但它引入的新麻煩讓這樣的優勢顯得蒼白無力。
性能
性能也是人們經常關注的一個維度。從性能的角度來看,數據庫的基本部署原則當然是離硬件越近越好,額外的隔離與抽象不利于數據庫的性能:越多的隔離意味著越多的開銷,即使只是內核棧中的額外拷貝。對于追求性能的場景,一些數據庫選擇繞開操作系統的頁面管理機制直接操作磁盤,而一些數據庫甚至會使用FPGA甚至GPU加速查詢處理。
實事求是地講,Docker作為一種輕量化的容器,性能上的折損并不大,通常不會超過 10% 。但毫無疑問的是,將數據庫放入Docker只會讓性能變得更差而不是更好。
總結
容器技術與編排技術對于運維而言是非常有價值的東西,它實際上彌補了從軟件到服務之間的空白,其愿景是將運維的經驗與能力代碼化模塊化。容器技術將成為未來的包管理方式,而編排技術將進一步發展為“數據中心分布式集群操作系統”,成為一切軟件的底層基礎設施Runtime。當越來越多的坑被踩完后,人們可以放心大膽的把一切應用,有狀態的還是無狀態的都放到容器中去運行。但現在起碼對于數據庫而言,還只是一個美好的愿景與雞肋的選項。
需要再次強調的是,以上討論僅限于生產環境數據庫。對于開發測試而言,盡管有基于Vagrant的虛擬機沙箱,但我也支持使用Docker —— 畢竟不是所有的開發人員都知道怎么配置本地測試數據庫環境,使用Docker交付環境顯然要比一堆手冊簡單明了得多。對于生產環境的無狀態應用,甚至一些帶有衍生狀態的不甚重要衍生數據系統(譬如redis緩存),Docker也是一個不錯的選擇。但對于生產環境的核心關系型數據庫而言,如果里面的數據真的很重要,使用Docker前還是需要三思:這樣做的價值到底在哪里?出了疑難雜癥能Hold住嗎?搞砸了這鍋背得動嗎?
任何技術決策都是一個利弊權衡的過程,譬如這里使用Docker的核心權衡可能就是犧牲可靠性換取可維護性。確實有一些場景,數據可靠性并不是那么重要,或者說有其他的考量:譬如對于云計算廠商來說,把數據庫放到容器里混部超賣就是一件很好的事情:容器的隔離性,高資源利用率,以及管理上的便利性都與該場景十分契合。這種情況下將數據庫放入Docker中,也許對他們而言就是利大于弊的。但對于更多的場景來說,可靠性往往都是優先級最高的的屬性,犧牲可靠性換取可維護性通常并不是一個可取的選擇。更何況也很難說運維管理數據庫的工作,會因為用了Docker而輕松多少:為了安裝部署一次性的便利而犧牲長久的日常運維可維護性并不是一個好主意。
綜上所述,將生產環境的數據庫放入容器中確實不是一個明智的選擇。