原文:https://codefresh.io/containers/docker-anti-patterns/
容器已经遍地开花。即便你尚未认定 Kubernetes 才是未来之选,单为 Docker 自身添枝加叶也非常容易。容器现在可以同时简化部署和 CI/CD 管道 (https://thenewstack.io/docker-based-dynamic-tooling-a-frequently-overlooked-best-practice/)。
官方的 Docker 最佳实践 (https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) 页面高度技术化并且更多地聚焦于 Dockerfile 的结构而非通常如何使用容器的基本信息。每个 Docker 新手都迟早会理解 Docker 层的使用、它们如何被缓存,以及如何创建更小的 Docker 镜像, 多阶段构建 也算不上造火箭,Dockerfiles 的语法也相当易于理解。
但是,使用容器的主要问题是企业无法在更大的图景上审视它,特别是容器/镜像不可改变的角色问题。尤其是很多企业试图将其既有的基于虚拟机的生产过程转化为容器,从而造成了有问题的结果。有太多关于容器的低层级细节(如何创建并运行它们),高层级的最佳实践却太少。
为了缩小文档的缺失,我为你呈上一份高层级 Docker 最佳实践的清单。鉴于无法覆盖所有企业的内部流程,我会转而说明坏的实践(也就是你不应该做的),但愿这会给你一些应该如何使用容器的启示。
这里就是我们将要考察的不良实践的完整清单:
- 试图将 VM 实践用于容器
- 创建不透明的 Dockerfile
- 创建有副作用的 Dockerfile
- 混淆了用于开发的镜像和用于部署的镜像
- 为每个环境创建一个不同的镜像
- 在生产服务器上拉取 git 代码并在线构建镜像
- 基于 git 源码而非 Docker 镜像进行团队协作
- 在容器镜像中硬编码密钥和配置
- 大而全-把 Docker 用作穷人的 CI/CD
- 小而不美-把容器只当成打包工具用
反模式 1 – 把 Docker 容器视为虚拟机
在见识更多实际例子之前,先明确一个基本原则:容器不是虚拟机。乍一看,它们行为类似,但实际上完全不同。
网上有很多诸如“如何升级容器内的应用?”、“如何 ssh 到一个 Docker 容器中?”、“如何从容器中取得日志?”、“如何在一个容器中运行多个程序?”之类的问题,从技术上讲这些问题及相关的解答是行得通的,但所有这些问题都是典型的“XY 问题”(自以为是的、非根本的问题)。这些提问背后的真正问题其实是:
如何将可变、长运行、有状态的 VM 实践,改变为 不可变、短周期、无状态 的容器工作流呢?
许多企业试图在容器世界中重用源自虚拟机的相同的实践/工具/知识。一些企业甚至对他们在容器出现后都还尚未完成从裸金属到虚拟机的迁移浑然不知。
改变积习非常困难。大多数开始使用容器的人起初将之视为他们既有实践的一个额外的新抽象层:
实际上,容器需要一种完全不同的视角,并改变现有的流程。你需要重新思考 所有 CI/CD 过程以适应容器。
相比于读懂容器的本质、弄懂其构建模块以及其历史(了不起的 chroot 命令),对于这种反模式没有更容易的解决之道。
如果你总是发现自己想要打开 ssh 会话运行容器以“更新”它们或是从外部手动取得日志/文件的话,那你肯定就是在使用 Docker 上走了歪路,需要格外地阅读一些容器如何工作的内容了。
反模式 2 – 创建不透明的 Docker 镜像
一个 Dockerfile 应该是透明且自包含的。它应该显而易见地描述应用的所有组件。任何人都应该能够取得相同的 Dockerfile 并重新创建出相同的镜像。从外部库中下载(以版本化且控制良好的方式) Dockerfile 是 ok 的,但创建那种能执行“神奇”步骤的 Dockerfile 应被避免。
这就是个倍儿坏的例子:
FROM alpine:3.4
RUN apk add --no-cache \
ca-certificates \
pciutils \
ruby \
ruby-irb \
ruby-rdoc \
&& \
echo http://dl-4.alpinelinux.org/alpine/edge/community/ >> /etc/apk/repositories && \
apk add --no-cache shadow && \
gem install puppet:"5.5.1" facter:"2.5.1" && \
/usr/bin/puppet module install puppetlabs-apk
# Install Java application
RUN /usr/bin/puppet agent --onetime --no-daemonize
ENTRYPOINT ["java","-jar","/app/spring-boot-application.jar"]
先别误会,我喜爱 puppet 这个棒棒的工具(或是 Ansible、Chef 等类似的)。在虚拟机中滥用它部署应用可能还凑合,但对于容器就是灾难性的了。
首先,这使得该 Dockerfile 依赖于所处的位置。你不得不将其构建在一台能访问到生产环境 puppet 服务器的的机器上。你的工作站满足条件吗?如果是的话,那么你的工作站真的应该能访问到生产环境的 puppet 服务器吗?
但最大的问题是这个 Docker 镜像不能被轻易地重新创建。其内容依赖于当初始化构建之时 puppet 服务器上有什么。如果在一天之内再次构建相同的 Dockerfile 则有可能得到全然不同的镜像。还有如果你无法访问 puppet 服务器或 puppet 服务器宕机了,你甚至根本都没法构建出镜像。如果无法访问到 puppet 脚本,甚至也不知道应用的版本。
写出这样 Dockerfile 的团队真是太懒了。已经有这么个在虚拟机中安装应用的 puppet 脚本,在编写 Dockerfile 时翻新一下拿过来就要用。
这个问题的解决办法是最小化 Dockerfile,让其明确地描述所做之事。这里是同一个应用的 “更合适的” Dockerfile:
FROM openjdk:8-jdk-alpine
ENV MY_APP_VERSION="3.2"
RUN apk add --no-cache \
ca-certificates
WORKDIR /app
ADD http://artifactory.mycompany.com/releases/${MY_APP_VERSION}/spring-boot-application.jar .
ENTRYPOINT ["java","-jar","/app/spring-boot-application.jar"]
可以注意到:
- 不依赖 puppet 基础设施。Dockerfile 可以被构建在任何能访问到指定二进制仓库的开发者机器上。
- 软件版本被明确地声明了。
- 只需要编辑 Dockerfile 就能轻易地改变应用的版本 (而不需要 puppet 脚本)。
这只是个非常简单(也是编造出来的)例子。现实中我见过很多依赖于“神奇”方法的 Dockerfile,对其可被构建的时机和位置都有特殊要求。请不要以这种给开发者(以及其它无法访问整个系统的人)在本地创建 Docker 镜像制造巨大困难的方式编写你的 Dockerfile。
一个更好的替代方式可能是让 Dockerfile 自己来(使用多阶段构建)编译 Java 代码。这让你对 Docker 镜像中将要发生什么一览无余。
反模式 3 – 创建有副作用的 Dockerfile
想象一下,如果你是一名工作在使用来多种编程语言的大企业中的 运维/SRE 工程师的话,是很难成为每种编程语言领域的专家并为之构建系统的。
这是优先采用容器的主要优势之一。你应该能从任何开发团队下载任何的 Dockerfile 并在不考虑副作用(因为就不应该有)的情况下构建它。
构建一个 Docker 镜像应该是个幂等的操作。对同一个 Dockerfile 构建一次还是一千次,或是先在 CI 服务器上后在你的工作站上构建都不应该有问题。
但是,有些构建阶段的 Dockerfile 则是这样的:
- 执行 git commit 或其它 git 动作
- 清理或破坏数据库
- 用 POST/PUT 操作调用其它外部服务
容器提供了与宿主文件系统有关的隔离性,但没有什么能保护你从一个 Dockerfile 中包含的 RUN 指令中调用 curl 向你的内联网 POST 一个 HTTP 负载。
这个简单的例子演示了一个在同一次运行中既安装依赖(安全操作)又发布(不安全的操作)npm 应用的 Dockerfile:
FROM node:9
WORKDIR /app
COPY package.json ./package.json
COPY package-lock.json ./package-lock.json
RUN npm install
COPY . .
RUN npm test
ARG npm_token
RUN echo "//registry.npmjs.org/:_authToken=${npm_token}" > .npmrc
RUN npm publish --access public
EXPOSE 8080
CMD [ "npm", "start" ]
这个 Dockerfile 混淆了两个不相干的关注点,即发布某个版本的应用和为之创建一个 Docker 镜像。或许有时这两个动作确实一起发生,但这不是污染 Dockerfile 的借口。
Docker 不是也永远不应该是 一种通用的 CI 系统。不要把 Dockerfiles 滥用为拥有无限威力的加强版 bash 脚本。容器运行时有副作用是 ok 的,但构建时不行。
解决之道是简化 Dockerfile 并确保其只包含幂等操作:
- clone 源码
- 下载依赖项
- 编译/打包代码
- 处理/压缩/转译 本地资源
- 只在容器文件系统中运行脚本并编辑文件
同时,谨记 Docker 缓存文件系统层的方式。Docker 假设如果一个层及早于其的若干层没有“被改变过”的话就可以从缓存中重用它们。如果你的 Dockerfile 指令有副作用,你就从本质上破坏了 Docker 缓存机制。
FROM node:10.15-jessie
RUN apt-get update && apt-get install -y mysql-client && rm -rf /var/lib/apt
RUN mysql -u root --password="" < test/prepare-db-for-tests.sql
WORKDIR /app
COPY package.json ./package.json
COPY package-lock.json ./package-lock.json
RUN npm install
COPY . .
RUN npm integration-test
EXPOSE 8080
CMD [ "npm", "start" ]
假设当你尝试构建该 Dockerfile 时你的测试失败的话,你会对改变源码并再试着重新构建一次。Docker 将假设清理数据库的那层已经 RUN 过了并且可以从缓存中重用它。所以你的新一次的测试将在数据库未被清理且包含了之前那次运行的数据的情况下被执行。
在本例中,Dockerfile 很小,有副作用的语句也容易定位 (mysql 命令) 并移动到合适的位置以修正层缓存。但在真实的 Dockerfile 中包含许多命令,如果你不知道 RUN 语句中哪条有副作用,要确定它们的的正确顺序非常困难。
如果要执行的所有动作都是只读且有本地作用域的,这样的 Dockerfile 会简化许多。
反模式 4 – 混淆了用于开发的镜像和用于部署的镜像
在任何采用了容器的企业,通常会有两个分别的 Docker 镜像目录。
第一个目录包含用作要发送到生产服务器的真实部署产物的镜像;而部署镜像中应该包含:
- 已压缩/已编译的应用代码及其运行时依赖
- 没别的了,真的没别的了
第二个目录中是用于 CI/CD 系统或开发者的镜像;镜像中可能包含:
- 原始状态的源代码(也就是未压缩过的)
- 编译器/压缩器/转译器
- 测试框架/统计工具
- 安全检查、质量检查、静态分析
- 云集成工具
- CI/CD 管道所需的其它工具
显然由于这两个容器镜像目录各有不同的用途和目标,应该被分别处理。要部署到服务器的镜像应该是最小化、安全的和经过检验的。用于 CI/CD 过程的镜像不需要真正部署,所以它们不需要多少严格的限制(对于尺寸和安全性)。
但出于一些原因,人们并不总是能理解这种差别。我见过好多尝试去使用同样的镜像用于开发和部署的企业,几乎总是会发生的是其生产环境 Docker 镜像中都包含了一堆毫不相干的工具和框架。
生产环境的 Docker 镜像绝无理由包含 git、测试框架或是编译器/压缩器。
作为通用部署产物的容器,总是应该在不同的环境中使用相同的部署产物并确保你所测试的也是你所部署的(更详细的稍后展开说);但尝试把本地开发和生产部署联合起来是注定失败的。
总之,要尝试去理解你的 Docker 镜像的角色。每一个镜像都应该扮演一个单独的角色。如果把测试框架/库放到生产环境那肯定是错的。你应该花些时间去学习并使用 多阶段构建。
反模式 5 – 为每个环境创建一个不同的镜像 (QA、stage、production)
使用容器的最重要优势之一就是其不可变的属性。这意味着一个 Docker 镜像应该只被构建一次并依次部署在各种环境中(测试、预发布)直至到达生产环境。
因为完全相同的镜像作为单一的实体被部署,就能保证你在一个环境中所测试的和其它环境中完全一致。
我见过很多企业将代码版本或配置稍有差别的不同产出物,用于各种环境的构建。
这之所以有问题是因为无法保证镜像“足够相似”,以便能够以相同方式验证其行为。同时也带来了很多滥用的可能,开发者/运维人员 各自在非生产镜像中使用额外的调试工具也造成了不同环境中镜像的更大差异。
与其竭力确保不同镜像尽可能地相同,远不如对所有软件生命周期阶段使用单一镜像来得容易。
要注意不同环境使用不同设置(也就是密钥和配置变量等)是特别正常的,本文后面也会谈论这点。但除此之外,其它的所有东西,都应该一模一样。
反模式 6 – 在生产服务器上创建 Docker 镜像
Docker registry(译注:可以理解为类似 git 仓库的实体,可以是 DockerHub 那样公有的,也可以在私有数据中心搭建)起到的作用就是作为那些可以被随时随处重新部署的既有应用的一个目录。它也是应用程序资源的中心位置,其中包含额外的元数据以及相同应用程序的以前的历史版本。从它上面选择一个 Docker 镜像的指定 tag 非常容易,并且能将其部署到任意环境中。
使用 Docker registry 的最灵活的方式之一就是在 registries 之间推进镜像。一个机构至少会有两个 registries(开发/生产)。一个 Docker 镜像应该被构建一次(参考之前的一个反模式)并被置于开发 registry 中。然后,一旦集成测试、安全检查,及其自身的各种功能行质量验证都正常后,该镜像就能被推进到生产 registry 以供发送到生产服务器或 Kubernetes 集群中了。
每个地区/位置或每个部门拥有不同的 Docker registries 机构同样是可能的。这里的要点是 Docker 部署的典型方式也会包含一个 Docker registry。Docker registries 同时起到了作为应用资源 repository 和应用部署到生产环境之前中介存储的两个作用。
一种相当有问题的做法就是从生命周期中完全移除了 Docker registries 并直接把源代码推送到生产服务器。
生产服务器使用 git pull
以取得源码,随后 Docker 在线构建出一个镜像并本地化地运行它(通常通过 Docker-compose 或其它编排工具)。这种“部署方法”简直是反模式的集大成者!
这样的部署做法造成一系列的问题,首先就是安全性问题。生产服务器不应该访问 git 仓库。如果一个企业严肃对待安全性问题,这种模式甚至不会被安全委员会批准。生产服务器安装了 git 本身就莫名其妙。git(或其它版本管理系统)是一种开发者协作工具,而非一种产出物交付方案。
但其最严重的问题是这种“部署方法”完全绕过了 Docker registries 的作用域。因为不再有持有 Docker 镜像的中心位置,你就无法感知哪个 Docker 镜像被部署到了服务器上了。
起初这种部署方法可能工作正常,但随着更大的安装量将迅速变得低效。你需要去学习如何使用 Docker registries 及其带来的好处(也包含相关的容器安全性检查)。
Docker registries 有定义良好的 API,以及若干可被用来创建你的镜像的开源和专有产品。
同样要注意到,借助 Docker registries,你的源码安全性将总是能被防火墙挡在身后了。
反模式 7 – 基于 git 源码而非 Docker 镜像进行团队协作
对于前两条反模式的一个推论是 -- 一旦采用了容器,你的 Docker registry 就应该成为一切的真理,人们谈论 Docker 的 tag 和镜像的话题。开发者和运维人员应该使用容器作为他们的通用语言,两类团队间的传递的实体应该是容器而非一个 git hash。
这与使用 git hash 作为“推进产物”的旧方式背道而驰。源码固然重要,但为了推进它而反复重新构建是一种对资源的浪费(参考反模式5)。很多企业认为容器只应该被运维人员处理,而开发者只要弄好源码就行了;这可能与正确做法相去甚远。容器是一个让开发者和运维人员协作的绝佳机会。
理想情况下,运维人员甚至不应该关心到一个应用的 git 仓库。他们需要知道的只是到手的 Docker 镜像是否准备好了被推送到产品环境,而不是着眼于重新构建一个 git hash 以取得开发者已经在预发布环境使用过的相同镜像。
通过询问你所在机构中的运维人员,就能知道你是否吃了这种反模式的亏。如果运维人员要熟悉构建系统或测试框架这些和实际运行时无关的应用内部细节,将很大地拖累其日常运维工作。
反模式 8 – 在容器镜像中硬编码密钥和配置
这个反模式和反模式 5 关系密切(每个环境一种镜像)。在大多数情况下,当我问起一些企业为何他们的 QA/预发/生产 环境需要不同的镜像时,答案通常是它们包含了不同的配置和密钥。
这不光破坏了对 Docker 的主要期待(部署你所测试过的),同时也让所有 CI/CD 管道变得非常复杂 -- 它们不得不在构建时管理密钥和配置。
当然对于熟悉 12-Factor(译注:III - 在环境中存储配置)的人来说,这个反模式不算新鲜事了。
应用应该在运行时而不是构建时请求配置。一个 Docker 镜像应该是与配置无关的。只有在运行时配置才应该被“附加”到容器中。有很多对此的解决方案,并且大部分集群化/部署系统都能集成一种运行时配置方案(如 configmaps、zookeeper、consul)和密钥方案(vault、keywhiz、confidant、cerberus)。
如果你的 Docker 镜像硬编码了 IP 或凭证等,那你就中招了。
反模式 9 – 创建大而全的 Dockerfile
我读到过一些文章,建议把 Dockerfile 应该被当成一种穷人版的 CI 解决方案去用。这就是一个那种 Dockerfile 的真实例子:
# Run Sonar analysis
FROM newtmitch/sonar-scanner AS sonar
COPY src src
RUN sonar-scanner
# Build application
FROM node:11 AS build
WORKDIR /usr/src/app
COPY . .
RUN yarn install \
yarn run lint \
yarn run build \
yarn run generate-docs
LABEL stage=build
# Run unit test
FROM build AS unit-tests
RUN yarn run unit-tests
LABEL stage=unit-tests
# Push docs to S3
FROM containerlabs/aws-sdk AS push-docs
ARG push-docs=false
COPY --from=build docs docs
RUN [[ "$push-docs" == true ]] && aws s3 cp -r docs s3://my-docs-bucket/
# Build final app
FROM node:11-slim
EXPOSE 8080
WORKDIR /usr/src/app
COPY --from=build /usr/src/app/node_modules node_modules
COPY --from=build /usr/src/app/dist dist
USER node
CMD ["node", "./dist/server/index.js"]
乍一看这个 Dockerfile 貌似很好的应用了 多阶段构建,而实际上这一股脑儿的集合了之前的反模式。
- 假设了存在一个 SonarQube server (反模式 2)
- 因为可以推送到 S3 而具有潜在的副作用 (反模式 3)
- 镜像既管开发又管部署 (反模式 4)
就其本身而言,Docker 并不是一个 CI 系统。容器化技术可被用作 CI/CD 管道的一部分,但这项技术某种程度上是完全不同的。不要混淆需要运行在 Docker 容器中的命令和需要运行在 CI 构建任务中运行的命令。
某些文章提倡使用构建参数与 labels 交互并切换某些指定的构建阶段等,但这只会徒增复杂性。
修正以上 Dockerfile 的方法就是将其一分为五。一个用来部署应用,其它用作 CI/CD 管道中不同的步骤。
一个 Dockerfile 只应该有一个单独的 用途/目标。
反模式 10 – 创建小得可怜的 Dockerfile
因为容器也包含了其依赖,所以很适于为每个应用隔离库和框架版本。开发者对于在工作站上尝试为相同工具安装多个版本的问题不厌其烦。只需要在你的 Dockerfile 中精确描述应用所需,Docker 就可以解决解决上述问题。
但是这种模式要用得对路才有效。作为一个运维人员,其实并不真的关心开发者在 Docker 镜像中使用了什么编程工具。运维人员应该在不用真的为每种编程语言建立一个开发环境的前提下,创建一个 Java 应用的 Docker 镜像,再创建一个 Python 的镜像,紧接着再创建一个 Node.js 的。
然而很多企业仍将 Docker 视为一种静默打包格式,并只用其打包一个已经在容器之外完成了的 产出物/应用。
Java 的繁重组织形式是这种反模式的重灾区,甚至官方文档中也有出现。下面就是 “Spring Boot Docker guide” 官方文档中推荐的 Dockerfile 写法:
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
这个 Dockerfile 只是打包了一个既有的 jar 文件。这文件从哪来的?没人知道。这事在 Dockerfile 中完全没有过描述。如果我是一名运维人员,还得专心安装上全套 Java 本地化开发库,就为了构建这么一个文件。如果你工作在一个使用了多种编程语言的机构中,不光是运维人员,对于整个构建节点,这个过程都会迅速变得脱离控制。
我用 Java 来举例,但这个反模式也出现在其它情形下。Dockerfile 无法工作,除非你先执行一句 npm install
,这也是常常发生的事情。
针对这个反模式的解决之道和对付反模式 2(不透明、不自包含的 Dockerfile)的办法一样。确保你的 Dockerfile 描述了某个过程的全部。如果你遵循这个方式,你的 运维/SRE 同事甚至会爱上你。
对于以上 Java 的例子,Dockerfile 应该被修改为:
FROM openjdk:8-jdk-alpine
COPY pom.xml /tmp/
COPY src /tmp/src/
WORKDIR /tmp/
RUN ./gradlew build
COPY /tmp/build/app.war /app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
这个 Dockerfile 明确描述了应用如何被创建,并且能够在不用安装本地 Java 的情况下被任何人在任何工作站上运行。作为练习,你还能自己使用 多阶段构建 来改进这个 Dockerfile。
总结
很多企业在采用容器时遇到了麻烦,因为他们企图把既有的虚拟机经验硬塞进容器。最好先花费一些工夫重新思考容器具有的所有优势,并理解如何利用新习得的知识从头创建你的过程。
在本文中,我列出了使用容器时若干错误的实践,也为每一条开出了解药。
检查你的工作流,和你的开发同事(如果你是运维人员的话)或运维同事(如果你是开发者)聊聊,试着找出企业是否踩了这些反模式的坑吧。
--End--
查看更多前端好文
请搜索 fewelife 关注公众号
转载请注明出处