镜像搬运工 skopeo 初体验

搬砖工具

上周末的时候更新完一篇《木子的搬砖工具😂》,最近因为项目需求又发现了个搬砖工具,所以来水篇博客分享给大家。

我司项目组中的一个需求就是:在一台机器上去 pull 一个镜像列表中的镜像,这些镜像存储在 registry A Harbor 上,pull 完这些镜像之后重新打上 tag 然后再 push 到另一个 registry B Harbor 上去。相当于一个同步镜像操作,但和 harbor 里在带的那个镜像同步还有很大的不同,我们仅仅需要同步特定 tag 的镜像,而不是整个 harbor 或者 project 里的全部镜像。目前我们的做法还是最简单的方式,使用 docker 命令行的方式来 pull 镜像,然后打 tag 接着 push 到 B harbor。但是啊,当同步二三百个的镜像,或者镜像的总大小几十 GB 的时候这种原始的方法速度还是太慢了,于是就思考有没有另一个工具可以直接将 registry A 中的某个镜像同步到 registry B 中去。

之前我看到过 漠然大佬 写的博客《如何不通过 docker 下载 docker image》 ,于是咱也就上手试一下这个工具看看能不能帮咱搬点砖😂。结合这个工具的使用,又一次加深了对容器镜像分发存储的了解,收获颇丰😋

image

关于镜像的详细分析可以参考 浅谈docker中镜像和容器在本地的存储

registry

根据 Robin 大佬在 镜像仓库中镜像存储的原理解析 文章里得出的结论:

  • 通过 Registry API 获得的两个镜像仓库中相同镜像的 manifest 信息完全相同。
  • 两个镜像仓库中相同镜像的 manifest 信息的存储路径和内容完全相同。
  • 两个镜像仓库中相同镜像的 blob 信息的存储路径和内容完全相同。

docker pull 和 docker push

之所以想使用 skopeo 替代原有使用 docker pull –> docker tag –> docker push 的操作,是因为 docker pull 镜像的时候,registry 中存储的镜像 layer 格式是 vnd.docker.image.rootfs.diff.tar.gzip ,这是一个 tar.gz 类型的文件。我们可以在本地搭建一个 harbor ,并向 harbor 推送一个 alpine:latest 镜像,来分析一下镜像是如何在 registry 中存储的。

  • harbor 的存储目录
1
2
3
4
5
6
7
8
9
10
11
tree
`-- registry
`-- v2 # registry V2 版本
|-- blobs # blobs 目录下存储镜像的 raw 数据,存储的最小单元为 layer
| `-- sha256
| |-- 39
| |-- cb
| `-- f7
`-- repositories # 镜像的元数据信息
`-- library
`-- alpine
  • 镜像的 manifest 是针对registry服务端的配置信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sh-4.2# skopeo inspect docker://index.docker.io/library/alpine:latest --raw
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 1507,
"digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 2813316,
"digest": "sha256:cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08"
}
]
}
  • 仔细看一下 digest 和下面文件夹的名称,他们是一一对应的,因为 manifest 信息就是镜像在 registry 中存储的信息。
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
tree
|-- [ 20] blobs
| `-- [ 36] sha256
| |-- [ 78] 39
| | `-- [ 18] 39eda93d15866957feaee28f8fc5adb545276a64147445c64992ef69804dbf01
| | `-- [ 528] data
| |-- [ 78] cb
| | `-- [ 18] cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08
| | `-- [2.7M] data
| `-- [ 78] f7
| `-- [ 18] f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a
| `-- [1.5K] data
`-- [ 21] repositories
`-- [ 20] library
`-- [ 55] alpine
|-- [ 20] _layers
| `-- [ 150] sha256
| |-- [ 18] cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08
| | `-- [ 71] link
| `-- [ 18] f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a
| `-- [ 71] link
|-- [ 35] _manifests
| |-- [ 20] revisions
| | `-- [ 78] sha256
| | `-- [ 18] 39eda93d15866957feaee28f8fc5adb545276a64147445c64992ef69804dbf01
| | `-- [ 71] link
| `-- [ 20] tags
| `-- [ 34] latest
| |-- [ 18] current
| | `-- [ 71] link
| `-- [ 20] index
| `-- [ 78] sha256
| `-- [ 18] 39eda93d15866957feaee28f8fc5adb545276a64147445c64992ef69804dbf01
| `-- [ 71] link
`-- [ 6] _uploads

26 directories, 8 files
  • 我们去看一下 [2.7M] data 这个文件,其他文件估计是一些 json 文本用于保存元数据信息。使用 file 命令查看 blobs/sha256/cb/cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08 目录下的 data 文件。镜像的每一层都是存放在一个 64 位长度名称的文件夹下,文件名就是 data 。而且这个文件还是个 gzip 压缩后的文件。我么可以使用 tar 命令将其解压开来。
1
2
3
4
5
cd registry/v2/blobs/sha256/cb/cbdbe7a5bc2a134ca8ec91be58565ec07d037386d1f1d8385412d224deafca08
sh-4.2# file data
data: gzip compressed data
sh-4.2# mkdir layer
sh-4.2# tar -xvf data -C layer/
  • 将其解压到 layer 目录下,使用 tree 命令看一下文件夹下的内容就会明白,这不就是我们的 alpine 镜像真实的内容嘛😂。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sh-4.2# tree -L 1 -d layer
layer
|-- bin
|-- dev
|-- etc
|-- home
|-- lib
|-- media
|-- mnt
|-- opt
|-- proc
|-- root
|-- run
|-- sbin
|-- srv
|-- sys
|-- tmp
|-- usr
`-- var

