曾几何时,我遇到了一个特殊的用例:我想在不使用Docker二进制文件或任何类似工具的情况下构建和推送Docker映像。
下面记录的方法仅依赖于Go标准库。
我做了一些假设,所以这个工作流不包括任何任意的场景。下面的工作流程依赖于这些假设和简化:
- 程序处理单个静态构建的linux/amd64 Go二进制文件的映像,该二进制文件没有外部依赖,您可以使用以下Dockerfile获得映像:
FROM scratch
COPY main /main
CMD ["/main"]
-
该程序应与AWS ECR存储库一起工作;
-
为了简单起见,程序从本地docker配置中读取身份验证凭据。
步骤
Docker提供了一个HTTP API;它的文档中方便地有“放置图像”一节。
简而言之,构建和发布映像的整个过程有以下几个步骤:
- 构造一个Docker映像层。我的docker映像将有一个保存单个文件的层。
- 构造一个图像配置——一个JSON文件,通过摘要(layer.tar.gz的sha256和)引用图像层,加上一些元数据,比如ARCH/OS,要运行什么命令,要使用什么环境变量。
- 上传一个docker层对象。
- 上传一个图像配置对象。
- 构造一个图像清单——一个JSON文件,通过它们的摘要引用层和配置。
- 发布映像清单。
认证
此工作流使用的所有API端点都需要授权。
AWS ECR文档说明了如何做到这一点:每个请求必须有一个Authorization: Basic $TOKEN标头。
如果你检查一个~/.docker/config. conf文件。运行docker login命令后,您将看到该文件包含注册表域和授权令牌之间的映射。
为简单起见,我的代码将从该文件获取匹配域的授权令牌。
解析全图名称
在我的原型代码将不得不处理一个docker镜像的全名,由3部分组成:docker注册域名,镜像名称,和一个标签。例如,对于public.ecr标识的映像。Aws /amazonlinux/amazonlinux:最新全名,代码需要区分域名(public.ecr.aws)、短名(amazonlinux/amazonlinux)和标签(最新)。
解析它很简单。代码有一个专门的类型来保存所有独立的部分:
type imageSpec struct {
Domain string
Name string
Tag string
}
构建Docker镜像层
Docker映像层只是一个gzip压缩的tar归档文件。Go标准库包archive/tar和compress/gzip涵盖了这种情况。
一个值得注意的警告是,在层构建期间,我需要计算未压缩和压缩内容(包括layer.tar和layer.tar.gz)的sha256校验和。有一种方便的方法可以做到这一点,因为程序构建了一个依赖于哈希的图像。哈希实现io。写入器接口,io。MultiWriter允许同时向多个写入器写入数据。
相关的代码是这样的:
outerHash, innerHash := sha256.New(), sha256.New()
buf := new(bytes.Buffer)
gw := gzip.NewWriter(io.MultiWriter(buf, outerHash)) // compressed stream (layer.tar.gz)
tw := tar.NewWriter(io.MultiWriter(gw, innerHash)) // uncompressed stream (layer.tar)
if err := tw.WriteHeader(&tar.Header{
Name: "main",
Mode: 0755,
ModTime: fi.ModTime(),
Size: fi.Size(),
}); err != nil {
return nil, nil, err
}
...
outerDigest := fmt.Sprintf("sha256:%x", outerHash.Sum(nil))
innerDigest := fmt.Sprintf("sha256:%x", innerHash.Sum(nil))
镜像配置
一个最小的镜像配置可能是这样的:
{
"os": "linux",
"architecture": "amd64",
"created": "2021-03-13T16:39:51.535472845Z",
"config": {
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"Cmd": ["/main"],
"WorkingDir": "/",
"ArgsEscaped": true
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:0a7631da79a7cf9bfbe5c09457481b869b45095dfd309681f7ed465e711815ed"
]
}
}
rootfs→diff_ids下的数组通过未压缩的内容摘要引用层。它对应于上一节代码片段中的innerDigest变量。
对象上传
图像层和配置以相同的方式上传到注册表:向注册表执行POST /v2/<name>/blobs/uploads/请求,从响应Location头获取一个新的唯一URL,然后使用PUT请求将有效载荷上传到此URL。(这个配置应该以与图层相同的方式上传,这对我来说是一个棘手的问题。起初,我以为它一定是清单的一部分。)
从回复中获取上传位置的代码:
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return "", fmt.Errorf("unexpected status on %v: %v", req.URL.Path, resp.Status)
}
uploadLocation := resp.Header.Get("Location")
if uploadLocation == "" {
return "", errors.New("response has no valid location")
}
return uploadLocation, nil
然后上传一个对象(层或配置),我还需要它的字节大小和摘要-一个十六进制编码的sha256校验和对象内容的sha256:前缀:
// payload has a []byte type, it's either a layer in tar.gz format, or a json-encoded image config
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(payload))
uploadLocation = uploadLocation + "?digest=" + digest
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadLocation, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = int64(len(payload))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("unexpected status on object upload %q: %v", req.URL, resp.Status)
}
图像清单
一旦层和配置都上传,我需要构建一个图像清单。现在,我掌握了所有的细节。
一个最小的清单看起来像这样:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 616,
"digest": "sha256:82a2678c5bdcdf82a0fe0d54b0a58f5604182d4ffb7b9e5ca6835e5c207c720c"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 360666,
"digest": "sha256:81c1eb1aeb9f8cabc9eca332be5c331a1bd687c453eec180c1f447ee720827eb"
}
]
}
在config键下,该对象引用前一节中的映像配置。Size以字节为单位描述配置大小,digest是有效负载摘要—与配置上传步骤中使用的相同。
图层数组描述所有图像图层。在我的例子中,图像只有一个图层。Size是层对象的大小,以字节为单位(layer.tar.gz文件大小)。摘要是层对象sha256摘要,对应于outerDigest变量。
Manifest通过一个专用的API端点发布,使用PUT /v2/<name>/ Manifest /<tag>请求。API支持不同的清单版本,上面的清单需要Content-Type: application/vnd.docker.distribution.manifest。v2 + json头。
如果成功,API将返回201个已创建的代码。
此时,注册表中应该出现一个新映像。
创建原型
您可以在https://github.com/artyom/push-to-docker-repo找到完整的原型代码。
它是一个独立的Go程序,只使用Go标准库。该程序可以采用静态构建的linux/amd64二进制文件,将其打包到docker容器中,并将其发布到docker注册表中。
请注意,它只在AWS ECR存储库中进行了测试。
更多资料
原文 https://artyom.dev/push-docker-image-without-docker.md