问题描述
今天看了一下我的一个 Go 的两个服务的构建时间,觉得不是非常满意。这两个服务分别为 server
端 和 worker
端,在使用了多阶段构建的情况下,每次全量编译出两个镜像需要 2.5min,由于两个服务基本属于通过代码结构可以共用代码的形式,只有 entrypoint
稍有不同,所以镜像的构建步骤只有略有不同。观察 docker 镜像的构建日志发现,即便是相同构建命令的 commit 也并没有 use-cache
,于是感觉还是有优化空间的。
├── cmd
│ ├── server
│ │ └── main.go # import from internal
│ └── worker
│ └── main.go # import from internal
├── internal
│ ├── server
│ │ └── server.go # import from pkg
│ └── worker
│ └── worker.go # import from pkg
├── pkg
│ ├── pkg1
│ │ ├── some
│ │ │ └── some.go
│ │ ├── another.go
问题分析
原本构建镜像时是分开两份 Dockerfile
,分别构建两个镜像,但除了编译命令以外,编译环境、工具、依赖、运行环境都是一致的。首先,从 Docker 的构建缓存机制入手,由于镜像是分层的,要缩短构建时间,就要尽量复用层,增加 cache 机制命中次数。并且这两个镜像的构建是先后发生的,缓存层并不会带来其他副作用。
FROM golang:alpine as builder
WORKDIR /workdir
RUN apk --no-cache add git make protobuf-dev
COPY . ./
RUN make install && \ # install tools(eg. protoc-gen-go)
CGO_ENABLED=0 app=worker make build # go build
FROM alpine:latest as prod
WORKDIR /app
COPY --from=0 /workdir/build/worker ./
ENTRYPOINT ["./worker"]
优化思路:
- 拆开编译和下载依赖的命令(编译工具和依赖都相同)
- 合并两个
Dockerfile
成一个,并输出两个镜像
方案一 --build-args
我首先想到的是通过构建参数来传入 Dockerfile
然后使用不同的编译命令达到在相应镜像编译出所需程序的效果。
ARG BUILD_APP=server
FROM golang:alpine as base
WORKDIR /workdir
RUN apk --no-cache add git make protobuf-dev
COPY . ./
RUN make install && \ # install tools
go mod download # download requirements
FROM base as builder
ARG BUILD_APP
RUN CGO_ENABLED=0 app=${BUILD_APP} make build # go build
FROM alpine:latest as prod
ARG BUILD_APP
WORKDIR /app
ENV BINARY_NAME=${BUILD_APP}
COPY --from=builder /workdir/build/${BINARY_NAME} ./
ENTRYPOINT ./$BINARY_NAME
由于 Docker 没有 global args ,所以需要每个 stage 都指定 args 也可以使用 env 等方式传递参数:https://github.com/moby/moby/issues/37345
然后在 Makefile
或者构建脚本内判断参数,编译对应程序即可。
build:
ifeq ($(app),server)
go build -v -o build/server ./cmd/server
else ifeq ($(app),worker)
go build -v -o build/worker ./cmd/worker
else
@echo "build nothing"
endif
执行构建参数使用:docker build -f Dockerfile -t server:tag . --build-arg BUILD_APP=server
方案二 --target
在查询资料的过程中发现多阶段构建还能使用 --target
参数指定输出阶段,于是 Dockerfile
也可以写成:
FROM golang:alpine as base
# ...
FROM base as builder
RUN CGO_ENABLED=0 app=server make build &&\
CGO_ENABLED=0 app=worker make build #
FROM alpine:latest as prod
WORKDIR /app
RUN something
FROM prod as server
COPY --from=builder /workdir/build/server ./
ENTRYPOINT ./server
FROM prod as worker
COPY --from=builder /workdir/build/worker ./
ENTRYPOINT ./worker
base 阶段与方案(一)一致,编译阶段同时编译两个程序,并分别编写两个最终输出镜像层。
使用:
docker build -f Dockerfile -t server:tag . --target server
docker build -f Dockerfile -t worker:tag . --target worker