从镜像中抽取文件
在某个应用场景中,我们需要在每个K8S节点上运行一个Agent,此Agent能够执行运维人员动态配置的Python脚本,来检查节点是否出现故障。
Python脚本能够调用的库,需要按需不断更新,受限于运行环境,我们不便搭建和维护PyPI私服。因此,我们考虑将库封装在Docker镜像中进行分发。这里会面临两个问题:
- 如何尽量高效的分发Python库镜像
- 如何在Agent中正确的安装Python库
我们的想法是,利用镜像分层这一特性,将Python库的更新存放在单独的层中,这样Agent就可以仅仅拉取需要的层。此外,Agent需要分析层对文件系统做了哪些变更,把这些变更在自己的文件系统中回放一遍。
包括Harbor在内的镜像仓库软件,底层是Registry,它是OCI Distribution Specification的参考实现,描述了客户端和镜像仓库服务器的交互接口。
OCI Distribution Specification是Docker Registry HTTP API V2的标准化(两者大体相同)。V2相对于V1,主要改进包括:镜像定义的简化、安全方面的增强、带宽占用的减少。
- 镜像校验:客户端可以先下载manifest,校验清单的签名,然后再下载镜像层。这可以确保来源可靠且未被篡改。在每下载一个层后,客户端都可以计算其摘要,和清单中的值进行比对,确保一致
- 可恢复Push/Pull:推送、拉取镜像都支持断点续传
- 层去重,两个相同的层仅仅会被上传或存储一份:
- 如果上传层时发现它已经存在,则不上传
- 如果两个进程同时上传一个层,则仅仅保存先上传的那个
所有接口端点都以API版本和仓库名为前缀,例如仓库library/ubuntu的URI前缀是 /v2/library/ubuntu/,这种URI布局便于实现身份验证和访问控制。
仓库名需要满足以下限制:
- 仓库名可以包含多个路径分量(/分隔开的片断),每个分量匹配正则式 [a-z0-9]+(?:[._-][a-z0-9]+)*
- 分量的数量没有限制
- 仓库名中长度(包括/)不超过256字符
如果处理请求出现错误,返回4xx响应,并且附带下面格式的响应体:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ // 1-N个错误 "errors:" [{ // 全大写唯一标识符 "code": <error identifier>, // 可读文本 "message": <message describing condition>, // 任意结构化消息 "detail": <unstructured> }, ... ] } |
示例: {"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
镜像仓库接口非常依赖于内容可寻址性,这种技术利用防冲撞哈希(collision-resistant hash)算法获取信息内容的摘要,此摘要作为信息的唯一标识符,既可用于寻址,也可用于信息完整性校验。
摘要的形式为sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b,前面是算法,后面是HEX形式的哈希值。
为了支持内容校验,所有接口响应都可以提供 Docker-Content-Digest头,其中包含响应中实体的摘要信息,对于:
- Blob,实体就是整个响应体
- Manifest,实体是清单体,不包括签名部分
出于安全性的考虑,客户端应该基于摘要来验证响应实体。
镜像是一个JSON清单 + 一系列独立的层文件,要获取完整镜像,需要对镜像仓库发起多次HTTP调用。
镜像拉取的第一步是获取清单,API格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// reference可以包含一个Tag或摘要 // GET /v2/<name>/manifests/<reference> // 示例 // curl -H 'Authorization: Basic <credentials>' // -H 'Accept: application/vnd.docker.distribution.manifst.v2+json' // https://docker.gmem.cc/v2/alpine/manifests/3.11 // 默认内容类型 application/vnd.docker.distribution.manifest.v1+prettyjws { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { ... }, "layers": [ { "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": 2802957, "digest": "sha256:c9b1b535fdd91a9855fb7f82348177e5f019329a58c53c47272962dd60f71fc9" } ] } |
可以看到,镜像包含的所有层的信息,都可以从清单上找到。下一步就是基于层的标识符(摘要)发起层拉取请求。
拉取层或者其它Blob数据,使用的是同一接口。你需要在URL中传递Blob数据的摘要,包括算法前缀:
1 2 3 4 5 |
// name为仓库名,digest为层的摘要 // GET /v2/<name>/blobs/<digest> // 示例 // https://docker.gmem.cc/v2/alpine/blobs/sha256:86235187a3abfce6fa63b526aaea608392a9f629fd827ad75867b906362f9dd0 |
镜像仓库可以发送307(对于HTTP1.1以下版本则是302)响应,客户端应当正确处理响应,从其它地方下载层。
镜像仓库会设置适当的头,以支持层的缓存。它还会设置Range头以支持断点续传。
了解镜像仓库的API后,我们可以确定,要下载镜像的某个层是很容易的。
对于本文开头的Python库镜像,我们可以检查并下载新增的层,剩下需要解决的问题就是,如何从层中读取对文件系统做的变更了。
要从层中抽取文件,必须理解镜像、层的结构。
Image Manifest Version 2, Schema 1(已经弃用)、Image Manifest Version 2, Schema 2 是Docker关于镜像清单文件的规范。
Docker Image Specification v1.2.0 是Docker关于镜像的规范,包含层的格式说明。
OCI Image Format Specification 是关于镜像格式的规范,包括Docker在内的主流容器引擎都支持支持此规范。
本章主要基于Docker相关的最新标准进行分析,OCI在很大程度上是兼容的。
对于Docker镜像,其清单格式大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
{ // 清单版本 "schemaVersion": 2, // MIME类型 "mediaType": "application/vnd.docker.distribution.manifest.v2+json", // 引用配置对象,配置存放在一个JSON blob中,容器运行时使用此配置来启动容器 "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 7023, // 需要使用blobs接口下载配置对象 "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7" }, // 层列表,以Base镜像开始(顺序和Schema1相反) "layers": [ { "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": 32654, // 需要使用blobs接口下载层 "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f" } ] } |
可以看到,清单给出了每个层的格式(mediaType),Schema 2支持的MIME类型包括:
MIME | 说明 |
application/vnd.docker.distribution.manifest.v1+json | 遗留的Schema 1清单格式 |
application/vnd.docker.distribution.manifest.v2+json | Schema 2清单格式 |
application/vnd.docker.distribution.manifest.list.v2+json | 清单列表(所谓fat manifest) |
application/vnd.docker.container.image.v1+json | 容器配置对象格式 |
application/vnd.docker.image.rootfs.diff.tar.gzip | 层,tar.gz格式 |
application/vnd.docker.image.rootfs.foreign.diff.tar.gzip | 永远不应该被Push的层 |
application/vnd.docker.plugin.v1+json | 插件配置JSON |
从MIME类型的说明上可以看到,镜像的层是gzip压缩处理的tar包。
尝试解压缩,粗略看起来就是一棵Linux目录层次树。那么,如何识别出哪些文件是新增、哪些是修改,哪些又是删除掉的呢?
在相关规范中,层也叫镜像文件系统变更集(Image Filesystem Changeset ),不管是OCI还是Docker,都遵循:
- 新增、修改的目录或文件,按照它的原始路径存储在变更集中
- 删除的目录或文件,为文件的basename前缀以 .wh.,存储位置保持不变。例如在某个变更集中 ./etc/hosts被删除,则会创建一个空白的 ./etc/.wh.hosts
可以看到,要解开存放在镜像层中的Python库更新,也很简单:
- 根据MIME类型,选取适当的工具解压缩
- 遍历得到的目录树,遇到 .wh.开头的节点,则从Agent文件系统中删除,对于其它节点,覆盖即可
尽管利用Go语言标准库就可以完成镜像层下载、解压缩、应用到Agent文件系统,我们还是在这里介绍一些可以简化操作的库。
该项目提供了创建、校验、解包OCI镜像的工具,鉴于Docker和OCI规范具有很多兼容的地方,这个项目中的代码参考。
github.com/docker/docker提供了Docker客户端库,比较不方便的地方是,它必须连接到Docker守护程序才能工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var dockerClient *client.Client var clientOpts []client.Opt clientOpts = append(clientOpts, client.FromEnv) clientOpts = append(clientOpts, client.WithAPIVersionNegotiation()) dockerClient, err = client.NewClientWithOpts(clientOpts...) // 获取镜像基本信息 dockerClient.ImageInspectWithRaw(ctx, "nginx:latest") // 从Docker守护程序获取一个或多个镜像,返回Reader readCloser, err := dockerClient.ImageSave(ctx, []string{id}) |
Docker客户端实现了接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
type ImageAPIClient interface { ImageBuild(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) BuildCachePrune(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) BuildCancel(ctx context.Context, id string) error ImageCreate(ctx context.Context, parentReference string, options types.ImageCreateOptions) (io.ReadCloser, error) ImageHistory(ctx context.Context, image string) ([]image.HistoryResponseItem, error) ImageImport(ctx context.Context, source types.ImageImportSource, ref string, options types.ImageImportOptions) (io.ReadCloser, error) ImageInspectWithRaw(ctx context.Context, image string) (types.ImageInspect, []byte, error) ImageList(ctx context.Context, options types.ImageListOptions) ([]types.ImageSummary, error) ImageLoad(ctx context.Context, input io.Reader, quiet bool) (types.ImageLoadResponse, error) ImagePull(ctx context.Context, ref string, options types.ImagePullOptions) (io.ReadCloser, error) ImagePush(ctx context.Context, ref string, options types.ImagePushOptions) (io.ReadCloser, error) ImageRemove(ctx context.Context, image string, options types.ImageRemoveOptions) ([]types.ImageDeleteResponseItem, error) ImageSearch(ctx context.Context, term string, options types.ImageSearchOptions) ([]registry.SearchResult, error) ImageSave(ctx context.Context, images []string) (io.ReadCloser, error) ImageTag(ctx context.Context, image, ref string) error ImagesPrune(ctx context.Context, pruneFilter filters.Args) (types.ImagesPruneReport, error) } |
可以看到,它没有提供针对层的操控函数,因此本文的场景下没有什么用处。
这个项目提供了镜像仓库接口的Go语言封装。提供读写镜像、层、ImageIndex等功能。
Leave a Reply