知乎在2016年完成了全业务的容器化,并在自研容器平台上作为原生镜像部署运行。随后,我们陆续实现了CI、Cron、Kafka、HAProxy、HBase、Twemproxy等一系列核心服务和基础组件的容器化。知乎不仅是容器技术的重度依赖,更是容器技术的深度践行者。本文分享知乎容器技术核心组件在镜像仓库的生产实践。基本背景容器的核心理念是通过镜像对运行环境进行封装,实现“一次构建,到处运行”,从而避免运行环境不一致导致的各种异常。在容器镜像的发布过程中,镜像仓库起到镜像存储和分发的作用,并支持通过标签进行镜像版本管理,类似于Git仓库在代码开发过程中所起的作用,在整个过程中缺一不可容器环境组件。镜像仓库实现方式按使用范围可分为两类:DockerHub,在公网环境中向所有容器用户开放的镜像服务,DockerRegistry,供开发者或公司在内部环境中搭建镜像仓库服务.基于网络带宽、延迟限制、公网下载镜像的可控性,通常需要使用DockerRegistry在私有云环境中搭建自己的镜像仓库服务。DockerRegistry本身是开源的,当前接口版本为V2(以下描述均针对该版本),支持多种存储后端,例如:InMemory:使用本地内存映射的临时存储驱动。这仅供参考和测试。文件系统:配置为使用本地文件系统中的目录树的本地存储驱动程序。S3:在AmazonSimpleStorageService(S3)存储桶中存储对象的驱动程序。Azure:在MicrosoftAzureBlobStorage中存储对象的驱动程序。Swift:在OpenstackSwift中存储对象的驱动程序。OSS:阿里云OSS中存储对象的驱动。GCS:将对象存储在GoogleCloudStorage存储桶中的驱动程序。默认使用本地磁盘作为DockerRegistry的存储,可以在本地启动一个镜像仓库服务,配置如下:$dockerrun-d\-p5000:5000\--restart=always\--nameregistry\-v/mnt/registry:/var/lib/registry\registry:2生产环境挑战很明显,上面这种方式启动的镜像仓库是无法在生产环境中使用的,问题如下:性能问题:readdelayof基于磁盘文件系统的DockerRegistry进程过于庞大,无法满足高并发、高吞吐量的镜像请求需求。并且受限于单机的磁盘、CPU、网络资源,无法满足上百台机器同时拉取镜像的负载压力。容量问题:单机磁盘容量有限,存储容量存在瓶颈。知道生产环境中不同版本的镜像大概有几万个,单次备份的容量在15T左右,备份的容量会增加很多。权限控制:在生产环境中,需要为镜像仓库配置相应的权限认证。缺少权限认证的镜像仓库就像没有认证的Git仓库一样,很容易造成信息泄露或者代码污染。在知乎的生产环境中,容器平台上运行着上百个业务,上万个容器。在繁忙的时候,每天都会创建几十万个容器,每个镜像的平均大小在1G左右。在部署高峰期,镜像仓库的压力非常大,上述性能和容量问题也尤为明显。知乎解决方案为解决上述性能和容量问题,需要将DockerRegistry构建为分布式服务,实现服务能力和存储能力的水平扩展。最重要的一点是为DockerRegistry选择一个共享的分布式存储后端,比如S3、Azure、OSS、GCS等云存储。这样,DockerRegistry本身就可以成为一个无状态的服务来水平扩展。实现架构如下:该方案主要有以下特点:客户端流量负载均衡为了实现多个DockerRegistry的流量负载均衡,需要引入LoadBalance模块。常见的LoadBalance组件,如LVS、HAProxy、Nginx等代理方案,存在单机性能瓶颈,无法满足数百台机器同时拉取镜像的带宽压力。因此,我们采用客户端负载均衡的方案,DNS负载均衡:Docker守护进程在解析Registry域名时,通过DNS解析为一个DockerRegistry实例IP,让不同的机器从不同的DockerRegistry拉取镜像,实现负载均衡。并且由于Dockerdaemon每次拉取镜像只需要解析一次Registry域名,所以本身DNS负载的压力也很小。从上图可以看出,我们每一个DockerRegistry实例都对应一个Nginx,部署在同一台主机上。对Registry的访问必须通过Nginx。Nginx在这里并没有起到负载均衡的作用,下面会具体介绍它的作用。这种基于DNS的客户端负载平衡的主要问题是它无法自动删除死后端。当某个Nginx出现故障时,会严重影响镜像仓库的可用性。因此需要第三方健康检查服务来检查DockerRegistry的节点。当健康检查失败时,相应的A记录会被移除,恢复健康检查,再将A记录添加回来。Nginx权限控制由于是完全私有云,加上维护成本的考虑,我们的DockerRegistry之前没有做过任何权限相关的配置。后来随着公司的发展,安全问题越来越重要,DockerRegistry的权限控制也提上了日程。对于DockerRegistry的权限管理,官方主要提供了两种方式,一种是简单的basicauth,一种是比较复杂的tokenauth。我们对DockerRegistry权限控制的主要需求是提供基本的认证和授权,尽量减少对现有系统的改动。basicauth方法只提供基本的认证功能,不包含认证。但是tokenauth的方式过于复杂,需要单独维护一个token服务。除非你需要相当全面和细粒度的ACL控制,并希望与现有的身份验证和身份验证系统集成,否则不推荐使用tokenauth的官方方式。这些方法都不适合我们。我们首先采用了basicauth+Nginx的权限控制方式。basicauth用于提供基本身份验证。OpenRestry+lua仅需少量代码即可针对不同的URL灵活配置路由认证策略。目前我们实现的认证策略主要有以下几种:基于仓库目录的权限管理:针对不同的仓库目录提供不同的权限控制。比如/v2/path1是公共仓库目录,可以直接访问,而/v2/path2是私有仓库目录,需要认证才能访问。基于机器的权限管理:只允许某些特定机器拥有拉/推图像权限。Nginx镜像缓存DockerRegistry本身基于文件系统,响应延迟大,并发性差。为了在降低后端存储负载压力的同时降低延迟,提高并发性,需要在DockerRegistry中添加缓存。DockerRegistry目前只支持在内存或Redis中缓存镜像级别的元信息,无法缓存镜像数据本身。我们也使用Nginx来缓存URL接口数据。为了防止缓存过大,可以配置缓存过期时间,只缓存最近读取的图像数据。主要配置如下:proxy_cache_path/dev/shm/registry-cachelevels=1:2keys_zone=registry-cache:10mmax_size=124G;添加缓存后,DockerRegistry的性能较之前有了明显的提升。经过测试,100台机器并行拉取一个1.2G的镜像层,不缓存平均耗时1m50s,最长2m30s。添加缓存配置后,平均下载时间为40s左右,最长为58s。可以看出,图片并发下载性能的提升还是比较明显的。HDFS存储后端DockerRegistry的后端分布式存储,我们选择使用HDFS,因为在私有云场景下访问公有云存储网络带宽和S3等延迟是无法接受的。HDFS本身也是一个稳定的分布式存储系统,广泛应用于大数据存储领域,其可靠性满足生产环境的要求。但是Registry正式版并没有提供HDFSStorageDriver,所以我们按照官方的接口要求和例子实现了DockerRegistry的HDFSStorageDriver。出于性能考虑,我们选择了使用Golang实现的原生HDFS客户端(colinmarc/hdfs)。StorageDriver的实现比较简单,只需要实现StorageDriver和FileWriter这两个接口即可。具体接口如下:typeStorageDriverinterface{//Name返回驱动程序的人类可读的“名称”。Name()string//GetContent检索存储在“path”处的内容作为[]byte.GetContent(ctxcontext.Context,pathstring)([]byte,error)//PutContentstoresthe[]bytecontentatalocationdesignatedby“path”.PutContent(ctxcontext.Context,pathstring,content[]byte)error//Readerretrievesanio.ReadCloserforthecontentsstoreedat"path"//withagivenbyteoffset.Reader(ctxcontext.Context,pathstring,offsetint64)(io.ReadCloser,error)//Writer返回一个FileWriter,它将存储写入的内容//在调用Commit.Writer(ctxcontext.Context,pathstring,appendbool)(FileWriter,error)//统计给定路径的文件信息,包括当前//字节大小和创建时间,error)//MovemovesanojectstoredatsourcePathtodestPath,removingthe//originalobject.Move(ctxcontext.Context,sourcePathstring,destPathstring)error//Deleterecursivelydeletesallobjectsstoredat"path"及其子路径.Delete(ctxcontext.Context,pathstring)errorURLFor(ctxcontext.Context,pathstring,optionsmap[string]interface{})(string,error)}typeFileWriterinterface{io.WriteCloser//SizereturnsthenumberofbyteswrittentothisFileWriter.Size()int64//CancelremovesanywrittencontentfromthisFileWriter.Cancel()error//CommitflushesallcontentwrittentothisFileWriterandmakesit//availableforfuturecallstoStorageDriver.GetContentand//StorageDriver.Reader.Commit()错误}其中最需要注意的是Driver的写入append参数,需要存储后端及其客户端提供相应的append方法colinmarc/hdfsHDFS客户端没有实现append方法,我们实现这个方法。在镜像清洗持续集成系统中,生产环境代码的每次发布都对应容器镜像的构建和发布,这会导致镜像仓库存储空间的不断增加,需要对容器镜像进行清理不用的图片及时释放存储空间。但是DockerRegistry本身并没有配置镜像TTL的机制,所以需要自己开发定时清理脚本。DockerRegistry中删除镜像有两种方式,一种是删除镜像:DELETE/v2/
