docker hub推送问题

问题开始

我自己的项目有一套自动构建+推送到 dockerHub 的工作流。
现在我将原本的项目前后端拆分为单独的仓库,发现工作流运行出现了问题

这是我原本的工作流

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
name: Publish frontend Docker image

on:
push:
tags:
- '*'

permissions:
contents: read

concurrency:
group: docker-publish-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false

jobs:
normalize-version:
name: Validate and normalize tag
runs-on: ubuntu-latest
outputs:
valid: ${{ steps.version.outputs.valid }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Validate vX.Y.Z-compatible tag
id: version
shell: bash
env:
TAG: ${{ github.ref_name }}
run: bash scripts/release/normalize-version.sh

publish-image:
name: Build and push frontend Docker image
needs: normalize-version
if: needs.normalize-version.outputs.valid == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to container registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}

- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_NAMESPACE }}/videobackup-frontend:${{ needs.normalize-version.outputs.version }}
${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_NAMESPACE }}/videobackup-frontend:latest
labels: |
org.opencontainers.image.title=VideoBackup frontend
org.opencontainers.image.version=${{ needs.normalize-version.outputs.version }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max

大概的步骤是:

先检查版本号,比如v1.0.0这样的三段式,v*.*.*的格式(Semantic Versioning,MAJOR.MINOR.PATCH语义化版本号),不符合格式的规范成这个格式

然后开始构建前端。先通过 docker/setup-buildx-action@v3 安装docker cli,然后使用docker/login-action@v3通过 github secret中的信息登录 docker hub。最后通过 docker/build-push-action@v6 构建并push镜像。
但是测试运行的时候,发现推送时报错

1
ERROR: failed to build: failed to solve: failed to fetch oauth token: unexpected status from GET request to https://auth.docker.io/token?scope=repository%3A***%2Fvideobackup-frontend%3Apull%2Cpush&service=registry.docker.io: 401 Unauthorized: access token has insufficient scopes

排查

一般来说这个的意思是 PAT 的权限不足以 push 上 dockerhub 。但是我很明确是授予了 read&write 权限的

而且更诡异的是,这一个发布的工作流是从另一个已经正常跑了 5 个月的仓库直接扒下来的,理论上配置什么的都相同,不应该出问题才对。

我的第一反应是 github secret 填错了,因为填入之后就看不到填入的内容,于是我尝试各种替换 github secrets 测试,无论怎样都是 401 错误。

于是我开始尝试根据流程排查。首先可以确认的是docker/login-action@v3是没有问题的,因为该步骤提示Login Succeeded!,说明REGISTRY_USERNAME和REGISTRY_PASSWORD是可以正常登录的,但是最后仍然提示没有授权。

已知:

  • 提示令牌权限不足

  • 确认令牌生成时给足了权限

  • 确认填入的是我的令牌

那么可能的原因只剩下了

  1. docker官方权限管理出问题了

  2. 工作流没有使用我的PAT

docker hub官方我没办法,且社区没有看到有明确的帖子讨论这件事情,因此不太可能是官方的问题。那就只能往工作流中没有使用我的 PAT 上面靠。

于是尝试让工作流直接将最终的config.json打印在控制台,发现了问题:config.json中存在两个密钥

1
2
3
4
5
6
7
8
9
10
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "..."
},
"registry-1.docker.io": {
"auth": "..."
}
}
}

按道理来说只应该存在registry-1.docker.io才对,多出了一个https://index.docker.io/v1/。

为此单独开了一个仓库测试:

在仓库传入registry host为registry-1.docker.io时,登录前和登录后的config.json分别为

1
2
3
4
5
6
7
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "..."
}
}
}
1
2
3
4
5
6
7
8
9
10
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "..."
},
"registry-1.docker.io": {
"auth": "..."
}
}
}

在 registry host为docker.io时,登录前和登录后的config.json分别为

1
2
3
4
5
6
7
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "..."
}
}
}
1
2
3
4
5
6
7
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "..."
}
}
}

如实验结果所示,在登录工作流运行之前,Action Runner容器中就已经存在了一份config.json文件,并且config.json文件中存在一个PAT。查找资料后,感觉可能是因为这个的原因

