ghcr.io 拉镜像报 unauthorized / denied / manifest unknown:按凭据→权限→名字的顺序排查
同一个 docker pull ghcr.io/OWNER/IMG:tag,可能返回三种完全不同的错误,处理路径也完全不同。先分清是哪一种,再动手:
| 报错 | HTTP 状态 | 含义 | 第一嫌疑 |
|---|---|---|---|
unauthorized | 401 | 没认证,或本地拿的 token 已失效 | 本地残留了被删/过期的 token |
denied | 403 | 认证过了,但账号对该包没有 read 权限 | 包可见性、包权限、继承开关、组织 PAT 策略 |
manifest unknown | 404 | 认证和权限都通了,名字或 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(未实测)。
收尾:定位顺序
先凭据,后权限,最后名字。
docker logout ghcr.io+ 清~/.docker/config.json的 auths,重试。这一步能解掉大部分公有包 401。- 换 token 打
/v2/OWNER/IMG/manifests/。401 回客户端查 token 本身;403 去 GitHub 侧查。 - GitHub 侧要查的:包可见性是否为 public、你的账号在包上的角色、包是否链接了仓库以及是否勾了 Inherit access、组织是否关掉了自动继承、组织是否有 PAT 生存期策略、Actions 场景下是否在 Manage Actions access 里加了仓库。
- 客户端侧的:本地 auths 里残留的旧 token、登录时用的 PAT(类型是不是 classic、有没有
read:packages、是否过期)、镜像名和 tag 拼写、K8s 里 imagePullSecret 的内容是否还是老 token。
manifest unknown 单独说一句:GitHub 页面上默认显示的那条 pull 命令是错的,它会去拉容器的签名元数据 .sig。要拉镜像本体,用 main(最新手动构建)、latest(最新 release 构建)或具体版本号。