背景
registry v2提供了新的基于token的鉴权方式,可以细粒度的配置不同用户对于不同镜像的权限,做到更好的镜像隔离。而公司正好需要使用鉴权,于是我们选用了开源项目docker auth。开始的时候使用都很正常,但是自从给私服配置了证书,推送镜像和拉取镜像都走https之后,镜像的推送经常会出现401错误(access to the requested resource is not authorized)。该问题和github上的一个issue很类似,
https://github.com/docker/docker/issues/17104(issue中提到的解决方案也是同事添加上去的)
写这篇文章的目的也就是给大家介绍下为什么会出现这个问题,同时避免大家再踩这个坑。。
线上环境
在介绍问题的根源之前,首先要介绍一下线上的环境。因为这个问题只有在特定的组网情况下才会出现。
为了更具有通用性,这里将组网大致概括为如下情况:用户请求通过前端nginx(开启443端口)转发到业务的nginx,然后由业务的nginx转发到不同的服务上。
虚线中的服务器上部署了nginx,同时也部署了registry v2以及其他的服务。这些服务(包括nginx)都采用docker部署,只有nginx对外暴露了80端口。
问题定位
当推送镜像的时候出现401,通过抓包发现当推送一个镜像层的时候PATCH的请求出现401导致整个镜像推送失败。经过定位发现PATCH请求发送到registry的时候没有携带对应的鉴权信息(添加一个名叫Authorization的header)。
通过阅读代码发现,当发送PATCH请求的时候会判断当前的url和整次推送请求的协议是否相同(整次请求的协议也就是这个镜像推送的时候是采用http还是https),如果不相同则不会添加对应的鉴权信息。这PATCH的请求是上一次发送的POST请求返回的Location,也就是说这个location是http的而不是https。
// /vendor/src/github.com/docker/distribution/registry/client/transport/transport.go
// tries to refresh/fetch a new token.
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := cloneRequest(req)
for _, modifier := range t.Modifiers {
if err := modifier.ModifyRequest(req2); err != nil { // 这里进行修改
return nil, err
}
}
t.setModReq(req, req2)
res, err := t.base().RoundTrip(req2)
if err != nil {
t.setModReq(req, nil)
return nil, err
}
res.Body = &onEOFReader{
rc: res.Body,
fn: func() { t.setModReq(req, nil) },
}
return res, nil
}
// /vendor/src/github.com/docker/distribution/registry/client/auth/session.go
func (ea *endpointAuthorizer) ModifyRequest(req *http.Request) error {
v2Root := strings.Index(req.URL.Path, "/v2/")
if v2Root == -1 {
return nil
}
ping := url.URL{
Host: req.URL.Host,
Scheme: req.URL.Scheme,
Path: req.URL.Path[:v2Root+4],
}
pingEndpoint := ping.String()
challenges, err := ea.challenges.GetChallenges(pingEndpoint)
if err != nil {
return err
}
if len(challenges) > 0 {
for _, handler := range ea.handlers {
for _, challenge := range challenges {
if challenge.Scheme != handler.Scheme() // ##1 {
continue
}
if err := handler.AuthorizeRequest(req, challenge.Parameters); err != nil {
return err
}
}
}
}
return nil
}
继续阅读registry的代码发现POST请求构建返回的location的时候是根据POST请求中的一个header(X-Forwarded-Proto)来判断当前的请求是http还是https,如果是http的话则设置Location为http协议(下面代码##1的位置)。
// /vendor/src/github.com/docker/distribution/registry/api/v2/urls.go
// NewURLBuilderFromRequest uses information from an *http.Request to
// construct the root url.
func NewURLBuilderFromRequest(r *http.Request) *URLBuilder {
var scheme string
// ##1
forwardedProto := r.Header.Get("X-Forwarded-Proto")
//
switch {
case len(forwardedProto) > 0:
scheme = forwardedProto
case r.TLS != nil:
scheme = "https"
case len(r.URL.Scheme) > 0:
scheme = r.URL.Scheme
default:
scheme = "http"
}
host := r.Host
forwardedHost := r.Header.Get("X-Forwarded-Host")
if len(forwardedHost) > 0 {
// According to the Apache mod_proxy docs, X-Forwarded-Host can be a
// comma-separated list of hosts, to which each proxy appends the
// requested host. We want to grab the first from this comma-separated
// list.
hosts := strings.SplitN(forwardedHost, ",", 2)
host = strings.TrimSpace(hosts[0])
}
basePath := routeDescriptorsMap[RouteNameBase].Path
requestPath := r.URL.Path
index := strings.Index(requestPath, basePath)
u := &url.URL{
Scheme: scheme,
Host: host,
}
if index > 0 {
// N.B. index+1 is important because we want to include the trailing /
u.Path = requestPath[0 : index+1]
}
return NewURLBuilder(u)
}
这个header的作用是告诉服务器经过代理服务器转发的请求的原始协议是什么。
a de facto standard for identifying the originating protocol of an HTTP request, since a reverse proxy (or a load balancer) may communicate with a web server using HTTP even if the request to the reverse proxy is HTTPS. An alternative form of the header (X-ProxyUser-Ip) is used by Google clients talking to Google servers.(https://en.wikipedia.org/wiki/List_of_HTTP_header_fields)
也就是说registry收到的X-Forwarded-Proto不是TLS。回顾下之前的线上环境,一个请求在到达registry之前经过了两次nginx转发。第一次转发将https变为了http,我们很容易就想到了是不是第一次转发的时候没有设置这个header,经过查证的确是这样。。
问题解决
最前端nginx转发的时候配置如下,
proxy_set_header X-Forwarded-Proto $scheme;