编程 ghcr.io 拉镜像报 unauthorized / denied / manifest unknown:按凭据→权限→名字的顺序排查

2026-10-20 00:03:47

ghcr.io 拉镜像报 unauthorized / denied / manifest unknown:按凭据→权限→名字的顺序排查

同一个 docker pull ghcr.io/OWNER/IMG:tag,可能返回三种完全不同的错误,处理路径也完全不同。先分清是哪一种,再动手:

报错HTTP 状态含义第一嫌疑
unauthorized401没认证,或本地拿的 token 已失效本地残留了被删/过期的 token
denied403认证过了,但账号对该包没有 read 权限包可见性、包权限、继承开关、组织 PAT 策略
manifest unknown404认证和权限都通了,名字或 tag 不对tag 写错,或被页面上的 .sig 命令带偏

先把错误归到这两类里:401 和 403 是「凭据 / 权限」问题,manifest unknown 是「名字」问题。下面按定位顺序展开。

第一步:清掉本地坏凭据

最常见的 401 是这样的:拉一个公有镜像,本地却还以某个已删除或已过期的 access token 登录着,结果反而 401。解决办法是清掉 ghcr.io 的凭据后重试:

docker logout ghcr.io

如果 logout 之后仍然 401,检查 ~/.docker/config.json,里面 auths 下应该不再有 ghcr.io 的条目。删干净之后,公有包匿名就能拉,不登录也能 pull。

需要登录的场景:

export CR_PAT=YOUR_TOKEN
echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin

输出 Login Succeeded 只说明这条命令跑通了,不代表 token 仍然有效。token 过期或被人删掉之后,登录照样可能成功,但后续 pull 依然 unauthorized。

第二步:用 registry API 分辨 401 还是 403

在 Docker 之外单独打一次 registry API,能把「客户端凭据」和「服务端权限」分开。先按 repo scope 换 token:

TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:OWNER/IMG:pull" | jq -r .token)

公有镜像可以用这种 no-op token,但仍然必须按 repo scope 申请;私有镜像要带 PAT:

TOKEN=$(curl -s -u USERNAME:$CR_PAT \
"https://ghcr.io/token?scope=repository:OWNER/IMG:pull" | jq -r .token)

再带 Bearer 请求 manifest,Accept 头必须包含 distribution manifest 的 media type,否则拿到的不是你要的东西:

curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" \
https://ghcr.io/v2/OWNER/IMG/manifests/main

返回 200 说明这条路径能拉到镜像,问题在本地 Docker 的凭据缓存;返回 401 说明 token 没换到(凭据问题);返回 403 说明凭据有效但账号没有该包的 read 权限,去 GitHub 侧查。

GHE 用户把 ghcr.io 换成 https://containers.SUBDOMAIN.ghe.com。

第三步:PAT 侧的两个坑

GitHub Packages 目前只认 personal access token (classic)。拉取镜像只需要 read:packages;write:packages 用于上传和下载;delete:packages 用于删除镜像。

坑一:在 UI 里勾 write:packages 会连带把 repo 一起勾上,而 repo 权限过宽。想只选 write:packages,用这个链接进去:

坑二:token 过期或被删。表现就是前面说的 Login Succeeded 之后仍然 unauthorized。

第四步:包和仓库的关系

包默认是私有的,只有设为 public 才允许匿名拉取。改可见性在包设置页 Danger Zone → Change visibility,输入包名确认。注意警告:一旦设为 public,就不能再改回 private。

从命令行推送时,包默认不会链接到仓库,即使命名空间和仓库名对得上。最简单的链接方式是用 ${{secrets.GITHUB_TOKEN}} 从工作流发布;也可以用 Dockerfile 里的标签在推送时自动建立关联:

LABEL org.opencontainers.image.source="https://github.com/OWNER/REPO"

链接到仓库的包会自动继承链接仓库的访问权限(但不继承可见性)——对仓库有读权限的人,对包也有读权限;该仓库里的 Actions 工作流也自动获得包的访问权。

