背景

在实现容器镜像仓库的时候,我们时常需要向 Docker CLI 抛出一些特定的问题信息,以便于指导用户执行适当的操作。例如,当用户推送镜像时,超出了服务配额,引导客户调整配额或者使用 TCR 企业版。

分析与实现

为了能够正确的向客户端抛出信息,我们需要了解客户端是如何处理这些错误请求的,并在服务端做出适当的响应。本篇以 Docker CLI v19.03 为例进行分析,代码仓库地址为:https://github.com/docker/cli。Docker CLI 使用 github.com/docker/distribution/registry/client 库与镜像仓库服务端交互,所使用的代码都使用 vendor 方式固定到了 docker/cli 项目中。

错误处理场景梳理

`docker/cli在与镜像仓库服务端交互中,以下 method 需要对服务端的请求响应信息做错误处理:

  • tokenHandlerfetchTokenWithBasicAuthfetchTokenWithOAuth
  • registryRepositoriestagsAllGet
  • manifestsExistsGetPutDelete
  • blobOpenCreate
  • blobStatterStatClear

这些 method 中遇到错误信息时,会调用 HandleErrorResponse 方法进行处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// HandleErrorResponse returns error parsed from HTTP response for an
// unsuccessful HTTP response code (in the range 400 - 499 inclusive). An
// UnexpectedHTTPStatusError returned for response code outside of expected
// range.
func HandleErrorResponse(resp *http.Response) error {
	if resp.StatusCode >= 400 && resp.StatusCode < 500 {
		// Check for OAuth errors within the `WWW-Authenticate` header first
		// See https://tools.ietf.org/html/rfc6750#section-3
		for _, c := range challenge.ResponseChallenges(resp) {
			if c.Scheme == "bearer" {
				var err errcode.Error
				// codes defined at https://tools.ietf.org/html/rfc6750#section-3.1
				switch c.Parameters["error"] {
				case "invalid_token":
					err.Code = errcode.ErrorCodeUnauthorized
				case "insufficient_scope":
					err.Code = errcode.ErrorCodeDenied
				default:
					continue
				}
				if description := c.Parameters["error_description"]; description != "" {
					err.Message = description
				} else {
					err.Message = err.Code.Message()
				}

				return mergeErrors(err, parseHTTPErrorResponse(resp.StatusCode, resp.Body))
			}
		}
		err := parseHTTPErrorResponse(resp.StatusCode, resp.Body)
		if uErr, ok := err.(*UnexpectedHTTPResponseError); ok && resp.StatusCode == 401 {
			return errcode.ErrorCodeUnauthorized.WithDetail(uErr.Response)
		}
		return err
	}
	return &UnexpectedHTTPStatusError{Status: resp.Status}
}

错误处理逻辑

在服务端请求响应代码介于400500 之间时,会先检查OAuth 的错误。在没有匹配到 OAuth 相关的错误时,才会使用 parseHTTPErrorResponse 处理请求响应错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func parseHTTPErrorResponse(statusCode int, r io.Reader) error {
	var errors errcode.Errors
	body, err := ioutil.ReadAll(r)
	if err != nil {
		return err
	}

	// For backward compatibility, handle irregularly formatted
	// messages that contain a "details" field.
	var detailsErr struct {
		Details string `json:"details"`
	}
	err = json.Unmarshal(body, &detailsErr)
	if err == nil && detailsErr.Details != "" {
		switch statusCode {
		case http.StatusUnauthorized:
			return errcode.ErrorCodeUnauthorized.WithMessage(detailsErr.Details)
		case http.StatusTooManyRequests:
			return errcode.ErrorCodeTooManyRequests.WithMessage(detailsErr.Details)
		default:
			return errcode.ErrorCodeUnknown.WithMessage(detailsErr.Details)
		}
	}

	if err := json.Unmarshal(body, &errors); err != nil {
		return &UnexpectedHTTPResponseError{
			ParseErr:   err,
			StatusCode: statusCode,
			Response:   body,
		}
	}

	if len(errors) == 0 {
		// If there was no error specified in the body, return
		// UnexpectedHTTPResponseError.
		return &UnexpectedHTTPResponseError{
			ParseErr:   ErrNoErrorsInBody,
			StatusCode: statusCode,
			Response:   body,
		}
	}

	return errors
}

在一些早期的镜像仓库服务端,会返回如下结构的错误信息:

1
2
3
type detailsErr struct {
	Details string `json:"details"`
}

为了保持对旧版本的向前兼容,parseHTTPErrorResponse 时会首先尝试 Unmarshal 服务端响应的 body 是否为 detailsErr 格式。如果 Unmarshal 成功了( error 为 nil 且 detailsErr.Details 为空),则转换成新的 errcode 格式错误信息。

在尝试 Unmarshal detailsErr 时如果 error 不是nil,则说明不是旧版本的错误信息,可以尝试 Unmarshal 看是否为 errcode 格式的。如果 Unmarshal 失败则直接使用UnexpectedHTTPResponseError 结构体抛出服务端返回的状态码和 body 内容。如果Unmarshal 成功了,但是 errors 为空(body 中没有错误信息)时,同样使用 使用UnexpectedHTTPResponseError 结构体抛出服务端返回的状态码和 body 内容。

定制错误信息

docker/cli 的代码看到这里已经很明显了,如果我们想要向Docker 客户端抛出特定的错误信息,只需要返回特定格式的 body 数据即可。显而易见的,当下我们应该选择 errcode 格式而不是已经过时了的 detailsErr 格式。

简单的 demo 代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
	"net/http"

	"github.com/docker/distribution/registry/api/errcode"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
)

type detailsErr struct {
	Details interface{} `json:"details,omitempty"`
}

func main() {
	var r = gin.Default()
	r.GET("/v2/", anyroute)
	var v2 = r.Group("/v2/:ns/:repo")
	v2.POST("/blobs/uploads/", anyroute, gcDenied)
	// v2.HEAD("/manifests/:tag", anyroute, forbidden)
	// v2.GET("/manifests/:tag", anyroute, forbidden)
	// v2.HEAD("/blobs/:tag", anyroute)
	// v2.GET("/blobs/:tag", anyroute)

	r.Run(":5000")
}

func anyroute(c *gin.Context) {
	var ns = c.Param("ns")
	var repo = c.Param("repo")
	var tag = c.Param("tag")
	logrus.WithField("URL", c.Request.URL).
		WithField("METHOD", c.Request.Method).
		WithField("ns", ns).
		WithField("repo", repo).
		WithField("tag", tag).
		Info("info")

	c.Next()
}

func gcDenied(c *gin.Context) {
	var denied = errcode.ErrorCodeDenied.WithMessage("您的个人版仓库已超配额,请使用 TCR  企业版:https://console.cloud.tencent.com/tcr")
	c.JSON(http.StatusForbidden, gin.H{"errors": []errcode.Error{
		denied,
	}})
}

func forbidden(c *gin.Context) {
	c.JSON(http.StatusForbidden, detailsErr{
		Details: "请求拒绝,您没有权限",
	})
}

把 demo 代码运行起来,添加host.docker.internal:5000 为 insecure 仓库。执行 docker push 可以看到结果符合预期。

image-20201127182618975

结语

​ 向客户端抛出合理的错误提示信息,能够帮助客户、一线和 FT 团队定位出问题所在。在涉及到用户使用开源产品的时候,通常我们无法控制用户的客户端,为了能够提供更好的支持,可以分析开源客户端的代码,在服务端做好兼容性支持。