知道了镜像在 registry 中是如何存储的,我们也就能够明白在当前仅仅为了同步两个 registry 上的镜像使用 docker pull –> docker tag –> docker push 操作的弊端。因为 docker pull 镜像时会对 registry 上的 layer 进行解压缩,这一点和我们的浏览器解压缩一些 gzip 压缩的资源一样道理,为了减少网络传输的流量。当我们 pull 镜像的时候,docker 会有一个单独的进程对镜像进行解压缩,在使用 docker pull 拉取镜像的时候使用 ps 查看一下进程就会找到 docker-untar 这个进程。

1
docker-untar /var/lib/docker/overlay2/a076db6567c7306f3cdab6040cd7d083ef6a39d125171353eedbb8bde7f203b4/diff

对于一些很大的镜像比如 2GB 以上,有时候镜像 layer 已经 download 完了,但是还在进行镜像的解压缩,性能的瓶颈也就在了解压镜像这一块。对于 docker push 来讲,也是如此。

有没有一种办法可以直接将 registry 上的 blob 复制到另一个 registry,中间过程不涉及对镜像 layer 的解压缩,这岂不美哉😂。

skopeo install

yum/dnf/zypper/brew

安装方式很简单,对于常见的发相伴直接 install 一把梭就行,从官方文档偷来的安装方式😂

1
$ sudo dnf install skopeo

on RHEL/CentOS ≤ 7.x:

1
$ sudo yum install skopeo

for openSUSE:

1
$ sudo zypper install skopeo

on alpine:

1
$ sudo apk add skopeo

on macOS:

1
$ brew install skopeo

build

由于我的 VPS 机器是 Ubuntu 1804 的 OS ,配置 apt 源并没成功,当场翻车。为了能够快速体验一把还是本地起一个 alpine 容器,在 alpine 里通过 apk add 的方式安装 skopeo。但 alpine 里的 skopeo 版本 还是 0.14.0 😥,GitHub 上的 master 分支已经 1.0.0了,而且并没有 sync 的选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/ # skopeo --help
NAME:
skopeo - Various operations with container images and container image registries
USAGE:
skopeo [global options] command [command options] [arguments...]
VERSION:
0.1.40
COMMANDS:
copy Copy an IMAGE-NAME from one location to another
inspect Inspect image IMAGE-NAME
delete Delete image IMAGE-NAME
manifest-digest Compute a manifest digest of a file
standalone-sign Create a signature using local files
standalone-verify Verify a signature using local files
help, h Shows a list of commands or help for one command

