简介:容器镜像在我们日常的开发工作中占据着极其重要的位置。通常情况下我们是将应用程序打包到容器镜像并上传到镜像仓库中,在生产环境将其拉取下来。然后用 docker/containerd 等容器运行时将镜像启动,开始执行应用。但是对于一些运维平台来说,对于一个镜像制品本身的扫描和分析才是真正的关注点。本文简单介绍下如何在代码中解析一个容器镜像。

作者 | 牧琦
来源 | 阿里技术公众号

一 背景

容器镜像在我们日常的开发工作中占据着极其重要的位置。通常情况下我们是将应用程序打包到容器镜像并上传到镜像仓库中,在生产环境将其拉取下来。然后用 docker/containerd 等容器运行时将镜像启动,开始执行应用。但是对于一些运维平台来说,对于一个镜像制品本身的扫描和分析才是真正的关注点。本文简单介绍下如何在代码中解析一个容器镜像。

二 go-containerregistry

go-containerregistry 是 google 公司的一个开源项目,它提供了一个对镜像的操作接口,这个接口背后的资源可以是 镜像仓库的远程资源,镜像的tar包,甚至是 docker daemon 进程。下面我们就简单介绍下如何使用这个项目来完成我们的目标—— 在代码中解析镜像。

除了对外提供了三方包,该项目里面还提供了 crane (与远端镜像交互的客户端)gcrane (与 gcr 交互的客户端)。

三 基本接口

1 镜像基本概念

在介绍具体接口之间先介绍几个简单概念

  • ImageIndex, 根据 OCI 规范,是为了兼容多架构(amd64, arm64)镜像而创造出来的数据结构, 我们可以在一个ImageIndex 里面关联多个镜像,使用同一个镜像tag,客户端(docker,ctr)会根据客户端所在的操作系统的基础架构拉取对应架构的镜像下来
  • Image Manifest 基本上对应了一个镜像,里面包含了一个镜像的所有layers digest,客户端拉取镜像的时候一般都是先获取manifest 文件,在根据 manifest 文件里面的内容拉取镜像各个层(tar+gzip)
  • Image Config 跟 ImageManifest 是一一对应的关系,Image Config 主要包含一些 镜像的基本配置,例如 创建时间,作者,该镜像的基础架构,镜像层的 diffID(未压缩的 ChangeSet),ChainID 之类的信息。一般在宿主机上执行 docker image 看到的ImageID就是 ImageConfig 的hash值。
  • layer 就是镜像层,镜像层信息不包含任何的运行时信息(环境变量等)只包含文件系统的信息。镜像是通过最底层 rootfs 加上各层的 changeset(对上一层的 add, update, delete 操作)组合而成的。

    • layer diffid 是未压缩的层的hash值,常见于 本地环境,使用 看到的便是diffid。因为客户端一般下载 ImageConfig, ImageConfig 里面是引用的diffid。
    • layer digest 是压缩后的层的hash值,常见于镜像仓库 使用 看到的layers 一般都是 digest. 因为 manifest 引用都是 layer digest。
  • 两者没有可以直接转换的方式,目前的唯一方式就是按照顺序来对应。
  • 用一张图来总结一下。

