如何在golang代码里面解析容器镜像
簡介:容器鏡像在我們日常的開發工作中占據著極其重要的位置。通常情況下我們是將應用程序打包到容器鏡像并上傳到鏡像倉庫中,在生產環境將其拉取下來。然后用 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。
- 兩者沒有可以直接轉換的方式,目前的唯一方式就是按照順序來對應。
- 用一張圖來總結一下。
相關接口功能已在注釋中說明,不再贅述。
四 獲取鏡像相關元信息
我們以 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
以上我們便完成了從容器鏡像中讀取信息的功能。
原文鏈接
本文為阿里云原創內容,未經允許不得轉載。?
總結
以上是生活随笔為你收集整理的如何在golang代码里面解析容器镜像的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 移动云正式发布基于龙蜥 Anolis O
- 下一篇: 云效发布策略指南|滚动、分批、灰度怎么选