真是一波三折啊,绕了一圈最终还是亲自指挥 build 一份吧,不过这个 build 过程也很简单。

1
2
3
4
5
git clone https://github.com/containers/skopeo skopeo
cd !$
git checkout v1.0.0
make binary-static DISABLE_CGO=1
cp skopeo /usr/bin/
  • 在这里需要注意一点,如果汝想构建一个在各 Linux 发行版通用的二进制可执行文件,一定要使用 make binary-static DISABLE_CGO=1 ,之前我没有仔细看文档直接 make 一把梭,然后在 Ubuntu 上构建出来的二进制执行文件拿到 CentOS 上去用,当场翻车提示以下错误:
1
skopeo: error while loading shared libraries: libdevmapper.so.1.02.1: cannot open shared object file: No such file or directory
  • 然后我傻乎乎地去安装 CentOS 上的这个库,但还是提示 libdevmapper.so.1.02.1 不存在。因为 Ubuntu 上的这个库和 CentOS 上的这个库是不一样名称的😑。所以说要在编译的时候加上 DISABLE_CGO=1 这个参数进行静态链接编译,这样编译出来的二进制可执行文件就可以在 Linux 发行版之间通用了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ldd skopeo_d #
linux-vdso.so.1 (0x00007ffed9e66000)
libgpgme.so.11 => /usr/lib/x86_64-linux-gnu/libgpgme.so.11 (0x00007f94aed2e000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f94aeb0f000)
libdevmapper.so.1.02.1 => /lib/x86_64-linux-gnu/libdevmapper.so.1.02.1 (0x00007f94ae8a4000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f94ae4b3000)
libassuan.so.0 => /usr/lib/x86_64-linux-gnu/libassuan.so.0 (0x00007f94ae2a0000)
libgpg-error.so.0 => /lib/x86_64-linux-gnu/libgpg-error.so.0 (0x00007f94ae08b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f94b0ac4000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f94ade63000)
libudev.so.1 => /lib/x86_64-linux-gnu/libudev.so.1 (0x00007f94adc45000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f94ad8a7000)
libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007f94ad635000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f94ad431000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f94ad229000)

# 加上 DISABLE_CGO=1 编译后的二进制可执行文件
ldd skopeo_s #
not a dynamic executable

usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Various operations with container images and container image registries

Usage:
skopeo [command]

Available Commands:
copy Copy an IMAGE-NAME from one location to another
delete Delete image IMAGE-NAME
help Help about any command
inspect Inspect image IMAGE-NAME
list-tags List tags in the transport/repository specified by the
login Login to a container registry
logout Logout of a container registry
manifest-digest Compute a manifest digest of a file
standalone-sign Create a signature using local files
standalone-verify Verify a signature using local files
sync Synchronize one or more images from one location to another

skopeo version 1.0.0 commit: bd162028cd83ceecd8915736f2d66d5ca73ee54a

可以看到 skopeo 的功能很简单:

  • copy:复制一个镜像从 A 到 B,这里的 A 和 B 可以为本地 docker 镜像或者 registry 上的镜像。
  • inspect:查看一个镜像的 manifest 火车 image config 详细信息
  • delete:删除一个镜像,可以是本地 docker 镜像或者 registry 上的镜像
  • list-tags:列出一个 registry 上某个镜像的所有 tag
  • login:登录到某个 registry,和 docker login 类似
  • logout: 退出已经登录到某个 registry 的 auth 信息,和 docker logout 类似
  • manifest-digest、standalone-sign、standalone-verify 这三个用的不多
  • sync:同步一个镜像从 A 到 B,感觉和 copy 一样,但 sync 支持的参数更多,功能更强大。在 0.14.0 版本的时候是没有 sync 选项的,到了 0.14.2 才有,现在是 1.0.0

IMAGE NAMES

在使用 skopeo 之前,我们首先要知道在命令行中镜像的格式,下面是官方详细的文档格式。无论我们的 src 镜像还是 desc 镜像都要满足以下格式才可以。

