删除Docker Registry里的镜像怎么那么难
除了官方的Docker Hub,Docker也提供了Docker Registry来让大家搭建自己的私有镜像库。虽然它提供了删除的API,但是不好用。为什么小小的删除功能没弄好呢?我们该怎么办?
问题
有很多人抱怨说Docker Registry的删除功能并不会真正地释放空间。虽然官方提供了API,但那些都是软删除(soft delete),只是把二进制和镜像的关系解除罢了,并不是真正的删除。真正的删除有那么困难吗?
目前docker官方提供了如下3个软删除的方法:
DELETE:/v2/<name>/manifests/<reference>
:这个API是软删除一个清单(manifest),但是真正占用存储空间的层还在。DELETE:/v2/<name>/blobs/<digest>
:这个API类似上面那个,只不过它要软删除的对象是层(layer)罢了。DELETE:/v2/<name>/blobs/uploads/<uuid>
:这个只是取消掉另一个上传的进程罢了。
难点
为了删除不需要的数据,腾出磁盘空间,我们希望有删除功能。但是如果一个不健全的删除功能不小心把有用的数据给删了,那还不如没有这个功能呢。在这个逻辑前提下,docker团队选择了不删除数据。除此之外,还有一个考虑:删除功能是需要很大工作量的。大家知道程序员们的价格是比较高的,以相对便宜的磁盘空间为代价,在眼下先节省这笔人工费开销,并把它投入到更有价值的地方去,不是更有意义么。
那为什么删除功能需要很大的工作量呢?这是因为删除有一个大坑。首先我们来看一下存储模型:所有的数据都被存放到VFS之上,它提供了最终一致性,但是可能需要较长时间才能达到一致。再看镜像的数据结构:一个docker镜像包含了3个概念:标签(tag)、清单(manifest)和层(layer)。标签被关联到清单上,而清单则被关联到层上,就像下图一样:
其实单说删除本身其实是比较容易的事情,就像现在的垃圾收集算法一样。一个是根搜索法,从根节点开始计算,若某对象不可达,则表明不被用到,可删之。在docker镜像中并没有“根层”的概念,所以需要循环所有的清单来看是否有哪些层不被用到。还有一个是引用计数法,它为每一个对象添加一个引用计数器,为0则表明不被用到。实现起来比较简单,但是很难删除掉循环引用。在docker镜像中,因为它是一个有向无环图(DAG),所以并不会有“循环引用”,正是解决这个问题的极佳方案之一。那么这个大坑在哪里呢?问题在于并发。想象一下,如果在删除某层的过程中,有另外一个push的线程误认为此层已经存在,就会在删除之后导致第二个线程push的镜像不能正常工作。
方案
目前docker官方有几个数据删除的方案(但是还没有实现):
- 引用计数法:如上文所述是垃圾收集算法的一种。需要维护引用计数器,对于已经存在着的docker registry来说需要数据迁移。
- 全局锁:引入GC线程来做删除。删除的时候不能写入。实现简单,但是影响性能。
- 新老代:也是引入GC线程来做删除。将存储分为年轻年老两代,GC线程删除某一代的时候允许同时写入另一代。避免了全局锁的性能问题但是实现起来比较麻烦。
- 数据库:引入一个数据库,用事务来解决并发的问题。
如果你等不及docker官方的实现,并且对自己的私有库的控制力比较强,不需要考虑并发,可以使用这个脚本来彻底删除,记得先把registry停掉,或者是设置为只读模式以避免并发哦。设置一个cron任务,每天凌晨停止服务一小段时间,然后运行脚本,再启动服务就好了。
如果你也认为磁盘空间是比较廉价的,那么使用软删除,也就是上文介绍的官方删除API应该能够符合需求。虽然磁盘空间并没有真正地释放出来,但是删除之后镜像真的就不能再被pull下来了。记得要把delete的设置打开,否则会得到The operation is unsupported
的异常信息。
如果不想使用那些感觉上奇奇怪怪的脚本,还有一个选择是设置两套docker registry,比较稳定的镜像版本放在其中一个库里,不稳定的开发版放另一个库里。每天凌晨把不稳定的版本库清空。这样的话就不会让稳定的版本库的磁盘消耗增长太快,但是也增加了一些管理的难度。没有两全其美的事啊。
写完这篇博客后不久,一个gc的commit被合并到了主干,有望在docker registry的2.4版本提供官方的删除功能。不过,它跟上面脚本的思路类似,也需要停止服务或者设置只读模式,并不能完美解决这个问题。抛开解决方案,就删除功能而言,我在测试过程中也发现了一个缺陷,需要使用docker 1.10版和registry 2.3版才能解决。
v1 vs v2
Docker Registry的老版本v1是用python写的,源码在这里。新版本v2是用go写的,源码在这里。它们的模型略有变化。老版本v1是个链表,A层链接到B层,B层链接到C层,层层组织起来一个镜像,每一层的ID都是随机生成的。这样一来浪费空间,不能实现层存储的共享,二来有安全隐患,如果不停地提交,会造成ID冲突概率提升。但也正因如此,删除的时候完全没有顾忌,真是成也萧何败也萧何啊。新版本v2的ID是对内容进行sha256哈希之后的结果,所以相同内容的层ID一定是相同的,很好地解决了v1的问题,就是删除功能需要仔细地设计才能实现。除此之外还有鉴权等其他改动,有兴趣的话可以参考这篇文章。
题外话:有些人看英文容易把registry和repository搞混。在docker的领域内,repository就是相同名字镜像的集合,比如tomcat的docker repository。而registry就是提供repository服务的系统,比如Docker Hub或者是自己使用Docker Registry安装的私有库等。