image\.png

这里有一个小插曲,在 Github Action 日志中,会自动隐藏掉 Github Secret 的值。我之前猜想这个隐藏是通过监控变量传值实现的。

但实际上,在日志中输出已经写入到文件中的 PAT 的值的时候,发现仍然不能输出,会被 github 拦截掉,才知道,最高等的食材往往采用最朴素的烹饪方式——直接从输出内容中匹配到相同的字符串,然后隐藏。

现在已经知道了存在一个多余的PAT,但是没有解释一件事情:在测试的时候,为什么当registry host为docker.io的时候,登录写入的 K 是https://index.docker.io/v1/,并且当registry host为registry-1.docker.io的时候推送存在问题

借助 GPT 对整体流程的相关代码进行扫描:

查阅了三个action脚本的代码没有发现明显问题

这里docker/login-action@v3对于 register host 的处理只有一个当不存在时自动设置为docker.io,无伤大雅

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
export function getAuthList(inputs: Inputs): Array<Auth> {
if (inputs.registryAuth && (inputs.registry || inputs.username || inputs.password || inputs.scope || inputs.ecr)) {
throw new Error('Cannot use registry-auth with other inputs');
}
let auths: Array<Auth> = [];
if (!inputs.registryAuth) {
const registry = inputs.registry || 'docker.io';
auths.push({
registry,
username: inputs.username,
password: inputs.password,
scope: inputs.scope,
ecr: inputs.ecr || 'auto',
configDir: scopeToConfigDir(registry, inputs.scope)
});
} else {
auths = (yaml.load(inputs.registryAuth) as Array<Auth>).map(auth => {
if (auth.password) {
core.setSecret(auth.password); // redacted in workflow logs
}
const registry = auth.registry || 'docker.io';
return {
registry,
username: auth.username,
password: auth.password,
scope: auth.scope,
ecr: auth.ecr || 'auto',
configDir: scopeToConfigDir(registry, auth.scope)
};
});
}
if (auths.length == 0) {
throw new Error('No registry to login');
}
return auths;
}

那么怀疑会不会是 docker 本身的问题。在本机 docker 环境完成复现后,我开始借助 gpt 对 docker 官方的代码库进行扫描,着重处理 docker 官方域名的处理逻辑:

docker cli

cli/command/registry/login.go line 161

1
2
3
4
5
if opts.serverAddress != "" && opts.serverAddress != registry.DefaultNamespace {
serverAddress = opts.serverAddress
} else {
serverAddress = registry.IndexServer
}

此处:

1
2
registry.DefaultNamespace == docker.io
registry.IndexServer == https://index.docker.io/v1/

在docker login流程中,传入的serverAddress不为空且不为docker.io时,设置为传入值,否则设置为默认https://index.docker.io/v1/

cli/config/configfile/file.go line 44

1
2
3
4
5
6
func getAuthConfigKey(domainName string) string {
if domainName == "docker.io" || domainName == "index.docker.io" {
return authConfigKey
}
return domainName
}

而authConfigKey = "``https://index.docker.io/v1/``", 也就是说,当DomainName为docker.io的时候,会直接返回https://index.docker.io/v1/。

这两串代码一起,会导致docker login docker.io时在本地config.json文件写入的domian为https://index.docker.io/v1/而不是docker.io

docker buildKit

util/resolver/resolver.go line 146

1
2
3
if host == "docker.io" {
host = "registry-1.docker.io"
}

docker buildx

util/dockerutil/dockerconfig/configprovider.go line 122~125

1
2
3
4
hostKey := host
if host == authprovider.DockerHubRegistryHost {
hostKey = authprovider.DockerHubConfigfileKey
}

其中

1
2
DockerHubConfigfileKey = "https://index.docker.io/v1/"
DockerHubRegistryHost = "registry-1.docker.io"

在 docker 中, buildx用于管理BuildKit的各种控制器,此处代码用于读取本地 config.json 以为 BuildKit 提供仓库认证信息

此处会检查hostKey是否为registry-1.docker.io,如果是则将其替换为https://index.docker.io/v1/

container

在 container 中检索到代码

core/remotes/docker/config/hosts.go line 102