Most commands refer to container images, using a transport:details format. The following formats are supported:

containers-storage:*docker-reference* An image located in a local containers/storage image store. Both the location and image store are specified in /etc/containers/storage.conf. (Backend for Podman, CRI-O, Buildah and friends)

dir:*path* An existing local directory path storing the manifest, layer tarballs and signatures as individual files. This is a non-standardized format, primarily useful for debugging or noninvasive container inspection.

docker://*docker-reference* An image in a registry implementing the “Docker Registry HTTP API V2”. By default, uses the authorization state in either $XDG_RUNTIME_DIR/containers/auth.json, which is set using (skopeo login). If the authorization state is not found there, $HOME/.docker/config.json is checked, which is set using (docker login).

docker-archive:*path[:*docker-reference*] An image is stored in the docker save formatted file. *docker-reference is only used when creating such a file, and it must not contain a digest.

docker-daemon:*docker-reference* An image docker-reference stored in the docker daemon internal storage. docker-reference must contain either a tag or a digest. Alternatively, when reading images, the format can be docker-daemon:algo:digest (an image ID).

oci:*path:tag* An image tag in a directory compliant with “Open Container Image Layout Specification” at path.

需要注意的是,这几种镜像的名字,对应着镜像存在的方式,不同存在的方式对镜像的 layer 处理的方式也不一样,比如 docker:// 这种方式是存在 registry 上的,docker-daemon: 是存在本地 docker pull 下来的,再比如 docker-archive 是通过 docker save 出来的镜像。同一个镜像有这几种存在的方式就像水有气体、液体、固体一样。可以这样去理解,他们表述的都是同一个镜像,只不过是存在的方式不一样而已。

IMAGE NAMES example
containers-storage: containers-storage:
dir: dir:/PATH
docker:// docker://k8s.gcr.io/kube-apiserver:v1.17.5
docker-daemon: docker-daemon:alpine:latest
docker-archive: docker-archive:alpine.tar (docker save)
oci: oci:alpine:latest

skopeo copy

Copy an IMAGE-NAME from one location to another

注意一下,这里的 location 就是指的上面提到的 IMAGE NAMES ,也就是说 skopeo copy src dest 可以有6*6=36 种组合!比如我可以将一个镜像从一个 registry 复制到另一个 registry,skopeo copy docker://IMAGE_NAME docker://IMAGE_NAME,再强调一遍,一定要注意 IMAGE_NAME 的命名的格式。

skopeo 的详细使用可以参考官方的文档,在使用之前先创建一个

在使用 skopeo 之前如果镜像是存放在 registry 上的话,需要先登录到 registry。使用 skopeo login 或者 docker login 都可以。成功登录之后会在本地保存一个为 config.json 的文件,里面保存了登录需要的验证信息,skopeo 拿到这个验证信息才有权限往 registry push 镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
╭─root@sg-02 /home/ubuntu/skopeo ‹master*›
╰─# jq "." ~/.docker/config.json
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "d2sddaqWM7bSVlJFpmQE43Sw=="
}
},
"HttpHeaders": {
"User-Agent": "Docker-Client/19.03.5 (linux)"
},
"experimental": "enabled"
}
  • k8s.gcr.io/kube-apiserver:v1.17.5 复制镜像到 index.docker.io/webpsh/kube-apiserver:v1.17.5
1
2
3
4
5
6
7
8
╭─root@sg-02 ~/skopeo ‹master›
╰─# skopeo copy docker://k8s.gcr.io/kube-apiserver:v1.17.5 docker://index.docker.io/webpsh/kube-apiserver:v1.17.5 --dest-authfile /root/.docker/config.json
Getting image source signatures
Copying blob 597de8ba0c30 done
Copying blob e13a88fa950c done
Copying config f640481f6d done
Writing manifest to image destination
Storing signatures
  • skopeo 输出的日志显示是 Copying blob 597de8ba0c30 done ,可以看到 skopeo 是直接 copy 镜像 layer 的 blob,而 blob 是在 registry 进行压缩存储的格式。
