:容器網(wǎng)絡(luò)與數(shù)據(jù)卷管理)
有一陣子我總在半夜被電話叫醒不是這個服務(wù)掛掉就是兩個服務(wù)互相連不上。后來排查來排查去問題基本都出在兩塊Docker容器網(wǎng)絡(luò)管理沒規(guī)劃好容器數(shù)據(jù)卷管理沒設(shè)計明白。說白了容器網(wǎng)絡(luò)管的是容器與容器、容器與外界之間的通信鏈路數(shù)據(jù)卷管的是容器內(nèi)數(shù)據(jù)的生命周期這兩件事搞不定容器化項目跑起來就是個隨時會響的警報器。這篇文章不是擺一堆理論名詞而是把我在實際項目里反復(fù)踩過的坑、驗證過的方案按照從原理到實操的順序一次說透。適合剛接觸Docker的人也適合那些已經(jīng)用了一段時間、但一遇到網(wǎng)絡(luò)和數(shù)據(jù)問題就頭疼的開發(fā)者。1. 先把容器網(wǎng)絡(luò)的底層邏輯捋清楚1.1 容器是怎么上網(wǎng)的很多剛接觸Docker的人會把容器當(dāng)成一臺輕量虛擬機(jī)但網(wǎng)絡(luò)模型其實差別很大。虛擬機(jī)是完整的系統(tǒng)鏡像自帶網(wǎng)卡、IP、路由表而容器本質(zhì)上是一組共享內(nèi)核的進(jìn)程默認(rèn)情況下它在網(wǎng)絡(luò)層面是租了宿主機(jī)的一套基礎(chǔ)設(shè)施。Docker容器啟動時會為每個容器創(chuàng)建一個獨立的網(wǎng)絡(luò)命名空間。這個命名空間可以理解為一張隔離的網(wǎng)絡(luò)配置表里面記錄了它自己的網(wǎng)卡、IP、路由、防火墻規(guī)則。默認(rèn)bridge模式往容器里插的是一對veth虛擬網(wǎng)卡一頭在容器內(nèi)部叫eth0另一頭掛在宿主機(jī)一個叫docker0的虛擬網(wǎng)橋上。這個veth pair很像一根虛擬網(wǎng)線一端插進(jìn)容器一端插進(jìn)網(wǎng)橋數(shù)據(jù)就是從這根網(wǎng)線流出來的。容器里的數(shù)據(jù)包到達(dá)docker0網(wǎng)橋之后如果是容器之間互相通信網(wǎng)橋直接二層轉(zhuǎn)發(fā)就搞定了。如果是容器要訪問外網(wǎng)就會走一條NAT規(guī)則將容器私有IP轉(zhuǎn)換成宿主機(jī)IP發(fā)出去外網(wǎng)的包響應(yīng)回來時NAT再把源地址替換回容器IP。這一套機(jī)制和家里路由器做地址轉(zhuǎn)換的原理幾乎一樣容器就是一個被NAT包裹著的內(nèi)網(wǎng)主機(jī)。所以排查網(wǎng)絡(luò)問題時腦子里要有這條鏈路容器網(wǎng)卡 → veth pair → docker0網(wǎng)橋 → 宿主機(jī)路由/NAT → 物理網(wǎng)卡。鏈路里任何一環(huán)出問題表象就是ping不通連不上但原因可能完全不同。我在線下培訓(xùn)時經(jīng)常讓人先畫這條數(shù)據(jù)流畫清楚了一半問題其實就解決了。1.2 四種網(wǎng)絡(luò)模式選錯了就是給自己挖坑Docker網(wǎng)絡(luò)模式主要就四種默認(rèn)bridge、host、none、container。每種的原理和適用場景差別很大我直接用一張表把它們的特性列出來。網(wǎng)絡(luò)模式獨立網(wǎng)絡(luò)棧端口映射外部訪問典型場景bridge默認(rèn)有獨立IP支持 -p 映射通過宿主機(jī)端口訪問單機(jī)多容器隔離部署host無獨立IP共享宿主機(jī)網(wǎng)絡(luò)不支持直接用宿主機(jī)IP和端口性能敏感型或依賴真實IP的應(yīng)用none只有回環(huán)地址不支持不提供網(wǎng)絡(luò)默認(rèn)不需要網(wǎng)絡(luò)的離線任務(wù)container共享指定容器的網(wǎng)絡(luò)棧不支持隨被共享容器sidecar模式、日志采集容器大多數(shù)Web后端服務(wù)、數(shù)據(jù)庫容器默認(rèn)用bridge模式?jīng)]有大問題它隔離性夠好每個容器都有自己獨立的IP。尤其是一個宿主機(jī)跑多個互不干擾的容器時bridge是唯一合理的默認(rèn)選擇。但你如果圖省事所有容器都丟到默認(rèn)的docker0網(wǎng)橋上會遇到一個很煩人的問題大家靠IP地址互相訪問容器重建一次IP就變了配置文件里寫死的IP就成了廢紙。所以實際項目里我?guī)缀醵际亲约簞?chuàng)建外部bridge網(wǎng)絡(luò)這個后面專門講。host模式我特別提醒一句性能確實好省掉了NAT轉(zhuǎn)換的損耗但代價是容器和宿主機(jī)共享一份網(wǎng)絡(luò)棧容器里看到的網(wǎng)卡、IP、路由表和宿主機(jī)完全一樣。兩個容器如果同時監(jiān)聽同一個端口直接沖突。更隱蔽的風(fēng)險是host模式下容器內(nèi)的進(jìn)程能嗅探宿主機(jī)的網(wǎng)絡(luò)流量隔離安全級別大幅下降。我在生產(chǎn)環(huán)境只用host模式跑一種東西對網(wǎng)絡(luò)延遲極其敏感的壓測工具或數(shù)據(jù)采集探針業(yè)務(wù)服務(wù)一律不用。none模式很好理解容器只有回環(huán)網(wǎng)卡不分配任何網(wǎng)絡(luò)能力適合做純計算任務(wù)比如批量數(shù)據(jù)處理、編譯任務(wù)。container模式則是在容器啟動時指定共享另一個容器的網(wǎng)絡(luò)棧常用于sidecar模式例如讓日志采集容器和業(yè)務(wù)容器共享網(wǎng)絡(luò)棧這樣采集器訪問localhost就能摸到業(yè)務(wù)容器。一個原則是默認(rèn)選bridge特殊需求才考慮其他模式。2. 網(wǎng)絡(luò)實操從容器互通到端口映射的完整流程2.1 自定義bridge網(wǎng)絡(luò)讓容器用名字互訪默認(rèn)bridge網(wǎng)絡(luò)有個很大的短板它不支持基于容器名的DNS解析兩個容器只能靠各自IP訪問。一旦容器重建、IP漂移你猜地址、改配置、再重啟整個鏈路就是脆弱的。Docker自帶的--link參數(shù)能緩解這個問題但官方早就把它標(biāo)記為過時功能了只做單機(jī)環(huán)境下的兼容保留。實際項目中我更推薦的做法是創(chuàng)建自定義bridge網(wǎng)絡(luò)。自定義網(wǎng)絡(luò)內(nèi)部自帶一個內(nèi)置DNS服務(wù)只要容器在同一個自定義網(wǎng)絡(luò)里就可以直接用容器名互相解析。這意味著應(yīng)用配置里只需要寫服務(wù)名比如db:3306而不是某個寫死的IP。這個能力是Docker默認(rèn)網(wǎng)橋不提供的很多人不知道結(jié)果白白繞了很多彎。創(chuàng)建一個自定義網(wǎng)絡(luò)的命令很簡單docker network create --driver bridge --subnet172.20.0.0/24 --gateway172.20.0.1 my-net--subnet和--gateway是我習(xí)慣顯式指定的。如果不指定Docker會從它自己的地址池里自動挑一個網(wǎng)段這容易在宿主機(jī)上和其他網(wǎng)段沖突也會讓你在排查時不知道IP段到底是多少。顯式指定后網(wǎng)絡(luò)拓?fù)湟荒苛巳?。再分別啟動兩個容器并接入這個網(wǎng)絡(luò)docker run -d --name app-server --network my-net nginx docker run -d --name app-db --network my-net mysql:8.0此時進(jìn)入app-server容器直接用容器名去ping app-db就能看到它解析出172.20.0.x的地址docker exec app-server ping app-db只要這一步通了應(yīng)用代碼里連接數(shù)據(jù)庫的地址寫成app-db:3306就完全沒問題。這個能力是后續(xù)做服務(wù)編排的基石不管是手動跑容器還是后面用docker compose我都建議先建立自定義網(wǎng)絡(luò)再掛容器。有一點需要提醒自定義網(wǎng)絡(luò)和默認(rèn)docker0網(wǎng)橋是不互通的。如果你有些容器掛在默認(rèn)bridge網(wǎng)絡(luò)另一些掛在自定義網(wǎng)絡(luò)它們之間默認(rèn)互相訪問不通。解決辦法有兩種要么把需要互通的容器都掛到同一個網(wǎng)絡(luò)里要么用docker network connect給容器多接一塊網(wǎng)卡讓它同時屬于兩個網(wǎng)絡(luò)。我在一個項目里就是靠這招把老容器平滑接入新網(wǎng)絡(luò)的不用重建容器運行中就能加網(wǎng)。# 讓某個已運行的容器追加接入新網(wǎng)絡(luò) docker network connect my-net app-server這種網(wǎng)絡(luò)規(guī)劃方式的好處是環(huán)境遷移時只需要把網(wǎng)絡(luò)定義和應(yīng)用配置一起帶走不用再關(guān)心IP變化問題。建議每個獨立項目單獨建一個網(wǎng)絡(luò)別把所有容器糊在一張網(wǎng)里。我和一些同行交流時發(fā)現(xiàn)很多線上事故都是因為把所有容器丟進(jìn)默認(rèn)bridge導(dǎo)致網(wǎng)絡(luò)廣播風(fēng)暴、IP沖突和權(quán)限混亂最后只能全部重建。2.2 端口映射常見坑-p 不只是打個洞容器默認(rèn)有獨立IP外部訪問不到它所以需要端口映射把容器端口暴露到宿主機(jī)上。這條命令大家都會寫但它背后的行為往往被忽略docker run -d -p 8080:80 nginx意思是把宿主機(jī)的8080端口流量轉(zhuǎn)發(fā)到容器的80端口。實現(xiàn)機(jī)制是Docker在宿主機(jī)iptables里插入一條DNAT規(guī)則。宿主機(jī)收到外來請求后根據(jù)規(guī)則把目的地址換成容器IP再轉(zhuǎn)發(fā)進(jìn)去。這里的細(xì)節(jié)是規(guī)則是動態(tài)寫入iptables的有些云平臺控制臺里看不到這些規(guī)則也不建議去直接操作iptables和Docker聯(lián)動區(qū)域否則很容易被Docker自己的規(guī)則覆蓋或者互相沖突。如果你不希望服務(wù)被所有人都能訪問比如數(shù)據(jù)庫端口暴露給本機(jī)的一些管理工具用建議綁定回環(huán)地址docker run -d -p 127.0.0.1:8080:80 nginx這樣宿主機(jī)以外的地方訪問不到你這個服務(wù)安全層面相當(dāng)于給端口加了一道門禁。我在做本地開發(fā)聯(lián)調(diào)時經(jīng)常這么干既能使用工具連數(shù)據(jù)庫又不怕端口暴露到外網(wǎng)。還有兩個高頻坑我必須講。第一EXPOSE不算端口映射。Dockerfile里的EXPOSE 80只是容器鏡像的元數(shù)據(jù)聲明它不代表宿主機(jī)任何端口會被打開。真正對外暴露是-p或者docker-compose里的ports配置做的兩者可以同時存在但作用完全不同。第二使用host模式時-p是無效的因為host模式容器直接用宿主機(jī)端口再用-p映射會直接提示端口沖突或行為異常排查時要先確認(rèn)網(wǎng)絡(luò)模式再判斷映射問題。端口映射后的防火墻狀態(tài)也要注意。Docker在啟動容器時會自動調(diào)整iptables但如果你在宿主機(jī)上另外運行了防火墻管理工具可能在容器重建或重啟Docker服務(wù)時丟失映射規(guī)則表現(xiàn)為剛部署好能訪問重啟后又連不上了。這個情況我在現(xiàn)場調(diào)試時遇到過多次排查思路是不要只盯著容器狀態(tài)要去看iptables規(guī)則是否存在。凡是遇到容器看起來正常端口卻不通第一件事就是查規(guī)則。3. 數(shù)據(jù)卷管理讓數(shù)據(jù)不再跟著容器一起消失3.1 bind mount、數(shù)據(jù)卷、tmpfs到底該用哪個容器是immutable的鏡像層只讀容器層寫的數(shù)據(jù)在容器刪除后默認(rèn)就沒了。容器數(shù)據(jù)卷管理就是為了解決這個容器消失數(shù)據(jù)也消失的問題。Docker提供了三種數(shù)據(jù)持久化方案綁定掛載、具名數(shù)據(jù)卷、tmpfs它們各自定位完全不同。類型位置生命周期備份恢復(fù)適合場景bind mount宿主機(jī)任意目錄/文件宿主機(jī)控制直接打包目錄配置文件、開發(fā)調(diào)試代碼數(shù)據(jù)卷Docker管理的宿主機(jī)目錄Docker控制--volumes-from或拷貝目錄生產(chǎn)環(huán)境數(shù)據(jù)庫、文件存儲tmpfs宿主機(jī)內(nèi)存容器重啟即丟失不需要臨時緩存、密鑰等敏感數(shù)據(jù)綁定掛載最簡單就是把宿主機(jī)路徑整個塞進(jìn)容器比如把項目的配置目錄/etc/nginx/conf.d直接掛進(jìn)去。這種方式的優(yōu)點是路徑直觀改宿主機(jī)的文件容器里立即生效缺點是宿主機(jī)目錄權(quán)限、SELinux標(biāo)簽等Linux安全機(jī)制都會影響容器內(nèi)訪問需要自己處理容器用戶和宿主目錄的所有權(quán)匹配問題。常見的一個坑是MySQL容器用bind mount裸掛一個空目錄結(jié)果MySQL因為目錄權(quán)限不足直接拒絕啟動原因往往是這個目錄的owner不是容器內(nèi)的mysql用戶。生產(chǎn)環(huán)境我更推薦具名數(shù)據(jù)卷。它由Docker自動創(chuàng)建和管理數(shù)據(jù)存放在/var/lib/docker/volumes/卷名/_data下你不用關(guān)心具體路徑Docker會處理好權(quán)限和生命周期。刪除容器時只要不加-v參數(shù)數(shù)據(jù)卷仍然保留下次再啟動新容器掛同一個卷數(shù)據(jù)就還在。具名數(shù)據(jù)卷配合docker-compose里的volumes聲明可以把整個存儲拓?fù)涔袒聛磉w移時非常方便。tmpfs比較特殊它把數(shù)據(jù)放在宿主機(jī)內(nèi)存里讀寫速度極快但容器一停數(shù)據(jù)就丟。它不是用來保存用戶數(shù)據(jù)的而是用來放臨時緩存或者運行時產(chǎn)生的敏感信息比如內(nèi)存中的Token、密鑰避免落到磁盤留下痕跡。比如我在跑一些數(shù)據(jù)處理任務(wù)時會用--tmpfs /tmp既提升了I/O性能又不用反復(fù)清理容器臨時文件。3.2 MySQL容器落盤與備份恢復(fù)的完整實驗我拿數(shù)據(jù)庫容器作為例子因為這是最容易出事故的場景。假設(shè)要部署一個MySQL 8.0容器數(shù)據(jù)必須持久化否則容器一重建訂單表就沒了后果不堪設(shè)想。第一步創(chuàng)建具名數(shù)據(jù)卷docker volume create mysql-data第二步啟動容器把數(shù)據(jù)卷掛載到MySQL的數(shù)據(jù)目錄/var/lib/mysqldocker run -d --name app-db -e MYSQL_ROOT_PASSWORDyourpass --network my-net --mount sourcemysql-data,target/var/lib/mysql mysql:8.0這里建議用--mount語法而不是-v。--mount能顯式聲明源和目的地可讀性更好排查時不容易搞混。啟動后MySQL會在數(shù)據(jù)卷目錄里初始化系統(tǒng)表生成第一個快照結(jié)構(gòu)。之后無論容器怎么刪、怎么重建只要這個卷還在數(shù)據(jù)就都還在。備份要分兩個層面。邏輯備份用mysqldump導(dǎo)出SQL文件物理備份直接拷貝數(shù)據(jù)卷目錄或做快照。我習(xí)慣是邏輯備份走定時任務(wù)物理備份走整盤快照。一條經(jīng)典命令是docker run --rm -e MYSQL_PWDyourpass --network my-net mysql:8.0 mysqldump -h app-db -u root mydb mydb_backup.sql這個命令用同一個鏡像跑一個臨時容器通過剛才建立的網(wǎng)絡(luò)去訪問數(shù)據(jù)庫容器導(dǎo)出完成后臨時容器自動刪除。注意--network my-net不能漏如果不指定網(wǎng)絡(luò)臨時容器走默認(rèn)bridge它連不到數(shù)據(jù)庫容器所在的自定義網(wǎng)絡(luò)會報連接拒絕?;謴?fù)流程也容易踩坑。第一種方式是用SQL文件恢復(fù)新起的空容器掛載的數(shù)據(jù)卷里沒有數(shù)據(jù)執(zhí)行mysql -h app-db -u root -p mydb_backup.sql導(dǎo)入即可。第二種方式是物理恢復(fù)停掉數(shù)據(jù)庫容器用新容器掛載恢復(fù)過的備份目錄比如docker run --rm --volumes-from 原有數(shù)據(jù)庫容器 alpine tar -cf /backup/mysql-backup.tar /var/lib/mysql再掛載這個備份目錄啟動容器。但我這里要特別提示物理備份恢復(fù)時MySQL的系統(tǒng)表和數(shù)據(jù)文件版本必須匹配新容器鏡像版本改動過大時直接掛老目錄容易報多個MySQL版本不兼容的錯誤。日常備份用mysqldump容災(zāi)恢復(fù)才考慮物理快照。數(shù)據(jù)卷還有一個生命周期手感問題docker compose down默認(rèn)不會刪數(shù)據(jù)卷docker compose down -v才會刪。很多人跑容器編排時不知道這個區(qū)別一條down -v把整個數(shù)據(jù)庫數(shù)據(jù)全卷沒了。如果要用這條命令一定三思最好在腳本里加個數(shù)據(jù)卷備份確認(rèn)環(huán)節(jié)。4. 網(wǎng)絡(luò)與數(shù)據(jù)卷的聯(lián)合排障記錄4.1 高頻問題排查速查表網(wǎng)絡(luò)和數(shù)據(jù)卷問題看起來五花八門實際上高頻場景就那么幾種。我自己整理了一個速查表遇到問題先照表判斷能省掉大量重復(fù)排查時間。癥狀可能原因排查命令解決方向容器間ping不通不在同一網(wǎng)絡(luò)docker inspect 容器名查看Networks接入同一網(wǎng)絡(luò)容器名解析失敗掛在默認(rèn)bridge確認(rèn)是否自定義網(wǎng)絡(luò)改用自定義網(wǎng)絡(luò)宿主機(jī)外訪問超時端口映射未綁定公網(wǎng)IP檢查-p參數(shù)用-p 0.0.0.0:8080:80或檢查防火墻容器重啟后數(shù)據(jù)還在但服務(wù)連不上容器IP變了應(yīng)用配置寫死IPdocker inspect看當(dāng)前IP改成容器名/服務(wù)名訪問刪容器后數(shù)據(jù)沒了未使用數(shù)據(jù)卷docker volume ls看卷是否存在改用具名卷掛載目錄后服務(wù)權(quán)限報錯宿主目錄屬主不匹配容器用戶ls -ln查看UID/GIDchown或改用數(shù)據(jù)卷MySQL啟動反復(fù)重啟數(shù)據(jù)目錄權(quán)限或現(xiàn)有數(shù)據(jù)沖突docker logs查看錯誤單獨初始化或清空目錄這個表格里的每一個場景我都實際遇到過。最諷刺的一次是一個同事為了解決容器間IP老是變的問題居然自己寫了一套IP提取腳本定時把新IP寫進(jìn)配置文件。這方案能跑但每次重啟都有一段時間服務(wù)不可用。后來我把兩個服務(wù)放進(jìn)同一個自定義網(wǎng)絡(luò)配置直接寫服務(wù)名效果立竿見影。遇到問題先退一步想機(jī)制反而比著急打補丁更高效。4.2 我踩過坑后保留的三個習(xí)慣第一個習(xí)慣每個項目單獨建一個自定義網(wǎng)絡(luò)并且顯式指定子網(wǎng)。我會把網(wǎng)絡(luò)名、網(wǎng)關(guān)地址、子網(wǎng)范圍寫進(jìn)部署文檔不管單機(jī)部署還是后續(xù)遷移這些參數(shù)都不用猜。容器啟動時不指定--network就是默認(rèn)bridge默認(rèn)bridge行為完全不可控這屬于能用但不好管的典型。第二個習(xí)慣所有需要持久化的數(shù)據(jù)目錄一律使用具名數(shù)據(jù)卷并在容器命名中帶上明確的用途標(biāo)記。例如項目數(shù)據(jù)庫卷命名為xx-db-data緩存卷xx-redis-data。定期用docker volume ls查看無主卷再利用docker volume rm清理。無主卷是指已經(jīng)沒有任何容器引用它的卷長時間累積會占用大量磁盤空間不要等到放不下才想起來清理。第三個習(xí)慣每次提交部署前先模擬一次容器刪除重建的操作。把容器停止、刪除再按原啟動命令重新拉起確認(rèn)數(shù)據(jù)卷正常掛載、網(wǎng)絡(luò)名解析正常、服務(wù)能恢復(fù)訪問。這個演練成本很低但在關(guān)鍵業(yè)務(wù)上能救命。數(shù)據(jù)卷設(shè)計的本質(zhì)就是肆無忌憚地刪容器反正數(shù)據(jù)還在網(wǎng)絡(luò)拓?fù)洳蛔兎?wù)就能原地復(fù)活。如果你發(fā)現(xiàn)某個容器刪了就起不來那說明你的部署方案還沒合格問題越早暴露越省事。最后再多說一句網(wǎng)絡(luò)和數(shù)據(jù)卷本來就是一體兩面的。網(wǎng)絡(luò)讓容器之間能找到彼此數(shù)據(jù)卷讓容器刪了還能恢復(fù)如初。我個人的習(xí)慣是在動手跑第一個容器之前先把網(wǎng)絡(luò)拓?fù)洚嬕槐?、把?shù)據(jù)卷清單列一遍。容器沒想明白就先跑起來后面一定還債這是我在無數(shù)個深夜排障里攢下來的教訓(xùn)。