知乎十萬級容器規模的Java分布式鏡像倉庫實踐

知乎十萬級容器規模的Java分佈式鏡像倉庫實踐

36份一線互聯網Java面試電子書

84個Java稀缺面試題視頻

前言

知乎在 2016 年已經完成了全量業務的容器化,並在自研容器平臺上以原生鏡像的方式部署和運行,並在後續陸續實施了 CI、Cron、Kafka、HAProxy、HBase、Twemproxy 等系列核心服務和基礎組件的容器化。知乎既是容器技術的重度依賴者,也是容器技術的深度實踐者,我們會陸續把容器技術的實踐經驗通過專欄和大家進行分享,本篇文章來分享知乎在鏡像倉庫這個容器技術核心組件的生產實踐。

基礎背景

容器的核心理念在於通過鏡像將運行環境打包,實現“一次構建,處處運行”,從而避免了運行環境不一致導致的各種異常。在容器鏡像的發佈流程中,鏡像倉庫扮演了鏡像的存儲和分發角色,並且通過 tag 支持鏡像的版本管理,類似於 Git 倉庫在代碼開發過程中所扮演的角色,是整個容器環境中不可缺少的組成部分。

鏡像倉庫實現方式按使用範圍可以分為 Docker Hub 和 Docker Registry 兩類,前者是在公網環境下面向所有容器使用者開放的鏡像服務,後者是供開發者或公司在內部環境下搭建鏡像倉庫服務,由於公網下載鏡像的網絡帶寬、延遲限制以及可控性的角度考慮,在私有云環境下通常需要採用 Docker Registry 來搭建自己的鏡像倉庫服務。

Docker Registry 本身開源,當前接口版本為 V2 (以下描述均針對該版本),支持多種存儲後端,如:

  • inmemory: A temporary storage driver using a local inmemory map. This exists solely for reference and testing.
  • filesystem: A local storage driver configured to use a directory tree in the local filesystem.
  • 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 的存儲,用下面的配置即可本地啟動一個鏡像倉庫服務:

$ docker run -d \

-p 5000:5000 \

--restart=always \

--name registry \

-v /mnt/registry:/var/lib/registry \

registry:2

生產環境挑戰

很顯然,以上面的方式啟動的鏡像倉庫是無法在生產環境中使用的,問題如下:

  1. 性能問題:基於磁盤文件系統的 Docker Registry 進程讀取延遲大,無法滿足高併發高吞吐鏡像請求需要,且受限於單機磁盤,CPU,網絡資源限制,無法滿足上百臺機器同時拉取鏡像的負載壓力。
  2. 容量問題:單機磁盤容量有限,存儲容量存在瓶頸。知乎生產環境中現有的不同版本鏡像大概有上萬個,單備份的容量在 15T 左右,加上備份這個容量還要增加不少。
  3. 權限控制:在生產環境中,需要對鏡像倉庫配置相應的權限認證。缺少權限認證的鏡像倉庫就如同沒有認證的 Git 倉庫一樣,很容易造成信息洩露或者代碼汙染。

知乎的生產環境中,有幾百個業務以及幾萬個容器運行在我們的容器平臺上,繁忙時每日創建容器數近十萬,每個鏡像的平均大小在 1G 左右,部署高峰期對鏡像倉庫的壓力是非常大的,上述性能和容量問題也表現的尤為明顯。

知乎解決方案

為了解決上述的性能和容量等問題,需要將 Docker Registry 構造為一個分佈式服務,實現服務能力和存儲容量的水平擴展。這其中最重要的一點是為 Docker Registry 選擇一個共享的分佈式存儲後端,例如 S3,Azure,OSS,GCS 等雲存儲。這樣 Docker Registry 本身就可以成為無狀態服務從而水平擴展。實現架構如下:

知乎十萬級容器規模的Java分佈式鏡像倉庫實踐

該方案主要有以下幾個特點:

  • 客戶端流量負載均衡

為了實現對多個 Docker Registry 的流量負載均衡,需要引入 Load Balance 模塊。常見的 Load Balance 組件,如 LVS,HAProxy,Nginx 等代理方案都存在單機性能瓶頸,無法滿足上百臺機器同時拉取鏡像的帶寬壓力。因此我們採用客戶端負載均衡方案,DNS 負載均衡:在 Docker daemon 解析 Registry 域名時,即通過 DNS 解析到某個 Docker Registry 實例 IP 上,這樣不同機器從不同的 Docker Registry 拉取鏡像,實現負載均衡。而且由於 Docker daemon 每次拉取鏡像時只需解析一次 Registry 域名,對於 DNS 負載壓力本身也很小。從上圖可以看出,我們每一個 Docker Registry 實例對應一個 Nginx,部署在同一臺主機上,對 Registry 的訪問必須通過 Nginx,Nginx 這裡並沒有起到負載均衡的作用,其具體的作用將在下文描述。這種基於 DNS 的客戶端負載均衡存在的主要問題是無法自動摘掉掛掉的後端。當某臺 Nginx 掛掉時,鏡像倉庫的可用性就會受到比較嚴重的影響。因此需要有一個第三方的健康檢查服務來對 Docker Registry 的節點進行檢查,健康檢查失敗時,將對應的 A 記錄摘掉,健康檢查恢復,再將 A 記錄加回來。

  • Nginx 權限控制