1
2
3
4
5
6
7
# 然后从重新 pul 下来刚刚 push 到 docker hub 上的镜像,验证是否正确
╭─root@sg-02 ~/skopeo ‹master›
╰─# docker pull webpsh/kube-apiserver:v1.17.5
v1.17.5: Pulling from webpsh/kube-apiserver
Digest: sha256:5ddc5c77f52767f2f225a531a257259228d74b32d8aac9cfe087251f998c42f3
Status: Downloaded newer image for webpsh/kube-apiserver:v1.17.5
docker.io/webpsh/kube-apiserver:v1.17.5
  • copy 镜像到本地
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
╭─root@sg-02 /home/ubuntu
╰─# skopeo copy docker-daemon:alpine:latest oci:alpine
Getting image source signatures
Copying blob 3e207b409db3 done
Copying config af88fdb253 done
Writing manifest to image destination
Storing signatures
╭─root@sg-02 /home/ubuntu
╰─# tree -h alpine
alpine
├── [4.0K] blobs
│   └── [4.0K] sha256
│   ├── [ 348] 1c6f747c933450c5169f349f2a57b9d31e833c0452e1ec712b8aab0cbfea4d2c
│   ├── [2.8M] 3eee30c545e47333e6fe551863f6f29c3dcd850187ae3f37c606adb991444886
│   └── [ 583] af88fdb253aac46693de7883c9c55244327908c77248d7654841503f744aae8b
├── [ 186] index.json
└── [ 31] oci-layout

有点好奇这个镜像格式,所以我们来分析一下 copy 出来的镜像,可以看到在导出来的.

这个应该是镜像的 mainfaet 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
╭─root@sg-02 /home/ubuntu/alpine/blobs/sha256
╰─# jq "." 1c6f747c933450c5169f349f2a57b9d31e833c0452e1ec712b8aab0cbfea4d2c
{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:af88fdb253aac46693de7883c9c55244327908c77248d7654841503f744aae8b",
"size": 583
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:3eee30c545e47333e6fe551863f6f29c3dcd850187ae3f37c606adb991444886",
"size": 2898973
}
]
}
  • 这个就是镜像的 image config 文件
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
╭─root@sg-02 /home/ubuntu/alpine/blobs/sha256
╰─# jq "." af88fdb253aac46693de7883c9c55244327908c77248d7654841503f744aae8b
{
"created": "2020-04-24T01:05:03.92860976Z",
"architecture": "amd64",
"os": "linux",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/sh"
]
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:3e207b409db364b595ba862cdc12be96dcdad8e36c59a03b7b3b61c946a5741a"
]
},
"history": [
{
"created": "2020-04-24T01:05:03.608058404Z",
"created_by": "/bin/sh -c #(nop) ADD file:b91adb67b670d3a6ff9463e48b7def903ed516be66fc4282d22c53e41512be49 in / "
},
{
"created": "2020-04-24T01:05:03.92860976Z",
"created_by": "/bin/sh -c #(nop) CMD [\"/bin/sh\"]",
"empty_layer": true
}
]
}

skopeo inspect

这个命令可以查看一个镜像的 image config 和 mainf 文件,和 docker inspect 命令差不多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

╭─root@sg-02 /home/ubuntu/alpine/blobs/sha256
╰─# skopeo inspect docker-daemon:alpine:latest --raw | jq "."
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 1507,
"digest": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 5878784,
"digest": "sha256:3e207b409db364b595ba862cdc12be96dcdad8e36c59a03b7b3b61c946a5741a"
}
]
}

skopeo delete

使用这个命令可以删除镜像,对于删除 registry 上的镜像很有帮助,因为目前想要删除 registry 上的镜像常规的做法还是登录到 registry 在 WEB 上手动删除。skopeo delete 也是调用 registry 的 API 来进行删除镜像。

skopeo list-tags

这个命令常用来列出 registry 上的某个镜像的所有 tag ,在一些 shell 脚本中可能会又用得到。