背景
在实现容器镜像仓库的时候,我们时常需要向 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 需要对服务端的请求响应信息做错误处理:
tokenHandler
的 fetchTokenWithBasicAuth
、fetchTokenWithOAuth
registry
的Repositories
,tags
的 All
、Get
manifests
的 Exists
、Get
、Put
、Delete
blob
的 Open
、Create
blobStatter
的Stat
、Clear
这些 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}
}
|
错误处理逻辑
在服务端请求响应代码介于400
到 500
之间时,会先检查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
可以看到结果符合预期。
结语
向客户端抛出合理的错误提示信息,能够帮助客户、一线和 FT 团队定位出问题所在。在涉及到用户使用开源产品的时候,通常我们无法控制用户的客户端,为了能够提供更好的支持,可以分析开源客户端的代码,在服务端做好兼容性支持。