容器构建最佳实践
容器的临时性
这个原则意味着容器可以被随时暂停,销毁,然后重建。容器是无状态的。有状态的数据应该持久化到后端服务中。
容器构建上下文
build context指docker build执行时的当前目录,默认会找context的Dockerfile进行构建,也可以手动-f指定Dockerfile文件,也可以指定构建目录。
当前目录的所有目录和文件都会被发送到docker daemon当作构建的上下文,过多、过大的文件都会使得镜像构建时间加长,镜像大小变大。
因此,build context中应该只包含最少的文件。
通过标准输出管道构建
build context
build context可以是stdin,通过管道传递,这样的好处是不需要传送额外的文件,提升构建速度。
docker build -<<EOF
FROM busybox
RUN echo "hello world"
EOF
不过只限于一些简单的构建场景,使用这种方式不可以COPY或者ADD其他文件。
Dockerfile
Dockerfile也可以是stdin,但能实现上述的方式更复杂些的操作,可以使用COPY、ADD。
docker build -t myimage:latest -f- . <<EOF
FROM busybox
COPY somefile.txt ./
RUN cat /somefile.txt
EOF
remote build context
构建上下文可以使用远程地址,适用于一些场景比如git等。
docker build -t myimage:latest -f- https://github.com/docker-library/hello-world.git <<EOF
FROM busybox
COPY hello.c ./
EOF
使用.dockerignore排除文件
同.gitignore类似,docker build也可以通过配置.dockerignore文件排除掉不需要的文件。 格式
# comment
*/temp*
*/*/temp*
temp?
*.md
!readme.md
可以通配符*
匹配,!
排除。
多阶段构建
通过缓存机制,可以在最后一步构建阶段创建镜像,实现镜像的最小化。顺序一般是先从很少变动的基础环境层开始到最后的频繁变动层,这样可以更有效使用缓存机制。 顺序如下:
- 安装应用需要的各类工具
- 安装或更新各种依赖库
- 构建应用
案例
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine AS build
# Install tools required for project
# Run `docker build --no-cache .` to update dependencies
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep
# List project dependencies with Gopkg.toml and Gopkg.lock
# These layers are only re-built when Gopkg files are updated
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# Install library dependencies
RUN dep ensure -vendor-only
# Copy the entire project and build it
# This layer is rebuilt when a file changes in the project directory
COPY . /go/src/project/
RUN go build -o /bin/project
######## 以上都是为了构建项目运行目录而进行的,不需要在实际运行镜像中,因此多阶段构建只需拷贝构建结果到运行时目录即可。
# This results in a single layer image
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
不安装不需要的东西
尽量最小化安装,降低复杂度。构建依赖应该及时清理掉,仅保留运行时依赖。 比如使用 –no-install-recommends 参数告诉 apt-get 不要安装推荐的软件包 安装完软件包及时清理/var/lib/apt/list/ 缓存 删除中间文件:比如下载的压缩包 删除临时文件:如果命令产生了临时文件,也要及时删除
案例
...
FROM debian:9
RUN apt-get update && \
apt-get install -y \
[buildpackage] && \
[build my app] && \
apt-get autoremove --purge \
-y [buildpackage] && \
apt-get -y clean && \
rm -rf /var/lib/apt/lists/*
单一应用
一个容器应该仅包含一个进程,以便于水平伸缩和容器复用。
最小化层数
只有RUN
,COPY
,ADD
会创建镜像层,其他的会构建临时镜像,不会增加build的大小。
因此,应该尽可能少的使用这些命令,比如安装多个软件包,应该通过 &&
串起来执行。
除此之外,应该是用多阶段构建的方式减少层数。
多行参数排序
便于去重和更新,也便于阅读。使用\
进行换行
案例
RUN apt-get update && apt-get install -y \
bzr \
cvs \
git \
mercurial \
subversion \
&& rm -rf /var/lib/apt/lists/*
利用构建缓存
image build会根据Dockerfile的顺序执行,在检查每个指令时,Docker会在缓存中查找可以复用的现有镜像,而不是重新创建一个临时镜像。
基本规则如下
- 从已经存在缓存的父镜像开始,下一条指令与该基础镜像派生的所有子镜像进行比较,查看其中一个是否是使用完全相同的指令构建的,不是的话则缓存无效。
ADD
和COPY
指令会对每个文件进行校验和检查以确定是否匹配缓存,校验和不考虑文件ctime和mtime。如果校验和不一致,则缓存无效。- 除了
ADD
和COPY
之外,容器不会检查文件进行匹配。比如RUN apt-get -y update
不会检查更新文件,只会通过命令字符串匹配。
使用尽可能小的基础镜像
基础镜像越小,生成的镜像越小,构建速度越快,比如alpine
一个完整示例
#
FROM debian:buster-slim
######## set -e 用于故障调试,命令结果不为0则直接退出
######## set -x 用于问题调试,输出更多明细
RUN set -ex \
&& addgroup --system --gid 101 nginx \
######## 创建运行时用户,不可登录,无默认shell,不创建家目录。
&& adduser --system --disabled-login --ingroup nginx --no-create-home --home /nonexistent --gecos "nginx user" --shell /bin/false --uid 101 nginx \
######## 构建依赖和运行时依赖通过变量声明
######## 构建依赖进行排序, 运行时依赖进行排序
&& buildDeps="a1 b1 c1" \
&& runtimeDeps="1a 1b 1c" \
&& apt-get update \
####### 使用--no-install-recommends --no-install-suggests来避免安装不必要的软件包
&& apt-get install --no-install-recommends --no-install-suggests -y $buildDeps $runtimeDeps \
######## 使用 apt-mark manual可以将手动安装的软件更新为自动安装的,后续通过purge --auto-remove自动清理不需要的依赖
&& savedAptMark="$(apt-mark showmanual)" \
&& apt-mark showmanual | xargs apt-mark auto > /dev/null \
&& { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; } \
######## apt-get remove --purge --auto-remove -y 可以清理掉不需要的安装依赖,只保留运行时软件
######## 清理apt的安装缓存 rm -rf /var/lib/apt/lists/*
&& apt-get remove --purge --auto-remove -y && rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
EXPOSE 80
STOPSIGNAL SIGQUIT
CMD ["nginx", "-g", "daemon off;"]
容器运维最佳实践
使用容器的原生日志机制
容器提供了一种简单且标准化的日志处理方式,一般应用可以通过写入stdin
和stderr
中,使用 docker logs
可以查看。在k8s中,可以通过fluent-bit进行收集,进一步转发到其他日志系统中,比如Fluentd,EFK。
这种标准一般采用json格式,可读性稍差。
对于一些不好写入stdin
和stderr
的应用,比如tomcat,可以通过边车模式配置一个专门用于收集日志和处理日志轮询的容器。
确保容器无状态且不可变
无状态并不是不可以有状态,而是任何状态(任何类型的持久性数据)均存储在容器之外,比如持久卷中。
不变性是指容器在其生命周期内不会被修改。如果应用更新,则需要重建容器。这样容器部署更安全,可重复性更高。
对于一些外部变量,可以通过configmap
或secrets
的方式进行装载。
避免使用特权容器
特权容器可以访问主机所有容器,绕过容器的所有安全功能。使用K8sPSPPrivilegedContainer
可以限制特权容器运行。
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPSPPrivilegedContainer
metadata:
name: psp-privileged-container
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
excludedNamespaces: ["kube-system"]
使应用易与监控
传统监控类型包括黑盒监控和白盒监控两种。
黑盒监控是指从外部检查应用的可用性,容器应用和传统应用没有区别。
白盒监控指使用某种特别访问权限检查应用,并收集终端用户无法查看到的行为指标。一般使用prometheus方案,请求应用的暴露的监控端点,比如/metrics
url。
有些应用无法实现http端点模式,此类可以通过边车模式,比如java类应用可以通过jmx_exporter边车容器来协助暴露监控数据,再提供给prometheus。
公开应用的运行情况
其实就是探针,包括健康探针,就续探针,此处不再赘述。
避免以root身份运行
主要是因为容器使用的还是主机的内核,存在主机获权的风险。
谨慎选择镜像版本
使用版本tag,明确版本号。尤为注意的是,尽量不要使用latest,可能由于版本变更大导致无法运行的风险。
FROM supersoft:1.2.3
RUN a-command