765DevOps

Thinking is the problem, Doing is the answer !

0%

代码自定义Dockerfile

之前我司所执行的CI方案,其中对于业务来说是不开放Dockerfile的,由系统生成对应的Dcokerfile,业务唯一需要关心的只有build.sh文件。有优势(简单、高效、且安全)也有劣势(不灵活、无法多阶段构建、构建环境前依赖Jenkins机器环境),所以此处也做一个支持自定义的Dockerfile的方案。

凡事都有两面性,各有优势,各位可执行选择哈

1、现行方案梳理

1
// TODO 待补充

2、自定义Dockerfile方案

建议业务代码仓库存在CI的三个 标准文件: Dockerfile、Makefile、build.sh

1
2
3
4
5
6
├── Dockerfile  // 业务代码基于此构建出响应的容器镜像
├── Makefile // make命令执行,多命令入口组合;可结合docker 实现编译(分平台)、打包、发布等过程阶段
├── build.sh // 与业务本身无关,CI执行入口,含特定CI过程,也可满足复杂CI场景需求,如:构建制品处理,前端文件Oss上传等
├── src
└── ...

Dockerfile

Dockerfile 是由一系列命令和参数构成的脚本,这些命令应用于基础镜像并最终创建一个新的镜像。它们简化了从头到尾的流程并极大的简化了部署工作。Dockerfile 从 FROM 命令开始,紧接着跟随者各种方法,命令和参数。其产出为一个新的可以用于创建容器的镜像。

自定义的Dockerfile,可以支持多阶段构建,可以避免依赖构建的基础环境(比如:我们使用的Jenkins打包机)。

**备注:**在应用CI集成过程中,不建议在Docker镜像中构建,一是新起构建容器有一定资源开销;二是整个构建过程相关依赖缓存无法使用;三是获取对应构建产出的制品文件不方便,总体来说 效率不高 。但是在开源或者演示项目,可以采用这种方式。

2022-11-24补充:

关于下文提供构建缓存问题,已经找到解决方案,同样以Go项目编译为示例说明:

  • ① 共用go mod 依赖缓存
1
2
3
4
5
6
7
// docker新增一层生成依赖的镜像,以go为例,优先处理go.mod go.sum,达到层级共用
参考:https://evilmartians.com/chronicles/speeding-up-go-modules-for-docker-and-ci

FROM golang:1.18.8

COPY go.mod go.sum /opt/build/
RUN go mod download // go.mod go.sum 这里COPY的文件有变化的话,docker build会重新生成镜像
  • 【推荐】实用buildkit,RUN --mount=type=cache 可以缓存中间文件到host,同时可以兼容使用①中的go mod缓存,且避免了mod变动需要重新生成分层的问题

备注:buildkit is available from docker 18.09,同时需要在 docker build 命令前加入 DOCKER_BUILDKIT=1 启用buildKit特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 参考:https://yeasy.gitbook.io/docker_practice/buildx/buildkit
# 官网参考:https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache
# docker compose示例参考:https://github.com/docker/compose-cli/blob/main/Dockerfile

# 示例
# ===================================================================================
# syntax=docker/dockerfile:1.2
FROM golang:1.18.8 as build

# 设置go 的相关环境变量
ENV GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \
GOPRIVATE=gitlab.2345.cn

COPY go.mod go.sum /opt/build/

WORKDIR /opt/build

RUN --mount=type=cache,target=/go/pkg/mod \
go mod download

