如何在golang程式碼裡面解析容器映象

一  背景

容器映象在我們日常的開發工作中佔據著極其重要的位置。通常情況下我們是將應用程式打包到容器映象並上傳到映象倉庫中,在生產環境將其拉取下來。然後用 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值,常見於 本地環境,使用 <docker inspect "docker-id"> 看到的便是diffid。因為客戶端一般下載 ImageConfig, ImageConfig 裡面是引用的diffid。
  • layer digest 是壓縮後的層的hash值,常見於映象倉庫 使用 <docker manifest inspect "xxx:xx" > 看到的layers 一般都是 digest. 因為 manifest 引用都是 layer digest。
  • 兩者沒有可以直接轉換的方式,目前的唯一方式就是按照順序來對應。
  • 用一張圖來總結一下。
// ImageIndex 定義與 OCI ImageIndex 互動的介面type ImageIndex interface {// 返回當前 imageIndex 的 MediaType MediaType() (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 引用的 Image Image(Hash) (Image, error)// 返回這個 ImageIndex 引用的 ImageIndex ImageIndex(Hash) (ImageIndex, error)}// Image 定義了與 OCI Image 互動的介面type Image interface {// 返回了當前映象的所有層級, 最老/最基礎的層在陣列的前面,最上面/最新的層在陣列的後面 Layers() ([]Layer, error)// 返回當前 image 的 MediaType MediaType() (types.MediaType, error)// 返回這個 Image manifest 的大小 Size() (int64, error)// 返回這個映象 ConfigFile 的hash值,也是這個映象的 ImageID ConfigName() (Hash, error)// 返回這個映象的 ConfigFile ConfigFile() (*ConfigFile, error)// 返回這個映象的 ConfigFile 的位元組陣列 RawConfigFile() ([]byte, error)// 返回這個Image Manifest 的sha256 值 Digest() (Hash, error)// 返回這個Image Manifest Manifest() (*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 的 MediaType MediaType() (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")funcmain() { 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}funcGetDockerOption()(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}functryRemote(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)) } elseif 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 {returnnil, nil, err } img, err := desc.Image()if err != nil {returnnil, 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 讀取檔案一次並共享內容,以便分析器可以共享資料functarOnceOpener(r io.Reader)func()([]byte, error) {var once sync.Oncevar b []bytevar err errorreturnfunc()([]byte, error) { once.Do(func() { b, err = ioutil.ReadAll(r) })if err != nil {returnnil, xerrors.Errorf("unable to read tar file: %w", err) }return b, nil }}// 該方法主要是遍歷整個 io stream,首先解析出檔案的元資訊 (path, prefix,suffix), 然後呼叫 analyzeFn 方法解析檔案內容funcWalkLayerTar(layer io.Reader, analyzeFn WalkFunc)([]string, []string, error) {var opqDirs, whFiles []stringvar 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 {returnnil, 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..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 {returnnil, nil, xerrors.Errorf("failed to analyze file: %w", err) } } }return opqDirs, whFiles, nil}// 呼叫不同的driver 對同一個檔案進行解析funcanalyzeFn(filePath string, info os.FileInfo, opener analyzer.Opener,result *AnalysisResult)error {if info.IsDir() {returnnil, 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 {returnnil, xerrors.Errorf("unable to open a file (%s): %w", filePath, err) }if err = limit.Acquire(ctx, 1); err != nil {returnnil, xerrors.Errorf("semaphore acquire: %w", err) } wg.Add(1)gofunc(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)returnnil, 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 endiflen(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 = origin pkg.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
funcparseArtifact(c conf, fileName string, r io.ReadCloser)([]types.Library, error) {defer r.Close() b, err := ioutil.ReadAll(r)if err != nil {returnnil, xerrors.Errorf("unable to read the jar file: %w", err) } zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))if err != nil {returnnil, 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 {returnnil, 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 {returnnil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err) }case isArtifact(fileInJar.Name): fr, err := fileInJar.Open()if err != nil {returnnil, xerrors.Errorf("unable to open %s: %w", fileInJar.Name, err) }// 遞迴解析 jar/war/ear innerLibs, err := parseArtifact(c, fileInJar.Name, fr)if err != nil {returnnil, 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 {returnappend(libs, manifestProps.library()), nil } } p, err := searchBySHA1(c, b)if err == nil {returnappend(libs, p.library()), nil } elseif !xerrors.Is(err, ArtifactNotFoundErr) {returnnil, 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

達摩院—趣味視覺AI訓練營

點選閱讀原文檢視詳情!

相關文章