关键限制:只有发布之前就把仓库链接好(比如上面那个 org.opencontainers.image.source 标签),才会自动继承。如果包已经推上去了,再去设置页把它 connect 到仓库,包会保留既有权限,不继承仓库权限,除非你显式勾上 Inherit access from repository。

组织层面还可以整体关掉自动继承:Settings → Packages → 取消 Inherit access from source repository → Save。关掉之后,新建的包默认不继承仓库权限——这是「同一个组织的同事拉不到」的常见原因:他看着仓库权限以为够了,包的权限其实是空的。

包的角色只有三档:Read(下载、读元数据)、Write(上传下载、读写元数据)、Admin(上传下载删除、管理包、授予权限)。发布者自动获得该包的 admin;发布到组织时,组织 owner 也获得 admin。

第五步:组织策略会让 admin 也 403

有一种 403 很反直觉:用户被直接授予了包的 admin 角色,拉取仍然 403。原因是组织开了「maximum lifetimes for personal access tokens」(PAT 最大生存期限制),而该用户的 token 没有设置过期时间。解决办法二选一:给 token 设一个过期时间,或者去掉该组织的最大生存期策略。

第六步:在 Actions 里拉

workflow 里用 GITHUB_TOKEN 拉包,需要 packages: read:

permissions:
packages: read

但光有权限声明不够:workflow 所在的仓库不一定对该包有访问权。要去 Package settings → Manage Actions access → Add Repository 把该仓库加进去。被授权的仓库不一定是源码所在的仓库,可以加多个;在这里授权后,Dependabot 也能不用 PAT 拉包。

注意:用 Add Repository 给工作流仓库授权,和在包设置里把包 connect 到仓库,是两件事。

另外,可以给 public 仓库授予私有包的访问权,但该仓库的 fork 也可能因此访问到私有包。

第七步:生产端(K8s)

K8s 用 imagePullSecrets 引用 dockerconfigjson 形式的 Secret:

kubectl create secret docker-registry ghcr-pull \
--docker-server=ghcr.io \
--docker-username=USERNAME \
--docker-password=$CR_PAT

(该命令为通用写法,未实测。)

PAT 过期之后,Pod 的表现是 ImagePullBackOff / ErrImagePull,日志里能看到 401。建议用独立只读账号的 classic PAT(只给 read:packages),不要复用个人账号的 token —— 人一离职或改密码,集群就拉不动了。

想固定版本,用 digest 拉取,别依赖可变 tag:

docker inspect ghcr.io/NAMESPACE/IMAGE_NAME   # 看 Digest
docker pull ghcr.io/NAMESPACE/IMAGE_NAME@sha256:xxxx

多架构镜像可以用 docker buildx imagetools inspect ghcr.io/OWNER/IMG: 查 manifest(未实测)。

收尾:定位顺序

先凭据,后权限,最后名字。

  1. docker logout ghcr.io + 清 ~/.docker/config.json 的 auths,重试。这一步能解掉大部分公有包 401。
  2. 换 token 打 /v2/OWNER/IMG/manifests/。401 回客户端查 token 本身;403 去 GitHub 侧查。
  3. GitHub 侧要查的:包可见性是否为 public、你的账号在包上的角色、包是否链接了仓库以及是否勾了 Inherit access、组织是否关掉了自动继承、组织是否有 PAT 生存期策略、Actions 场景下是否在 Manage Actions access 里加了仓库。
  4. 客户端侧的:本地 auths 里残留的旧 token、登录时用的 PAT(类型是不是 classic、有没有 read:packages、是否过期)、镜像名和 tag 拼写、K8s 里 imagePullSecret 的内容是否还是老 token。

manifest unknown 单独说一句:GitHub 页面上默认显示的那条 pull 命令是错的,它会去拉容器的签名元数据 .sig。要拉镜像本体,用 main(最新手动构建)、latest(最新 release 构建)或具体版本号。

参考

推荐文章

程序员茄子在线接单