ARG LDFLAGS="-s -w"
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build,id=jszx-yfzt_go-build-cache \
GOOS=linux go build -ldflags "${LDFLAGS}" -trimpath -o ./bin/yfzt ./cmd/web/main.go
  • 示例(Old版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# =========【多阶段构建】构建阶段 =========
# 【Resolved】注1:公司应用CI过程,不建议此种方式,会存在构建制品依赖、资源开销、缓存无法复用、制品导出不方便等效率问题
# 注2:公司应用CI过程,建议build.sh中执行:① make build 构建; ② Dcokerfile 中简单 Copy 即可
# 使用go16作为基础镜像 [建议:镜像放置在私有仓库中,否则可能被“墙”,另测试golang:1.16-alpine轻量镜像无git tool,会影响后续构建,公司私有gomod拉不下来,无语……]
FROM golang:1.16.15 as build

# 传入LDFLAGS参数,指定应用相应版本、时间等信息
ARG LDFLAGS="-s -w"

# 设置go 的相关环境变量
ENV GOPROXY=https://goproxy.cn,direct \
GOPRIVATE=gitlab.xxx.cn

WORKDIR /opt/build

# 注意加入.dockerignore,避免大量无用文件copy,如:.git、.idea、.vscode等
COPY . .

# CGO_ENABLED禁用cgo,使用静态编译(前提:程序未调用cgo命令)
# 当CGO_ENABLED=1(默认),进行编译时会将文件中引用libc的库(比如常用的net包),以动态链接的方式生成目标文件(此方式经过测试可能会引发后续运行的系列问题……)。
# 当CGO_ENABLED=0,进行编译时则会把在目标文件中未定义的符号(外部函数)一起链接到可执行文件中,避免不同环境运行报错,含不同版本linux系统
# 然后指定OS等,并go build
# RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "${LDFLAGS}" -trimpath -o ./bin/demo ./cmd/web/main.go # 程序依赖了cgo,编译不通过,去除CGO_ENABLED=0
RUN GOOS=linux go build -ldflags "${LDFLAGS}" -trimpath -o ./bin/demo ./cmd/web/main.go


# =========【多阶段构建】运行阶段 =========
# 基础镜像,建议使用研发中台-应用配置-部署配置指定的基础镜像
# FROM harbor.xxx.cn/base/centos:7.8_200506170720 as prod # 平台基础镜像存在go依赖libc版本不匹配,若使用轻量的alpine镜像,默认为sh,无bash/dash,程序运行报错……
FROM golang:1.16.15 as prod

WORKDIR /opt/case/cloud

# 更改Docker运行用户为httpd,若未指定,默认为:root
# USER httpd

# 将build阶段对应的文件夹下的所有文件复制进来
COPY --from=build /opt/build/bin/. .
COPY --from=build /opt/build/conf .
COPY --from=build /opt/build/image-syncer .
COPY --from=build /opt/build/resource .

# 更改/opt/case的权限为:httpd(对应uid:2000)
RUN chown 2000:2000 -R /opt/case

# 服务暴露端口,建议使用研发中台-应用配置-部署配置指定的服务端口
EXPOSE 8089

# 程序执行命令
CMD ["./demo"]


# =========【多阶段构建】导出构建制品阶段 =========
# 注:针对Docker容器内构建且需要传统部署应用,其他场景此阶段直接Pass
# 运行阶段指定scratch(空镜像)作为基础镜像
FROM scratch AS export-artifacts
# 需要导出的制品文件
COPY --from=build /opt/build/bin/. .

Makefile

make命令是GNU的工程化编译工具,用于编译众多相互关联的源代码文件,以实现工程化的管理,提高开发效率,make 执行时会在当前目录下寻找 Makefile 或 makefile 文件。如果存在相应的文件,它就会依据其中定义好的规则完成构建任务。Makefile主要出现在c/c++的项目居多,用来管理c/c++的编译依赖。实际上 Makefile 内都是你根据 make 语法规则,自己编写的特定 Shell 命令。

它原生支持多入口子命令执行,是一个系统变量或者命令的集合。在容器化应用中引用dockerfile,或者读取默认的dockerfile,来完成自动按照某个流程 build的目的。

  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
NOW = $(shell date -u '+%Y%m%d%I%M%S')

GOVET = go tool vet -composites=false -methods=false -structtags=false
GOFMT ?= gofmt "-s"
GOFILES := $(shell find . -name "*.go" -type f -not -path "./vendor/*")
LDFLAGS += -s -w
LDFLAGS += -X "demo/pkg/version.gitBranch=$(shell git symbolic-ref --short -q HEAD)"
LDFLAGS += -X "demo/pkg/version.gitCommit=$(shell git rev-parse HEAD)"
LDFLAGS += -X "demo/pkg/version.goVersion=$(shell go version)"
LDFLAGS += -X "demo/pkg/version.buildTime=$(shell date "+%Y-%m-%d %T %Z")"

# 镜像构建相关参数
IMAGE_NAME = harbor.xxx.cn/defalut_project/default_name
IMAGE_TAG = 0.1-demo
ARTIFACT_OUTPUT = output_scm

export GOPROXY=https://goproxy.io

.PHONY: clean build linux macos windows docker docker-push export-artifacts

# 默认编译目标系统是 Linux 的二进制文件
all: clean linux

clean:
rm -rf demo

fmt:
@$(GOFMT) -w $(GOFILES)

build:
go build -ldflags '$(LDFLAGS)' -trimpath -o demo cmd/web/main.go

linux:
# Jenkins 构建机器上,go16使用为绝对路径
@#GOOS=linux /opt/app/go16/bin/go build -ldflags '$(LDFLAGS)' -trimpath -o ./bin/demo cmd/web/main.go
GOOS=linux go build -ldflags '$(LDFLAGS)' -trimpath -o ./bin/demo cmd/web/main.go

macos:
GOOS=darwin go build -ldflags '$(LDFLAGS)' -trimpath -o ./bin/demo cmd/web/main.go

windows:
GOOS=windows go build -ldflags '$(LDFLAGS)' -trimpath -o ./bin/demo cmd/web/main.go

docker:
docker build --build-arg LDFLAGS='$(LDFLAGS)' --target prod -t $(IMAGE_NAME):$(IMAGE_TAG) -f ./Dockerfile .

docker-push: docker
docker push $(IMAGE_NAME):$(IMAGE_TAG)
docker rmi $(IMAGE_NAME):$(IMAGE_TAG)

# 针对Docker容器内构建且需要传统部署应用,其他场景此阶段直接Pass(备注:buildkit is available from docker 18.09)
export-artifacts:
DOCKER_BUILDKIT=1 docker build --target export-artifacts --output ${ARTIFACT_OUTPUT} -f ./Dockerfile .

build.sh

满足相对复杂的构建场景需求,作为CI(本文使用工具为:Jenkins)的执行的唯一入口。一般来说涵盖的执行过程大体和应用本身关联不大,主要是业务或者特殊场景处理情况,比如:构建制品的处理,前端文件Oss上传等

  • 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/bin/bash

# 基本入参信息说明,业务根据实际需要获取参数即可
# appCode,对应平台的唯一Code
APP_CODE="$1"
# 构建环境(前端构建强依赖该参数),示例:ci1、st1、ga-devops
BUILD_ENV="$2"
# 是否为容器构建,"0"-不需要,"1"-需要
IS_DOCKER="$3"

# (传统部署必须)构建制品压缩包的文件名,示例:xxx.tar.gz
# //TODO,考虑和APP_CODE合并,注意前端应用兼容性
APP_PKG_NAME="$4"

# (容器部署必须)容器镜像地址 <IMAGE_NAME>:<IMAGE_TAG>,如:harbor.xxx.cn/cloud-demo/helloworld:v0.1-demo
IMAGE_NAME="$5"
IMAGE_TAG="$6"

# 当前工作目录,也就是代码仓库的根目录
BASE_DIR=$(cd "$(dirname "$0")";pwd)
# 制品目录,约定为:output_scm
OUTPUT_DIR="${BASE_DIR}/output_scm"

# check可根据自身业务业务需求做检查项调整
function check() {
if [ -z ${APP_PKG_NAME} ]; then
_showError '参数错误!请检查是否传入 APP_PKG_NAME 参数!'
fi
if [[ ! -z ${IS_DOCKER} && "${IS_DOCKER}" -eq "1" ]]; then
if [[ -z ${IMAGE_NAME} || -z ${IMAGE_TAG} ]]; then
_showError '参数错误!容器化应用需传入 IMAGE_NAME 和 IMAGE_TAG 参数!'
fi
fi
}

function build() {

# 方式一:非基于Docker容器内构建,借助Makile(将原有构建过程放置Makefile)
# make linux
# make docker-push

# 方式二:基于Dockerfile容器构建并Push
echo "make docker-push IMAGE_NAME=${IMAGE_NAME} IMAGE_TAG=${IMAGE_TAG}"
make docker-push "IMAGE_NAME=${IMAGE_NAME}" "IMAGE_TAG=${IMAGE_TAG}"

# 判断构建结果
if [ $? != 0 ]; then
_showError '构建错误!请检查控制台日志!'
fi
}

# Cloud平台管理的构建制品:xxx.tar.gz(传统/混合部署为必须项;容器化部署为非必须项,因为本身image即为制品)
function package() {
[ -d "${BASE_DIR}/output_scm" ] && rm -rf "${BASE_DIR}/output_scm"
mkdir -p ${BASE_DIR}/output_scm/${APP_PKG_NAME}


# 方式一:非基于Docker容器内构建,即基于make linux以来构建机器本地构建,则直接Copy即可
# 获取构建制品,默认文件导出为 ${BASE_DIR}/output_scm/${APP_PKG_NAME}
# cp -r "${BASE_DIR}/bin/." "${BASE_DIR}/output_scm/${APP_PKG_NAME}"


# 方式二:基于Dockerfile容器构建(注:此步骤会重新执行 docker build过程,有点耗时)
# 获取构建制品,默认文件导出为 ${BASE_DIR}/output_scm/${APP_PKG_NAME}
make export-artifacts "ARTIFACT_OUTPUT=${BASE_DIR}/output_scm/${APP_PKG_NAME}"

# 可选,其他配置或静态资源的copy
cp -r "${BASE_DIR}/conf" "${BASE_DIR}/output_scm/${APP_PKG_NAME}"
cp -r "${BASE_DIR}/image-syncer" "${BASE_DIR}/output_scm/${APP_PKG_NAME}"
cp -r "${BASE_DIR}/resource" "${BASE_DIR}/output_scm/${APP_PKG_NAME}"

cd "${BASE_DIR}/output_scm"
tar -zcf "${BASE_DIR}/output_scm/${APP_PKG_NAME}.tar.gz" "${APP_PKG_NAME}"
md5sum "${BASE_DIR}/output_scm/${APP_PKG_NAME}.tar.gz" >"${BASE_DIR}/output_scm/${APP_PKG_NAME}.tar.gz.md5"
}

function _showError() {
echo ''
echo '################################################'
echo " $1 "
echo '################################################'
echo ''
exit 1
}

check
build
# 传统/混合部署为必须项;容器化部署为非必须项,因为本身image即为制品(不执行则xxx.tar.gz访问404)
# package
# 前端应用选项,具体可参考历史前端项目
# uploadToOSS