知乎十萬級容器規(guī)模的分布式鏡像倉庫實踐
知乎在 2016 年已經(jīng)完成了全量業(yè)務(wù)的容器化,并在自研容器平臺上以原生鏡像的方式部署和運行。
后續(xù)我們陸續(xù)實施了 CI、Cron、Kafka、HAProxy、HBase、Twemproxy 等系列核心服務(wù)和基礎(chǔ)組件的容器化。
知乎既是容器技術(shù)的重度依賴者,也是容器技術(shù)的深度實踐者,本篇文章分享知乎在鏡像倉庫這個容器技術(shù)核心組件的生產(chǎn)實踐。
基礎(chǔ)背景
容器的核心理念在于通過鏡像將運行環(huán)境打包,實現(xiàn)“一次構(gòu)建,處處運行”,從而避免了運行環(huán)境不一致導(dǎo)致的各種異常。
在容器鏡像的發(fā)布流程中,鏡像倉庫扮演了鏡像的存儲和分發(fā)角色,并且通過 tag 支持鏡像的版本管理,類似于 Git 倉庫在代碼開發(fā)過程中所扮演的角色,是整個容器環(huán)境中不可缺少的組成部分。
鏡像倉庫實現(xiàn)方式按使用范圍可以分為兩類:
- Docker Hub,在公網(wǎng)環(huán)境下面向所有容器使用者開放的鏡像服務(wù)
- Docker Registry,供開發(fā)者或公司在內(nèi)部環(huán)境下搭建鏡像倉庫服務(wù)。
基于公網(wǎng)下載鏡像的網(wǎng)絡(luò)帶寬、延遲限制以及可控性的角度考慮,在私有云環(huán)境下通常需要采用 Docker Registry 來搭建自己的鏡像倉庫服務(wù)。
Docker Registry 本身開源,當前接口版本為 V2 (以下描述均針對該版本),支持多種存儲后端,如:
- InMemory:A temporary storage driver using a local in memory map. This exists solely for reference and testing。
- FileSystem:A local storage driver configured to use a directory tree in the local file system。
- S3:A driver storing objects in an Amazon Simple Storage Service (S3) bucket。
- Azure:A driver storing objects in Microsoft Azure Blob Storage。
- Swift:A driver storing objects in Openstack Swift。
- OSS:A driver storing objects in Aliyun OSS。
- GCS:A driver storing objects in a Google Cloud Storage bucket。
默認使用本地磁盤作為 Docker Registry 的存儲,用下面的配置即可本地啟動一個鏡像倉庫服務(wù):
- $ docker run -d \
- -p 5000:5000 \
- --restart=always \
- --name registry \
- -v /mnt/registry:/var/lib/registry \
- registry:2
生產(chǎn)環(huán)境挑戰(zhàn)
很顯然,以上面的方式啟動的鏡像倉庫是無法在生產(chǎn)環(huán)境中使用的,問題如下:
- 性能問題:基于磁盤文件系統(tǒng)的 Docker Registry 進程讀取延遲大,無法滿足高并發(fā)高吞吐鏡像請求需要。
且受限于單機磁盤,CPU,網(wǎng)絡(luò)資源限制,無法滿足上百臺機器同時拉取鏡像的負載壓力。
- 容量問題:單機磁盤容量有限,存儲容量存在瓶頸。知乎生產(chǎn)環(huán)境中現(xiàn)有的不同版本鏡像大概有上萬個,單備份的容量在 15T 左右,加上備份這個容量還要增加不少。
- 權(quán)限控制:在生產(chǎn)環(huán)境中,需要對鏡像倉庫配置相應(yīng)的權(quán)限認證。缺少權(quán)限認證的鏡像倉庫就如同沒有認證的 Git 倉庫一樣,很容易造成信息泄露或者代碼污染。
知乎的生產(chǎn)環(huán)境中,有幾百個業(yè)務(wù)以及幾萬個容器運行在容器平臺上,繁忙時每日創(chuàng)建容器數(shù)近十萬,每個鏡像的平均大小在 1G 左右。
部署高峰期對鏡像倉庫的壓力是非常大的,上述性能和容量問題也表現(xiàn)的尤為明顯。
知乎解決方案
為了解決上述的性能和容量等問題,需要將 Docker Registry 構(gòu)造為一個分布式服務(wù),實現(xiàn)服務(wù)能力和存儲容量的水平擴展。
這其中最重要的一點是為 Docker Registry 選擇一個共享的分布式存儲后端,例如 S3,Azure,OSS,GCS 等云存儲。
這樣 Docker Registry 本身就可以成為無狀態(tài)服務(wù)從而水平擴展。
實現(xiàn)架構(gòu)如下:
該方案主要有以下幾個特點:
客戶端流量負載均衡
為了實現(xiàn)對多個 Docker Registry 的流量負載均衡,需要引入 Load Balance 模塊。
常見的 Load Balance 組件,如 LVS,HAProxy,Nginx 等代理方案都存在單機性能瓶頸,無法滿足上百臺機器同時拉取鏡像的帶寬壓力。
因此我們采用客戶端負載均衡方案,DNS 負載均衡:在 Docker daemon 解析 Registry 域名時,通過 DNS 解析到某個 Docker Registry 實例 IP 上,這樣不同機器從不同的 Docker Registry 拉取鏡像,實現(xiàn)負載均衡。
而且由于 Docker daemon 每次拉取鏡像時只需解析一次 Registry 域名,對于 DNS 負載壓力本身也很小。
從上圖可以看出,我們每一個 Docker Registry 實例對應(yīng)一個 Nginx,部署在同一臺主機上。
對 Registry 的訪問必須通過 Nginx,Nginx 這里并沒有起到負載均衡的作用,其具體的作用將在下文描述。
這種基于 DNS 的客戶端負載均衡存在的主要問題是無法自動摘掉掛掉的后端。
當某臺 Nginx 掛掉時,鏡像倉庫的可用性就會受到比較嚴重的影響。因此需要有一個第三方的健康檢查服務(wù)來對 Docker Registry 的節(jié)點進行檢查,健康檢查失敗時,將對應(yīng)的 A 記錄摘掉,健康檢查恢復(fù),再將 A 記錄加回來。
Nginx 權(quán)限控制
由于是完全的私有云,加上維護成本的考慮,我們的 Docker Registry 之前并沒有做任何權(quán)限相關(guān)的配置。
后來隨著公司的發(fā)展,安全問題也變的越來越重要,Docker Registry 的權(quán)限控制也提上了日程。
對于 Docker Registry 的權(quán)限管理,官方主要提供了兩種方式,一種是簡單的 basic auth,一種是比較復(fù)雜的 token auth。
我們對 Docker Registry 權(quán)限控制的主要需求是提供基本的認證和鑒權(quán),并且對現(xiàn)有系統(tǒng)的改動盡量最小。
basic auth 的方式只提供了基本的認證功能,不包含鑒權(quán)。而 token auth 的方式又過于復(fù)雜,需要維護單獨的 token 服務(wù)。
除非你需要相當全面精細的 ACL 控制并且想跟現(xiàn)有的認證鑒權(quán)系統(tǒng)相整合,否則官方并不推薦使用 token auth 的方式。這兩種方式對我們而言都不是很適合。
我們***采用了 basic auth + Nginx 的權(quán)限控制方式。basic auth 用來提供基本的認證,OpenRestry + lua 只需要少量的代碼,就可以靈活配置不同 URL 的路由鑒權(quán)策略。
我們目前實現(xiàn)的鑒權(quán)策略主要有以下幾種:
基于倉庫目錄的權(quán)限管理:針對不同的倉庫目錄,提供不同的權(quán)限控制。
例如 /v2/path1 作為公有倉庫目錄,可以直接進行訪問,而 /v2/path2 作為私有倉庫目錄,必須經(jīng)過認證才能訪問。
基于機器的權(quán)限管理:只允許某些特定的機器有 pull/push 鏡像的權(quán)限。
Nginx 鏡像緩存
Docker Registry 本身基于文件系統(tǒng),響應(yīng)延遲大,并發(fā)能力差。為了減少延遲提升并發(fā),同時減輕對后端存儲的負載壓力,需要給 Docker Registry 增加緩存。
Docker Registry 目前只支持將鏡像層級 meta 信息緩存到內(nèi)存或者 Redis 中,但是對于鏡像數(shù)據(jù)本身無法緩存。
我們同樣利用 Nginx 來實現(xiàn) URL 接口數(shù)據(jù)的 cache。為了避免 cache 過大,可以配置緩存失效時間,只緩存最近讀取的鏡像數(shù)據(jù)。
主要的配置如下所示:
- proxy_cache_path /dev/shm/registry-cache levels=1:2 keys_zone=registry-cache:10m max_size=124G;
加了緩存之后,Docker Registry 性能跟之前相比有了明顯的提升。
經(jīng)過測試,100 臺機器并行拉取一個 1.2G 的 image layer,不加緩存平均需要 1m50s,花費最長時間為 2m30s。
添加緩存配置之后,平均的下載時間為 40s 左右,花費最長時間為 58s,可見對鏡像并發(fā)下載性能的提升還是相當明顯的。
HDFS 存儲后端
Docker Registry 的后端分布式存儲,我們選擇使用 HDFS,因為在私有云場景下訪問諸如 S3 等公有云存儲網(wǎng)絡(luò)帶寬和時延都無法接受。
HDFS 本身也是一個穩(wěn)定的分布式存儲系統(tǒng),廣泛應(yīng)用在大數(shù)據(jù)存儲領(lǐng)域,其可靠性滿足生產(chǎn)環(huán)境的要求。
但 Registry 的官方版本里并沒有提供 HDFS 的 Storage Driver,所以我們根據(jù)官方的接口要求及示例,實現(xiàn)了 Docker Registry 的 HDFS Storage Driver。
出于性能考慮,我們選用了一個 Golang 實現(xiàn)的原生 HDFS Client (colinmarc/hdfs)。
Storage Driver 的實現(xiàn)比較簡單,只需要實現(xiàn) Storage Driver 以及 FileWriter 這兩個 interface 就可以了。
具體的接口如下:
- type StorageDriver interface {
- // Name returns the human-readable "name" of the driver。
- Name() string
- // GetContent retrieves the content stored at "path" as a []byte.
- GetContent(ctx context.Context, path string) ([]byte, error)
- // PutContent stores the []byte content at a location designated by "path".
- PutContent(ctx context.Context, path string, content []byte) error
- // Reader retrieves an io.ReadCloser for the content stored at "path"
- // with a given byte offset.
- Reader(ctx context.Context, path string, offset int64) (io.ReadCloser, error)
- // Writer returns a FileWriter which will store the content written to it
- // at the location designated by "path" after the call to Commit.
- Writer(ctx context.Context, path string, append bool) (FileWriter, error)
- // Stat retrieves the FileInfo for the given path, including the current
- // size in bytes and the creation time.
- Stat(ctx context.Context, path string) (FileInfo, error)
- // List returns a list of the objects that are direct descendants of the
- //given path.
- List(ctx context.Context, path string) ([]string, error)
- // Move moves an object stored at sourcePath to destPath, removing the
- // original object.
- Move(ctx context.Context, sourcePath string, destPath string) error
- // Delete recursively deletes all objects stored at "path" and its subpaths.
- Delete(ctx context.Context, path string) error
- URLFor(ctx context.Context, path string, options map[string]interface{}) (string, error)
- }
- type FileWriter interface {
- io.WriteCloser
- // Size returns the number of bytes written to this FileWriter.
- Size() int64
- // Cancel removes any written content from this FileWriter.
- Cancel() error
- // Commit flushes all content written to this FileWriter and makes it
- // available for future calls to StorageDriver.GetContent and
- // StorageDriver.Reader.
- Commit() error
- }
其中需要注意的是 Storage Driver 的 Writer 方法里的 append 參數(shù),這就要求存儲后端及其客戶端必須提供相應(yīng)的 append 方法。
colinmarc/hdfs 這個 HDFS 客戶端中沒有實現(xiàn) append 方法,我們補充實現(xiàn)了這個方法。
鏡像清理
持續(xù)集成系統(tǒng)中,每次生產(chǎn)環(huán)境代碼發(fā)布都對應(yīng)有容器鏡像的構(gòu)建和發(fā)布,會導(dǎo)致鏡像倉庫存儲空間的持續(xù)上漲,需要及時清理不用的鏡像釋放存儲空間。
但 Docker Registry 本身并沒有配置鏡像 TTL 的機制,需要自己開發(fā)定時清理腳本。
Docker Registry 刪除鏡像有兩種方式,一種是刪除鏡像:
- DELETE /v2/<name>/manifests/<reference>
另一種是直接刪除鏡像層 blob 數(shù)據(jù):
- DELETE /v2/<name>/blobs/<digest>
由于容器鏡像層級間存在依賴引用關(guān)系,所以推薦使用***種方式清理過期鏡像的引用,然后由 Docker Registry 自身判斷鏡像層數(shù)據(jù)沒有被引用后再執(zhí)行物理刪除。
未來展望
通過適當?shù)拈_發(fā)和改造,我們實現(xiàn)了一套分布式的鏡像倉庫服務(wù),可以通過水平擴容來解決單機性能瓶頸和存儲容量問題,很好的滿足了我們現(xiàn)有生產(chǎn)環(huán)境需求。
但是在生產(chǎn)環(huán)境大規(guī)模分發(fā)鏡像時,服務(wù)端(存儲、帶寬等)依然有較大的負載壓力。
因此在大規(guī)模鏡像分發(fā)場景下,采用 P2P 的模式分發(fā)傳輸鏡像更加合適,例如阿里開源的 Dragonfly 和騰訊開發(fā)的 FID 項目。
知乎當前幾乎所有的業(yè)務(wù)都運行在容器上,隨著業(yè)務(wù)的快速增長,該分布式鏡像倉庫方案也會越來越接近性能瓶頸。
因此我們在后續(xù)也會嘗試引入 P2P 的鏡像分發(fā)方案,以滿足知乎快速增長的業(yè)務(wù)需求。