// ImageIndex 定义与 OCI ImageIndex 交互的接口
type ImageIndex interface {// 返回当前 imageIndex 的 MediaTypeMediaType() (types.MediaType, error)// 返回这个 ImageIndex manifest 的 sha256值。Digest() (Hash, error)// 返回这个 ImageIndex manifest 的大小Size() (int64, error)// 返回这个 ImageIndex 的 manifest 结构IndexManifest() (*IndexManifest, error)// 返回这个 ImageIndex 的 manifest 字节数组RawManifest() ([]byte, error)// 返回这个 ImageIndex 引用的 ImageImage(Hash) (Image, error)// 返回这个 ImageIndex 引用的 ImageIndexImageIndex(Hash) (ImageIndex, error)
}// Image  定义了与 OCI Image 交互的接口
type Image interface {// 返回了当前镜像的所有层级, 最老/最基础的层在数组的前面,最上面/最新的层在数组的后面Layers() ([]Layer, error)// 返回当前 image 的 MediaTypeMediaType() (types.MediaType, error)// 返回这个 Image manifest 的大小Size() (int64, error)// 返回这个镜像 ConfigFile 的hash值,也是这个镜像的 ImageIDConfigName() (Hash, error)// 返回这个镜像的 ConfigFileConfigFile() (*ConfigFile, error)// 返回这个镜像的 ConfigFile 的字节数组RawConfigFile() ([]byte, error)// 返回这个Image Manifest 的sha256 值Digest() (Hash, error)// 返回这个Image ManifestManifest() (*Manifest, error)// 返回 ImageManifest 的bytes数组RawManifest() ([]byte, error)// 返回这个镜像中的某一层layer, 根据 digest(压缩后的hash值) 来查找LayerByDigest(Hash) (Layer, error)// 返回这个镜像中的某一层layer, 根据 diffid (未压缩的hash值) 来查找LayerByDiffID(Hash) (Layer, error)
}// Layer 定义了访问 OCI Image 特定 Layer 的接口
type Layer interface {// 返回了压缩后的layer的sha256 值Digest() (Hash, error)// 返回了 未压缩的layer 的sha256值.DiffID() (Hash, error)// 返回了压缩后的镜像层Compressed() (io.ReadCloser, error)// 返回了未压缩的镜像层Uncompressed() (io.ReadCloser, error)// 返回了压缩后镜像层的大小Size() (int64, error)// 返回当前 layer 的 MediaTypeMediaType() (types.MediaType, error)
}

相关接口功能已在注释中说明,不再赘述。

四 获取镜像相关元信息

我们以 remote 方式(拉取远程镜像) 举例说明下如何使用。

package mainimport ("github.com/google/go-containerregistry/pkg/authn""github.com/google/go-containerregistry/pkg/name""github.com/google/go-containerregistry/pkg/v1/remote"
)func main() {ref, err := name.ParseReference("xxx")if err != nil {panic(err)}tryRemote(context.TODO(), ref, GetDockerOption())if err != nil {panic(err)}// do stuff with img
}type DockerOption struct {// AuthUserName stringPassword string// RegistryToken is a bearer token to be sent to a registryRegistryToken string// ECRAwsAccessKey    stringAwsSecretKey    stringAwsSessionToken stringAwsRegion       string// GCPGcpCredPath stringInsecureSkipTLSVerify boolNonSSL                boolSkipPing              bool // this is ignored nowTimeout               time.Duration
}func GetDockerOption() (types.DockerOption, error) {cfg := DockerConfig{}if err := env.Parse(&cfg); err != nil {return types.DockerOption{}, fmt.Errorf("unable to parse environment variables: %w", err)}return types.DockerOption{UserName:              cfg.UserName,Password:              cfg.Password,RegistryToken:         cfg.RegistryToken,InsecureSkipTLSVerify: cfg.Insecure,NonSSL:                cfg.NonSSL,}, nil
}func tryRemote(ctx context.Context, ref name.Reference, option types.DockerOption) (v1.Image, extender, error) {var remoteOpts []remote.Optionif option.InsecureSkipTLSVerify {t := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true},}remoteOpts = append(remoteOpts, remote.WithTransport(t))}domain := ref.Context().RegistryStr()auth := token.GetToken(ctx, domain, option)if auth.Username != "" && auth.Password != "" {remoteOpts = append(remoteOpts, remote.WithAuth(&auth))} else if option.RegistryToken != "" {bearer := authn.Bearer{Token: option.RegistryToken}remoteOpts = append(remoteOpts, remote.WithAuth(&bearer))} else {remoteOpts = append(remoteOpts, remote.WithAuthFromKeychain(authn.DefaultKeychain))}desc, err := remote.Get(ref, remoteOpts...)if err != nil {return nil, nil, err}img, err := desc.Image()if err != nil {return nil, nil, err}// Return v1.Image if the image is found in Docker Registryreturn img, remoteExtender{ref:        implicitReference{ref: ref},descriptor: desc,}, nil
}

