容器镜像在我们日常的码里面解开发工作中占据着极其重要的位置。通常情况下我们是析容像将应用程序打包到容器镜像并上传到镜像仓库中,在生产环境将其拉取下来。器镜然后用 docker/containerd 等容器运行时将镜像启动,码里面解开始执行应用。析容像但是器镜对于一些运维平台来说,对于一个镜像制品本身的码里面解扫描和分析才是真正的关注点。本文简单介绍下如何在代码中解析一个容器镜像。析容像
go-containerregistry 是器镜 google 公司的一个开源项目,它提供了一个对镜像的码里面解操作接口,这个接口背后的析容像资源可以是 镜像仓库的远程资源,镜像的器镜tar包,甚至是码里面解 docker daemon 进程。下面我们就简单介绍下如何使用这个项目来完成我们的析容像目标—— 在代码中解析镜像。
除了对外提供了三方包,器镜该项目里面还提供了 crane (与远端镜像交互的客户端)gcrane (与 gcr 交互的客户端)。高防服务器
在介绍具体接口之间先介绍几个简单概念
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。 两者没有可以直接转换的方式,站群服务器目前的唯一方式就是按照顺序来对应。 用一张图来总结一下。相关接口功能已在注释中说明,不再赘述。
我们以 remote 方式(拉取远程镜像) 举例说明下如何使用。
package main import ( "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 { // Auth UserName string Password string // RegistryToken is a bearer token to be sent to a registry RegistryToken string // ECR AwsAccessKey string AwsSecretKey string AwsSessionToken string AwsRegion string // GCP GcpCredPath string InsecureSkipTLSVerify bool NonSSL bool SkipPing bool // this is ignored now Timeout 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.Option if 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 Registry return 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.Once var b []byte var err error return 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 []string var result *AnalysisResult tr := 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.Name filePath = strings.TrimLeft(filepath.Clean(filePath), "/") fileDir, fileName := filepath.Split(filePath) // e.g. etc/.wh..wh..opq if opq == fileName { opqDirs = append(opqDirs, fileDir) continue } // etc/.wh.hostname if 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.WaitGroup for _, d := range drivers { // filepath extracted from tar file doesnt 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.Package var version string for scanner.Scan() { line := scanner.Text() // check package if paragraph end if 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 = version case "o:": origin := line[2:] pkg.SrcName = origin pkg.SrcVersion = version } } // in case of last paragraph if 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 方法中将两个文件记录下来,跳过解析。
下面我们实际来看下如何读取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.Library var m manifest var foundPomProps bool for _, 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 }以上我们便完成了从容器镜像中读取信息的功能。
参考:
https://github.com/google/go-containerregistry
https://github.com/aquasecurity/fanal
项目地址: https://github.com/google/go-containerregistry