不可思議的快!加速 Docker 中構建 Golang 應用

這些天我在工作中正在進行一個 GoLang 項目。這與我們通常使用的 Java 和 Spring Boot 應用程序有很大不同, 感覺很不錯:)。

和我們所有的其他組件一樣, 這個 GoLang 項目也需要被封裝在一個容器中, 才能在 Kubernetes 集羣中執行。所以我編寫了一個 Dockerfile:

# 構建階段
FROM golang:1.22.1-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# 最終階段
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.3
WORKDIR /
COPY --from=build /app/app /
CMD ["./app"]

我對此很滿意。這是一個多階段構建, 因此最終鏡像只包含編譯後的應用程序, 不需要 Go。

使用 Docker buildx, 我可以爲 amd64arm64 架構生成 Docker 鏡像。

docker buildx create --name builder --driver docker-container --bootstrap
docker buildx use builder
docker buildx build --platform linux/amd64,linux/arm64 -t ${CONTAINER_REGISTRY}/${CONTAINER_IMAGE_NAME}:latest --push .

在我的 M1 Mac 上, 鏡像在一分鐘內生成。不算快, 但也可以接受。

然後是在 GitLab 管道中運行它。在那裏它花了超過 30 分鐘!

GoLang 構建需要很長時間

這很奇怪。爲什麼構建需要這麼長時間? 深入研究, 發現是 go build 命令花費了大部分時間。 amd64 的構建相對較快 (3 分鐘), 而 arm64 則花了近 30 分鐘! 注意, 管道是在 AMD 機器上運行的。

#21 [linux/amd64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build -a -o app .
#21 DONE 179.8s
#25 [linux/arm64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build -a -o app .
#25 DONE 1793.4s

這是怎麼回事?

優化 #1 — 多架構構建: 模擬 vs 交叉編譯

經過一番搜索, 我發現了這篇 Docker 博客。它解釋了構建多架構 Docker 的兩種不同方式。默認情況下, 當構建與當前機器架構不同的鏡像時, 會使用軟件模擬器。這總是比原生執行慢。

我們能運行原生執行嗎? 是的, 通過交叉編譯方法, 可以通過將 FROM 指令更新爲 FROM--platform=$BUILDPLATFORM golang:1.22.1-alpine AS build 來實現。這將導致構建階段始終使用底層機器的架構, 即原生。

但是, 這會導致指令 RUN CGO_ENABLED=0GOOS=linux go build-o app. 失敗, 因爲它沒有指定目標架構。這可以通過使用內置參數 TARGETOSTARGETARCH 來解決。

這是新的 Dockerfile(更新的行已高亮):

# 構建階段
FROM --platform=$BUILDPLATFORM golang:1.22.1-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -a -o app .
# 最終階段
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.3
WORKDIR /
COPY --from=build /app/app /
CMD ["./app"]

現在, 構建在 4 分鐘內完成!

顯著提高了構建時間!

go build 命令只用了 1.5 分鐘左右完成兩個架構。太棒了!

#17 [linux/amd64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o app .
#17 DONE 97.6s
#18 [linux/amd64->arm64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -o app .
#18 DONE 97.6s

注意上面的 linux/amd64->arm64 表示交叉編譯。

有了 20 倍的提升, 我們可以到此爲止了。但還有一個優化可以做。

優化 #2 — 利用 Go 構建緩存

每次在 docker buildx 環境中運行 go mod downloadgo build 時, 都會下載依賴包。這顯然是低效的。我們如何在 docker buildx 環境中啓用訪問 Go 緩存和之前下載的包?

在我的 M1 Mac 上, Go 將下載的包存儲在 $HOME/go/pkg/mod 中, 緩存在 $HOME/Library/Cache/go-build 中。你可以在自己的環境中運行以下命令找到路徑。

❯ go env GOCACHE GOMODCACHE GOPATH
/Users/abhinavsonkar/Library/Caches/go-build
/Users/abhinavsonkar/go/pkg/mod
/Users/abhinavsonkar/go

如果我們可以讓 docker buildx 訪問這些路徑, 那麼應該會加快構建速度。這可以通過 docker 掛載來實現。

更新後的 Dockerfile(更新的行已高亮):

# 構建階段
FROM --platform=$BUILDPLATFORM golang:1.22.1-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
ARG GOMODCACHE GOCACHE
RUN --mount=type=cache,target="$GOMODCACHE" go mod download
ARG TARGETOS TARGETARCH 
COPY . .
RUN --mount=type=cache,target="$GOMODCACHE" \
    --mount=type=cache,target="$GOCACHE" \
    CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" go build -o /out/app .
# 最終階段
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.3
WORKDIR /
COPY --from=build /app/app /
CMD ["./app"]

以及用於傳遞正確路徑的 docker buildx 命令:

GOCACHE=${HOME}/Library/Caches/go-build
GOMODCACHE=${HOME}/go/pkg/mod
docker buildx create --name builder --driver docker-container --bootstrap
docker buildx use builder
docker buildx build                                             \
        --platform linux/amd64,linux/arm64                      \
        --build-arg GOCACHE=${GOCACHE}                          \
        --build-arg GOMODCACHE=${GOMODCACHE}                    \
        -t ${CONTAINER_REGISTRY}/${CONTAINER_IMAGE_NAME}:latest \
        --push                                                  \
        .

這在 M1 Mac 上啓用了重用 Go 緩存和包。但要在 GitLab 管道中使用, 稍微有點棘手。

GitLab 支持跨管道緩存, 使用 cache 關鍵字。但只有在檢出的項目下的文件夾才能被緩存。所以我們需要先修改 GOPATHGOCACHEGOMODCACHE 爲項目下的路徑。在 GitLab 管道中, 項目目錄可通過內置環境變量 $CI_PROJECT_DIR 訪問。

以下是一個 GitLab 作業片段, 可以正確利用 Go 緩存。編譯:

compile:
  services:
    - docker:25.0.4-dind-alpine3.19
  variables: # (1)
    GOPATH: "$CI_PROJECT_DIR/.go"
    GOCACHE: "$CI_PROJECT_DIR/.cache/go-build"
    GOMODCACHE: "$GOPATH/pkg/mod"
  before_script:
    - mkdir -p .go/pkg/mod .cache/go-build
  stage: build
  cache: # (2)
    key: "${CI_COMMIT_REF_SLUG}"
    paths:
      - .go/pkg/mod/
      - .cache/go-build/
  script: # (3)
    - |
      docker buildx create --name builder --driver docker-container --bootstrap
      docker buildx use builder
      docker buildx build                                       \
        --platform linux/amd64,linux/arm64                      \
        --build-arg GOCACHE=${GOCACHE}                          \
        --build-arg GOMODCACHE=${GOMODCACHE}                    \
        -t ${CONTAINER_REGISTRY}/${CONTAINER_IMAGE_NAME}:latest       \
        --push                                                  \
        .
  1. Go 的環境變量被設置爲項目內部的目錄。

  2. GitLab 緩存被定義爲選定的路徑。鍵 $CI_COMMIT_REF_SLUG將爲每個分支維護一個唯一的緩存。

  3. Docker buildx 命令與在 M1 Mac 上本地使用的命令相同。

通過上述更改, arm64go build命令完成時間縮短至 15 秒!

#21 [linux/amd64->arm64 build 6/6] RUN --mount=type=cache,target="/builds/project/.go/pkg/mod"     --mount=type=cache,target="/builds/project/.cache/go-build"     CGO_ENABLED=0 GOOS="linux" GOARCH="arm64" go build -o /out/app .
#21 DONE 14.5s
#22 [linux/amd64 build 6/6] RUN --mount=type=cache,target="/builds/project/.go/pkg/mod"     --mount=type=cache,target="/builds/project/.cache/go-build"     CGO_ENABLED=0 GOOS="linux" GOARCH="amd64" go build -o /out/app .
#22 DONE 5.4s

從 30 分鐘到 15 秒, 性能提升了 120 倍! 太棒了, 對嗎?

額外 - 利用 Docker 緩存

我使用的 Dockerfile 有一些其他指令來下載 RPM 包和創建用戶。每次管道運行時, 這些指令都會執行。但是一個簡單的技巧在這裏描述將重複使用 Docker 緩存, 並跳過每次下載。

docker pull ${CONTAINER_REGISTRY}/${CONTAINER_IMAGE_NAME}:latest || true
docker buildx create --name builder --driver docker-container --bootstrap
docker buildx use builder
docker buildx build                                             \
        --platform linux/amd64,linux/arm64                      \
        --build-arg GOCACHE=${GOCACHE}                          \
        --build-arg GOMODCACHE=${GOMODCACHE}                    \
        -t ${CONTAINER_REGISTRY}/${CONTAINER_IMAGE_NAME}:latest       \
        --push                                                  \
        .

這個技巧是先拉取我們打算構建的 Docker 鏡像 (即使鏡像不存在也不會失敗)。這將重複使用未更改的 Docker 緩存層。

最初, 管道的構建階段需要 31 分鐘。經過所有優化和上述 Docker 緩存之後, 構建階段在 52 秒內完成。

結論

我正在學習 GoLang, 這些改進給我帶來了巨大的滿足感。你是否正在爲你的 GoLang 應用程序運行管道? 那麼請檢查你的管道是否可以從本博客中描述的改進中獲益。如果你有其他建議, 請告訴我!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VdQ2b-6KyZvfq3UEpzTvgg