执行完 tryRemote 代码之后就可以获取 Image 对象的实例,进而对这个实例进行操作。明确以下几个关键点

  • remote.Get() 方法只会实际拉取镜像的manifestList/manifest,并不会拉取整个镜像。
  • desc.Image() 方法会判断 remote.Get() 返回的媒体类型。如果是镜像的话直接返回一个 Image interface, 如果是 manifest list 的情况会解析当前宿主机的架构,并且返回指定架构对应的镜像。 同样这里并不会拉取镜像。
  • 所有的数据都是lazy load。只有需要的时候才会去获取。

五 读取镜像中系统软件的信息

通过上面的接口定义可知,我们可以通过 Image.LayerByDiffID(Hash) (Layer, error) 获取一个 layer 对象, 获取了layer对象之后我们可以调用 layer.Uncompressed() 方法获取一个未被压缩的层的 io.Reader , 也就是一个 tar file。

// tarOnceOpener 读取文件一次并共享内容,以便分析器可以共享数据
func tarOnceOpener(r io.Reader) func() ([]byte, error) {var once sync.Oncevar b []bytevar err errorreturn func() ([]byte, error) {once.Do(func() {b, err = ioutil.ReadAll(r)})if err != nil {return nil, xerrors.Errorf("unable to read tar file: %w", err)}return b, nil}
}// 该方法主要是遍历整个 io stream,首先解析出文件的元信息 (path, prefix,suffix), 然后调用 analyzeFn 方法解析文件内容
func WalkLayerTar(layer io.Reader, analyzeFn WalkFunc) ([]string, []string, error) {var opqDirs, whFiles []stringvar result *AnalysisResulttr := tar.NewReader(layer)opq := ".wh..wh..opq"wh  := ".wh."for {hdr, err := tr.Next()if err == io.EOF {break}if err != nil {return nil, nil, xerrors.Errorf("failed to extract the archive: %w", err)}filePath := hdr.NamefilePath = strings.TrimLeft(filepath.Clean(filePath), "/")fileDir, fileName := filepath.Split(filePath)// e.g. etc/.wh..wh..opqif opq == fileName {opqDirs = append(opqDirs, fileDir)continue}// etc/.wh.hostnameif strings.HasPrefix(fileName, wh) {name := strings.TrimPrefix(fileName, wh)fpath := filepath.Join(fileDir, name)whFiles = append(whFiles, fpath)continue}if hdr.Typeflag == tar.TypeSymlink || hdr.Typeflag == tar.TypeLink || hdr.Typeflag == tar.TypeReg {analyzeFn(filePath, hdr.FileInfo(), tarOnceOpener(tr), result)if err != nil {return nil, nil, xerrors.Errorf("failed to analyze file: %w", err)}}}return opqDirs, whFiles, nil
}// 调用不同的driver 对同一个文件进行解析
func analyzeFn(filePath string, info os.FileInfo, opener analyzer.Opener,result *AnalysisResult) error {if info.IsDir() {return nil, nil}var wg sync.WaitGroupfor _, d := range drivers {// filepath extracted from tar file doesn't have the prefix "/"if !d.Required(strings.TrimLeft(filePath, "/"), info) {continue}b, err := opener()if err != nil {return nil, xerrors.Errorf("unable to open a file (%s): %w", filePath, err)}if err = limit.Acquire(ctx, 1); err != nil {return nil, xerrors.Errorf("semaphore acquire: %w", err)}wg.Add(1)go func(a analyzer, target AnalysisTarget) {defer limit.Release(1)defer wg.Done()ret, err := a.Analyze(target)if err != nil && !xerrors.Is(err, aos.AnalyzeOSError) {log.Logger.Debugf("Analysis error: %s", err)return nil, err}result.Merge(ret)}(d, AnalysisTarget{Dir: dir, FilePath: filePath, Content: b})}return result, nil
}// drivers: 用于解析tar包中的文件
func (a alpinePkgAnalyzer) Analyze(target analyzer.AnalysisTarget) (*analyzer.AnalysisResult, error) {scanner := bufio.NewScanner(bytes.NewBuffer(target.Content))var pkg types.Packagevar version stringfor scanner.Scan() {line := scanner.Text()// check package if paragraph endif len(line) < 2 {if analyzer.CheckPackage(&pkg) {pkgs = append(pkgs, pkg)}pkg = types.Package{}continue}switch line[:2] {case "P:":pkg.Name = line[2:]case "V:":version = string(line[2:])if !apkVersion.Valid(version) {log.Printf("Invalid Version Found : OS %s, Package %s, Version %s", "alpine", pkg.Name, version)continue}pkg.Version = versioncase "o:":origin := line[2:]pkg.SrcName = originpkg.SrcVersion = version}}// in case of last paragraphif analyzer.CheckPackage(&pkg) {pkgs = append(pkgs, pkg)}parsedPkgs := a.uniquePkgs(pkgs)return &analyzer.AnalysisResult{PackageInfos: []types.PackageInfo{{FilePath: target.FilePath,Packages: parsedPkgs,},},}, nil
}

以上代码的重点在于 Analyze(target analyzer.AnalysisTarget) 方法,在介绍这个方法之前,有两个特殊文件需要稍微介绍下。众所周知,镜像是分层的,并且所有层都是只读的。当容器是以镜像为基础起来的时候,它会将所有镜像层包含的文件组合成为 rootfs 对容器暂时,当我们将容器 commit 成一个新的镜像的时候,容器内对文件修改会以新的layer 的方式覆盖到原有的镜像中。其中有如下两种特殊文件:

  • .wh..wh..opq: 代表这个文件所在的目录被删除了
  • .wh.:以这个词缀开头的文件说明这个文件在当前层已经被删除

所以综上所述,所有容器内的文件删除均不是真正的删除。所以我们在 WalkLayerTar 方法中将两个文件记录下来,跳过解析。

1 Analyze(target analyzer.AnalysisTarget)

  • 首先我们调用 bufio.scanner.Scan() 方法, 他会不断扫描文件中的信息,当返回false 的时候代表扫描到文件结尾,如果这时在扫描过程中没有错误,则 scanner 的 Err 字段为 nil
  • 我们通过 scanner.Text() 获取扫描文件的每一行,截取每一行的前两个字符,得出 apk package 的 package name & package version。

六 读取镜像中的java 应用信息

下面我们实际来看下如何读取java 应用中的依赖信息,包括 应用依赖 & jar包依赖, 首先我们使用上面的方式读取某一层的文件信息。

  • 如果发现 文件是jar包
  • 初始化 zip reader, 开始读取 jar 包内容
  • 开始通过 jar包名称进行解析 artifact的名称和版本, 例如: spring-core-5.3.4-SNAPSHOT.jar => sprint-core, 5.3.4-SNAPSHOT
  • 从 zip reader 读取被压缩的文件
  • 判断文件类型

    • 调用parseArtifact进行递归解析
    • 将返回的innerLibs放到 libs对象中
    • 从 MANIFEST.MF 文件中解析出manifest返回
    • 从 properties 文件中解析 groupid, artifactid, version 并返回
    • 将上述信息放到 libs 对象中
    • 如果是 pom.properties
    • 如果是 MANIFEST.MF
    • 如果是 jar/war/ear 等文件
  • 如果 找不到 artifactid or groupid

    • 根据jar sha256查询对应的包信息
    • 找到直接返回
  • 返回解析出来的libs
func parseArtifact(c conf, fileName string, r io.ReadCloser) ([]types.Library, error) {defer r.Close()b, err := ioutil.ReadAll(r)if err != nil {return nil, xerrors.Errorf("unable to read the jar file: %w", err)}zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))if err != nil {return nil, xerrors.Errorf("zip error: %w", err)}fileName = filepath.Base(fileName)fileProps := parseFileName(fileName)var libs []types.Libraryvar m manifestvar foundPomProps boolfor _, fileInJar := range zr.File {switch {case filepath.Base(fileInJar.Name) == "pom.properties":props, err := parsePomProperties(fileInJar)if err != nil {return nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)}libs = append(libs, props.library())if fileProps.artifactID == props.artifactID && fileProps.version == props.version {foundPomProps = true}case filepath.Base(fileInJar.Name) == "MANIFEST.MF":m, err = parseManifest(fileInJar)if err != nil {return nil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)}case isArtifact(fileInJar.Name):fr, err := fileInJar.Open()if err != nil {return nil, xerrors.Errorf("unable to open %s: %w", fileInJar.Name, err)}// 递归解析 jar/war/ear innerLibs, err := parseArtifact(c, fileInJar.Name, fr)if err != nil {return nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)}libs = append(libs, innerLibs...)}}// 如果找到了 pom.properties 文件,则直接返回libs对象if foundPomProps {return libs, nil}// 如果没有找到 pom.properties 文件,则解析MANIFEST.MF 文件manifestProps := m.properties()if manifestProps.valid() {// 这里即使找到了 artifactid or groupid 也有可能是非法的。这里会访问 maven等仓库确认 jar包是否真正存在if ok, _ := exists(c, manifestProps); ok {return append(libs, manifestProps.library()), nil}}p, err := searchBySHA1(c, b)if err == nil {return append(libs, p.library()), nil} else if !xerrors.Is(err, ArtifactNotFoundErr) {return nil, xerrors.Errorf("failed to search by SHA1: %w", err)}return libs, nil
}

以上我们便完成了从容器镜像中读取信息的功能。

原文链接

本文为阿里云原创内容,未经允许不得转载。

如何在golang代码里面解析容器镜像相关推荐

  1. 让容器应用管理更快更安全,Dragonfly 发布 Nydus 容器镜像加速服务

    镜像对容器部署的挑战 在容器的生产实践中,偏小的容器镜像能够很快地部署启动.当应用的镜像达到几个 GB 以上的时候,在节点上下载镜像通常会消耗大量的时间.Dragonfly 通过引入 P2P 网络有效 ...

  2. 对容器镜像的思考和讨论

    作者 | Liu,Bo 来源|阿里巴巴云原生公众号 前言 常言道,startup 有 startup 的好,大厂有大厂的好,那么大厂究竟好在哪呢?拿硅谷老牌大厂们 FLG 来说,如果要问最令人怀念的是 ...

  3. 阿里云、蚂蚁开源 Nydus——容器镜像加速服务

    近日,Dragonfly 项目引入了一个容器镜像加速服务 nydus.据悉,nydus 是由阿里云和蚂蚁集团的工程师合作开发,并大规模部署在内部的生产环境中. 据 Dragonfly 发布的消息,在其 ...

  4. Golang cgo:如何在Go代码中调用C语言代码?

    如何在Go代码中调用C语言代码? Go语言是通过自带的一个叫CGO的工具来支持C语言函数调用,同时我们可以用Go语言导出C动态库接口给其它语言使用. 方式一.直接在 Go 代码中写入 C 代码 检查是 ...

  5. 利用Serverless Kubernetes和Kaniko快速自动化构建容器镜像

    前言: 在云原生时代中,容器镜像是一切应用分发的基础载体,除了dockerhub作为流行的镜像仓库外,各大公有云厂商也都提供了功能丰富镜像仓库服务,如ACR(Aliyun Container Regi ...

  6. 如何构建尽可能小的容器镜像?

    关注我们获得更多内容 这是Google Developer Advocate Sandeep Dinesh 关于如何充分利用Kubernetes环境的七部分视频和博客系列的第一部分. 主要讲保持容器镜 ...

  7. 云原生时代,如何保证容器镜像安全?

    目录 遵从最佳实践,编写 Dockerfile 选择合适的基础镜像 以非 root 用户启动容器 采用多阶段构建 选择来源可靠且经常更新的镜像 用安全的方式构建容器镜像 使用容器镜像扫描 和极狐 Gi ...

  8. 可靠、稳定、安全,龙蜥云原生容器镜像正式发布!

    文/云原生 SIG 01 背景 随着云原生的蓬勃发展,越来越多的企业在自己的生产或者测试环境使用云原生技术,而容器镜像正是云原生技术中应用的实际运行环境.一个好的容器运行环境即容器镜像会真正关系到应用 ...

  9. 《深入剖析Kubernetes》-张磊——白话容器基础(三):深入理解容器镜像

    <深入剖析Kubernetes>-张磊 白话容器基础(三):深入理解容器镜像 写在前面: 张磊的极客时间课程<深入剖析Kubernetes>,是我见过讲docker和k8s最好 ...

最新文章

  1. 最长子序列和 动态规划python_算法基础之python实现动态规划中数字三角形和最长上升子序列问题...
  2. AutoML自定义搜索网络类(如何在一个大的网络中搜索一个网络)
  3. 势能线段树/吉司机线段树-我没有脑子
  4. android代码获取应用名称,Android获取应用程序名称(ApplicationName)
  5. CRM My Opportunity max hit的技术实现
  6. tcode SMQS
  7. Leetcode 3:无重复字符的最长子串
  8. oracle crontab e,Linux运维知识之通过crontab -e编辑生成的定时任务,写在哪个文件中...
  9. Angualr routerLink 两种传参方法及参数的使用
  10. MATLAB GUI程序设计中ListBox控件在运行期间消失的原因及解决方法
  11. Linq 支持动态字查询集合, 也就是说根据传入的值进行查询。
  12. 光学定位与追踪技术_视觉SLAM技术学习笔记(一)基础知识以及SLAM的应用
  13. 人工智能 一种现代方法 第8章 一阶逻辑
  14. (转载)高速ADC的关键指标:量化误差、offset/gain error、DNL、INL、ENOB、分辨率、RMS、SFDR、THD、SINAD、dBFS、TWO-TONE IMD...
  15. 数据统计分析(SPSS)【6】
  16. 孝感市小学生机器人编程比赛_小学生获机器人大赛一等奖 编程是语文老师教的...
  17. Android应用架构之Retrofit
  18. html 样式之style属性的使用
  19. 手把手教你美国亚马逊直购
  20. Python数据分析之用户留存

热门文章

  1. python中如何编写代码输入多个数据并把它们放在一个列表中去_10分钟学习函数式Python...
  2. Oracle distinct后加as,【大话IT】为何加distinct之后就不走索引了
  3. 设计一个类代表二维空间的一个圆。_平面设计基础——点、线、面
  4. c语言入门经典18个程序
  5. php request time,php中time()与$_SERVER[REQUEST_TIME]用法区别分析
  6. 计算机网络数据链路层 --- 后退n帧协议(GBN)
  7. linux设置历史命令保留数目限制,linux下修改history命令保存条数
  8. vscode angular智能提示_【线下活动】手把手教你玩转 VS Code 插件开发
  9. linux 到文件的最后一行,linux – 将第一行复制到文件中的最后一行
  10. java嵌入groovy脚本,java-如何捕获传递给Groovy脚本的参数?