1
2
3
4
if host == "docker.io" {
hosts[len(hosts)-1].scheme = "https"
hosts[len(hosts)-1].host = "registry-1.docker.io"
}

在传入是docker.io时,container会将其映射回registry-1.docker.io

core/remotes/docker/registry.go line 200

1
2
3
else if host == "docker.io" {
config.Host = "registry-1.docker.io"
}

串联

也就是说,在docker login阶段,如果:

  • 传入的register host为docker.io,那么会被规范化为https://index.docker.io/v1/并保存

  • 传入的register host为registry-1.docker.io,不会做规范处理直接以registry-1.docker.io原样保存

在docker/build-push-action@v6阶段,如果:

  • 传入的register host为docker.io,会被规范化为https://index.docker.io/v1/并尝试从config.json获取

  • 传入的register host为registry-1.docker.io,也会被规范化为https://index.docker.io/v1/并尝试从config.json获取

这样,

  • 当传入register host为docker.io时,写入和读取的都是https://index.docker.io/v1/,即使原本github runner中存在,也会被覆盖

  • 传入的register host为registry-1.docker.io时,写入registry-1.docker.io,读取https://index.docker.io/v1/,本应该读取失败。但是因为github runner自带一个用于加速拉取的PAT,会读取成功,但是该PAT不具备访问个人仓库权限

后记

想要把上面的改改提个 pr 上去,看 cli 调用流程的时候又发现个小问题¯\(°_o)/¯

在docker login的时候,docker cli会将docker.io解析为index.docker.io并且写入config.json。但是docker logout的时候没有解析流程

也就是说,

  • 登陆时:docker login docker.io成功,写入https://index.docker.io/v1/

  • 登出时:docker logout docker.io成功(实际失败),尝试删除docker.io,但不存在

PixPin\_2026\-09\-23\_13\-03\-25\.gif

该问题已提交pr,会被合并吗?

后记的后记

又双发现个问题,域名前方可以带有scheme,也就是http://和https://。在docker login中确实对其进行了处理,但是docker login的时候判断域名是否是docker.io是在 URL 处理之前的,也就是说:

处理官方域名兼容 ----> 处理传入域名scheme

所以如果docker login ``https://docker.io最终会绕过解析化流程最终成功在config.json中写入docker.io......

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func runLogin(ctx context.Context, dockerCLI command.Cli, opts loginOptions) error {
······
// 在这里将docker.io进行处理
if opts.serverAddress != "" && opts.serverAddress != registry.DefaultNamespace {
serverAddress = opts.serverAddress
} else {
serverAddress = registry.IndexServer
}
isDefaultRegistry := serverAddress == registry.IndexServer

// 在这里GetDefaultAuthConfig方法会检测"://"并进行处理,统一处理为不带协议前缀的形式
* *authConfig, err := command.GetDefaultAuthConfig(dockerCLI.ConfigFile(), opts.user == "" && opts.password == "", serverAddress, isDefaultRegistry)
if err == nil && authConfig.Username != "" && authConfig.Password != "" {
msg, err = loginWithStoredCredentials(ctx, dockerCLI, authConfig)
}
······
}

在login.go文件中会调用register.go文件的authConfig, err := command.GetDefaultAuthConfig()方法,该方法会在register.go文件中进行scheme前缀的处理,但这一个流程是在将docker.io解析化为index.docker.io流程之后,因此如果输入的是https://docker.io的话,会跳过前方的解析流程,在后方的register.go文件中才被scheme前缀处理,最终落库到config.json中的键就是docker.io而不是之前的index.docker.io

于是:

  • docker login ``https://docker.io,写入config.json为docker.io

  • buildKit推送失败,因为镜像引用不支持scheme,因此无论如何都不会读取docker.io

buildx 不会接受外部或者环境变量传入的 host,而是通过镜像引用解析出来的,比如镜像引用为ghcr.io/yin2hao/test:latest,buildx就会自动解析registry为ghcr.io。

但是,如果镜像引用没有指定的话,就会默认是docker.io,也就是会被解析为index.docker.io。在尝试获取凭证的时候,就会发现:config.json中只有docker.io的key没有index.docker.io的key,因此推送就会失败