目录

透过一次registry(distribution)的cache缓存bug看context

背景

    去年在做sealer的时候,考虑到需要改造distribution[1](registry,镜像仓库,GitHub仓库为distribution,下面统称为distribution),使其支持multi-proxyregistry(社区仅支持一个proxy registry),然后支持sealer需要的镜像缓存。

    既然要改造,我一上来就fork了distribution,然后基于其main分支做了multi-registry的拓展[2] (贴下当初做完后写的原理文档)。然后开始测试,紧接着就发现有的layer不会被缓存下来,而且基本是必现的,并且还是随机的。这就尴尬了,一顿操作猛如虎,改造完了竟然发现不可用。

    随后查看日志,错误如图,发现主要问题应该是“context canceled”。

/images/2022-01-24-1.png     根据log,我把错误锁定到了distribution/registry/proxy/proxyblobstore.go的一段代码。不过排查了一会还是没有找到根因,此时我知道ctx 换成 context.Background()可以解决问题,但是没有找到 context 被cancel的具体位置。同时,我回到distribution提了issue[3],然后看了distribution最近一个release的时间,发现竟然是两年前了,说明main分支确实可能存在不稳定的问题,所以我用回v2.7.1测试了下,发现这个版本完全没有问题,随后我就基于v2.7.1进行hack了。然后因为问题不存在了,我也就没有继续跟这个问题了。

	go func(dgst digest.Digest) {
		if err := pbs.storeLocal(ctx, dgst); err != nil {
			dcontext.GetLogger(ctx).Errorf("Error committing to storage: %s", err.Error())
		}

		blobRef, err := reference.WithDigest(pbs.repositoryName, dgst)
		if err != nil {
			dcontext.GetLogger(ctx).Errorf("Error creating reference: %s", err)
			return
		}

		pbs.scheduler.AddBlob(blobRef, repositoryTTL)
	}(dgst)

    过了一段时间后,我已经没有在sealer做相关工作了。也没有关注distribution了,这时当初提的issue有了一些反馈,有一些社区的同学也遇到了这类问题,然后也看到了该issue,想要寻找合适的解决方案。

解决思路

问题排查与定位

    前段时间的一个下午,闲着无聊,在逛社区,随便看看。又想到了之前没有解决的distribution缓存不完整问题,于是想着重新翻翻看看(过了几个月,我肯定变强了吧?),试着彻底解决它。

    重新看了之前觉得有问题的代码块。先描述下proxy registry拉取blob的逻辑:(1)proxy registry收到客户端拉取blob的请求。(2)registry检查本地是否存在,有就直接写回给http.ResponseWriter。(3)若本地不存在的话,则向具体的endpoint发起请求,写回http.ResponseWriter,称为a;同时开启另一个goroutine同样发起请求,写回本地,称为b;这是两个独立的请求,请求相同的blob,但是共享同一个cxt,所以我就怀疑,很可能存在一种情况,a请求完成时,关闭了context,而b又涉及一些逻辑对context.Done()进行检查,若发现context结束了,就结束本地存储。这应该就是问题的主要原因。

/images/2022-01-24-2.png

    最后发现是v2.7.1之后的其中一个commit,将请求的context传入了request中,http request持有 context是没问题的,但问题是该context还被另一个请求持有,其中一个context提前结束,都可能影响另一个功能。

解决方法

    问题明确后,解决方法就很简单了,将两个请求的context独立开,但在registry向远程请求blob失败时,仍然能终止缓存本地的事务。所以最后使用 cancelable context解决[4]。

Context的使用思考

  • 我认为context的优势是,其定义的几个接口,尤其是Done()接口,能很好地提供跨goroutine的事务生命周期的同步;如distribution所用的方式,其使用在缓存处使用context也许是因为“向远程拉取blob,传回client”和“向远程拉取blob,缓存到本地”这两个goroutine在完成一个blob返回服务,所以当某个goroutine因某错误失败了,就及时结束另一个任务。这是没问题的,但是context不能很好地管理goroutine的结束,比如此次问题其实是正常结束context,而非由错误导致。
  • 在distribution项目中,我观察到,http server接收到请求时,会将一些数据,比如一部分头部信息,写入到context中(k-v)。然后向下传递,我认为这是一个双刃剑,好的是,开发人员可以特别方便地将数据写入context中,然后一直传就行了。但同时,这很可能也是一个极其糟糕的编码习惯,后期维护追溯k-v数据源会极其困难。一旦在项目中,同意这种编码习惯,你永远不知道在使用该context时,它变成啥样了。(blog[5] “If you use ctx.Value in my(non-existent) company, you’re fired”)

参考资料

[1] https://github.com/distribution/distribution.

[2] https://github.com/osemp/distribution/wiki/osemp-distribution-%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90.

[3] https://github.com/distribution/distribution/issues/3438.

[4] https://github.com/distribution/distribution/pull/3567.

[5] https://faiface.github.io/post/context-should-go-away-go2/.