由於是完全的私有云,加上維護成本的考慮,我們的 Docker Registry 之前並沒有做任何權限相關的配置。後來隨著公司的發展,安全問題也變的越來越重要,Docker Registry 的權限控制也提上了日程。對於 Docker Registry 的權限管理,官方主要提供了兩種方式,一種是簡單的 basic auth,一種是比較複雜的token auth。我們對 Docker Registry 權限控制的主要需求是提供基本的認證和鑑權,並且對現有系統的改動儘量最小。basic auth 的方式只提供了基本的認證功能,不包含鑑權。而 token auth 的方式又過於複雜,需要維護單獨的 token 服務,除非你需要相當全面精細的 ACL 控制並且想跟現有的認證鑑權系統相整合,否則官方並不推薦使用 token auth 的方式。這兩種方式對我們而言都不是很適合。我們最後採用了 basic auth + Nginx 的權限控制方式。basic auth 用來提供基本的認證,OpenRestry + lua 只需要少量的代碼,就可以靈活配置不同 URL 的路由鑑權策略。我們目前實現的鑑權策略主要有以下幾種:

  • 基於倉庫目錄的權限管理:針對不同的倉庫目錄,提供不同的權限控制,例如 /v2/path1 作為公有倉庫目錄,可以直接進行訪問,而 /v2/path2 作為私有倉庫目錄,必須經過認證才能訪問。基於機器的權限管理:只允許某些特定的機器有 pull/push 鏡像的權限。Nginx 鏡像緩存

Docker Registry 本身基於文件系統,響應延遲大,併發能力差。為了減少延遲提升併發,同時減輕對後端存儲的負載壓力,需要給 Docker Registry 增加緩存。Docker Registry 目前只支持將鏡像層級 meta 信息緩存到內存或者 Redis 中,但是對於鏡像數據本身無法緩存。我們同樣利用 Nginx 來實現 URL 接口數據的 cache。為了避免 cache 過大,可以配置緩存失效時間,只緩存最近讀取的鏡像數據。主要的配置如下所示:

proxy_cache_path /dev/shm/registry-cache levels=1:2 keys_zone=registry-cache:10m max_size=124G;

加了緩存之後,Docker Registry 性能跟之前相比有了明顯的提升。經過測試,100 臺機器並行拉取一個 1.2G 的 image layer,不加緩存平均需要 1m50s,花費最長時間為 2m30s,添加緩存配置之後,平均的下載時間為 40s 左右,花費最長時間為 58s,可見對鏡像併發下載性能的提升還是相當明顯的。

  • HDFS 存儲後端

Docker Registry 的後端分佈式存儲,我們選擇使用 HDFS,因為在私有云場景下訪問諸如 S3 等公有云存儲網絡帶寬和時延都無法接受。HDFS 本身也是一個穩定的分佈式存儲系統,廣泛應用在大數據存儲領域,其可靠性滿足生產環境的要求。但 Registry 的官方版本里並沒有提供 HDFS 的 Storage Driver, 所以我們根據官方的接口要求及示例,實現了 Docker Registry 的 HDFS Storage Driver。出於性能考慮,我們選用了一個 Golang 實現的原生 HDFS Client (colinmarc/hdfs)。Storage Driver 的實現比較簡單,只需要實現 StorageDriver 以及 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

}

其中需要注意的是 StorageDriver 的 Writer 方法裡的 append 參數,這就要求存儲後端及其客戶端必須提供相應的 append 方法,colinmarc/hdfs 這個 HDFS 客戶端中沒有實現 append 方法,我們補充實現了這個方法。

  • 鏡像清理

持續集成系統中,每次生產環境代碼發佈都對應有容器鏡像的構建和發佈,會導致鏡像倉庫存儲空間的持續上漲,需要及時清理不用的鏡像釋放存儲空間。但 Docker Registry 本身並沒有配置鏡像 TTL 的機制,需要自己開發定時清理腳本。

Docker Registry 刪除鏡像有兩種方式,一種是刪除鏡像:

DELETE /v2//manifests/

另一種是直接刪除鏡像層 blob 數據:

DELETE /v2//blobs/

由於容器鏡像層級間存在依賴引用關係,所以推薦使用第一種方式清理過期鏡像的引用,然後由 Docker Registry 自身判斷鏡像層數據沒有被引用後再執行物理刪除。

未來展望

通過適當的開發和改造,我們實現了一套分佈式的鏡像倉庫服務,可以通過水平擴容來解決單機性能瓶頸和存儲容量問題,很好的滿足了我們現有生產環境需求。但是在生產環境大規模分發鏡像時,服務端(存儲、帶寬等)依然有較大的負載壓力,因此在大規模鏡像分發場景下,採用 P2P 的模式分發傳輸鏡像更加合適,例如阿里開源的 Dragonfly 和騰訊開發的 FID 項目。知乎當前幾乎所有的業務都運行在容器上,隨著業務的快速增長,該分佈式鏡像倉庫方案也會越來越接近性能瓶頸,因此我們在後續也會嘗試引入 P2P 的鏡像分發方案,以滿足知乎快速增長的業務需求。

團隊介紹

知乎架構平臺團隊是支撐整個知乎業務的基礎技術團隊,開發和維護著知乎幾乎全量的核心基礎組件,包括容器、Redis、MySQL、Kafka、LB、HBase 等核心基礎設施,團隊小而精,每個同學都獨當一面負責上面提到的某個核心系統。


分享到:


相關文章: