[译] 十大 Docker 反模式

原文:codefresh.io/containers/…前端

容器已经遍地开花🐒。即使你还没有认定 Kubernetes 才是将来之选,单为 Docker 自身添枝加叶也很是容易。容器如今能够同时简化部署和 CI/CD 管道 (thenewstack.io/docker-base…)。java

官方的 Docker 最佳实践 (docs.docker.com/develop/dev…) 页面高度技术化而且更多地聚焦于 Dockerfile 的结构而非一般如何使用容器的基本信息。每一个 Docker 新手都早晚会理解 Docker 层的使用、它们如何被缓存,以及如何建立更小的 Docker 镜像, 多阶段构建 也算不上造火箭,Dockerfiles 的语法也至关易于理解。node

可是,使用容器的主要问题是企业没法在更大的图景上审视它,特别是容器/镜像不可改变的角色问题。尤为是不少企业试图将其既有的基于虚拟机的生产过程转化为容器,从而形成了有问题的结果。有太多关于容器的低层级细节(如何建立并运行它们),高层级的最佳实践却太少。mysql

为了缩小文档的缺失,我为你呈上一份高层级 Docker 最佳实践的清单。鉴于没法覆盖全部企业的内部流程,我会转而说明坏的实践(也就是你不该该作的),希望这会给你一些应该如何使用容器的启示。linux

这里就是咱们将要考察的不良实践的完整清单:git

  1. 试图将 VM 实践用于容器
  2. 建立不透明的 Dockerfile
  3. 建立有反作用的 Dockerfile
  4. 混淆了用于开发的镜像和用于部署的镜像
  5. 为每一个环境建立一个不一样的镜像
  6. 在生产服务器上拉取 git 代码并在线构建镜像
  7. 基于 git 源码而非 Docker 镜像进行团队协做
  8. 在容器镜像中硬编码密钥和配置
  9. 大而全-把 Docker 用做穷人的 CI/CD
  10. 小而不美-把容器只当成打包工具用

反模式 1 – 把 Docker 容器视为虚拟机

在见识更多实际例子以前,先明确一个基本原则:容器不是虚拟机。乍一看,它们行为相似,但实际上彻底不一样。spring

网上有不少诸如“如何升级容器内的应用?”、“如何 ssh 到一个 Docker 容器中?”、“如何从容器中取得日志?”、“如何在一个容器中运行多个程序?”之类的问题,从技术上讲这些问题及相关的解答是行得通的,但全部这些问题都是典型的“XY 问题”(自觉得是的、非根本的问题)。这些提问背后的真正问题实际上是:sql

如何将可变、长运行、有状态的 VM 实践,改变为 不可变、短周期、无状态 的容器工做流呢?docker

许多企业试图在容器世界中重用源自虚拟机的相同的实践/工具/知识。一些企业甚至对他们在容器出现后都还还没有完成从裸金属到虚拟机的迁移浑然不知。数据库

改变积习很是困难。大多数开始使用容器的人起初将之视为他们既有实践的一个额外的新抽象层:

容器可不是虚拟机

实际上,容器须要一种彻底不一样的视角,并改变现有的流程。你须要从新思考 全部 CI/CD 过程以适应容器。

Containers require a new way of thinking
容器须要一种突破性的新思考方式

相比于读懂容器的本质、弄懂其构建模块以及其历史(了不得的 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"]
复制代码

能够注意到:

  1. 不依赖 puppet 基础设施。Dockerfile 能够被构建在任何能访问到指定二进制仓库的开发者机器上。
  2. 软件版本被明确地声明了。
  3. 只须要编辑 Dockerfile 就能轻易地改变应用的版本 (而不须要 puppet 脚本)。

这只是个很是简单(也是编造出来的)例子。现实中我见过不少依赖于“神奇”方法的 Dockerfile,对其可被构建的时机和位置都有特殊要求。请不要以这种给开发者(以及其它没法访问整个系统的人)在本地建立 Docker 镜像制造巨大困难的方式编写你的 Dockerfile。

一个更好的替代方式多是让 Dockerfile 本身来(使用多阶段构建)编译 Java 代码。这让你对 Docker 镜像中将要发生什么尽收眼底。

反模式 3 – 建立有反作用的 Dockerfile

想象一下,若是你是一名工做在使用来多种编程语言的大企业中的 运维/SRE 工程师的话,是很难成为每种编程语言领域的专家并为之构建系统的。

这是优先采用容器的主要优点之一。你应该能从任何开发团队下载任何的 Dockerfile 并在不考虑反作用(由于就不该该有)的状况下构建它。

构建一个 Docker 镜像应该是个幂等的操做。对同一个 Dockerfile 构建一次仍是一千次,或是先在 CI 服务器上后在你的工做站上构建都不该该有问题。

可是,有些构建阶段的 Dockerfile 则是这样的:

  1. 执行 git commit 或其它 git 动做
  2. 清理或破坏数据库
  3. 用 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 镜像目录。

第一个目录包含用做要发送到生产服务器的真实部署产物的镜像;而部署镜像中应该包含:

  1. 已压缩/已编译的应用代码及其运行时依赖
  2. 没别的了,真的没别的了

第二个目录中是用于 CI/CD 系统或开发者的镜像;镜像中可能包含:

  1. 原始状态的源代码(也就是未压缩过的)
  2. 编译器/压缩器/转译器
  3. 测试框架/统计工具
  4. 安全检查、质量检查、静态分析
  5. 云集成工具
  6. CI/CD 管道所需的其它工具

显然因为这两个容器镜像目录各有不一样的用途和目标,应该被分别处理。要部署到服务器的镜像应该是最小化、安全的和通过检验的。用于 CI/CD 过程的镜像不须要真正部署,因此它们不须要多少严格的限制(对于尺寸和安全性)。

但出于一些缘由,人们并不老是能理解这种差异。我见过好多尝试去使用一样的镜像用于开发和部署的企业,几乎老是会发生的是其生产环境 Docker 镜像中都包含了一堆绝不相干的工具和框架。

生产环境的 Docker 镜像绝无理由包含 git、测试框架或是编译器/压缩器。

做为通用部署产物的容器,老是应该在不一样的环境中使用相同的部署产物并确保你所测试的也是你所部署的(更详细的稍后展开说);但尝试把本地开发和生产部署联合起来是注定失败的。

总之,要尝试去理解你的 Docker 镜像的角色。每个镜像都应该扮演一个单独的角色。若是把测试框架/库放到生产环境那确定是错的。你应该花些时间去学习并使用 多阶段构建

反模式 5 – 为每一个环境建立一个不一样的镜像 (QA、stage、production)

使用容器的最重要优点之一就是其不可变的属性。这意味着一个 Docker 镜像应该只被构建一次并依次部署在各类环境中(测试、预发布)直至到达生产环境。

Promoting the same Docker image
部署相同的 Docker 镜像

由于彻底相同的镜像做为单一的实体被部署,就能保证你在一个环境中所测试的和其它环境中彻底一致。

我见过不少企业将代码版本或配置稍有差异的不一样产出物,用于各类环境的构建。

Different image per environment
每一个环境用了不一样的镜像

这之因此有问题是由于没法保证镜像“足够类似”,以便可以以相同方式验证其行为。同时也带来了不少滥用的可能,开发者/运维人员 各自在非生产镜像中使用额外的调试工具也形成了不一样环境中镜像的更大差别。

与其竭力确保不一样镜像尽量地相同,远不如对全部软件生命周期阶段使用单一镜像来得容易。

要注意不一样环境使用不一样设置(也就是密钥和配置变量等)是特别正常的,本文后面也会谈论这点。但除此以外,其它的全部东西,都应该如出一辙。

反模式 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 并直接把源代码推送到生产服务器。

Building images in production servers
在生产服务器上构建镜像

生产服务器使用 git pull 以取得源码,随后 Docker 在线构建出一个镜像并本地化地运行它(一般经过 Docker-compose 或其它编排工具)。这种“部署方法”简直是反模式的集大成者!

这样的部署作法形成一系列的问题,首先就是安全性问题。生产服务器不该该访问 git 仓库。若是一个企业严肃对待安全性问题,这种模式甚至不会被安全委员会批准。生产服务器安装了 git 自己就莫名其妙。git(或其它版本管理系统)是一种开发者协做工具,而非一种产出物交付方案。

但其最严重的问题是这种“部署方法”彻底绕过了 Docker registries 的做用域。由于再也不有持有 Docker 镜像的中心位置,你就没法感知哪一个 Docker 镜像被部署到了服务器上了。

起初这种部署方法可能工做正常,但随着更大的安装量将迅速变得低效。你须要去学习如何使用 Docker registries 及其带来的好处(也包含相关的容器安全性检查)。

Using a Docker registry
使用一个 Docker registry

Docker registries 有定义良好的 API,以及若干可被用来建立你的镜像的开源和专有产品。

一样要注意到,借助 Docker registries,你的源码安全性将老是能被防火墙挡在身后了。

反模式 7 – 基于 git 源码而非 Docker 镜像进行团队协做

对于前两条反模式的一个推论是 -- 一旦采用了容器,你的 Docker registry 就应该成为一切的真理,人们谈论 Docker 的 tag 和镜像的话题。开发者和运维人员应该使用容器做为他们的通用语言,两类团队间的传递的实体应该是容器而非一个 git hash。

Talking about git hashes
谈论的仍是 git hash 😕

这与使用 git hash 做为“推动产物”的旧方式背道而驰。源码当然重要,但为了推动它而反复从新构建是一种对资源的浪费(参考反模式5)。不少企业认为容器只应该被运维人员处理,而开发者只要弄好源码就好了;这可能与正确作法相去甚远。容器是一个让开发者和运维人员协做的绝佳机会。

Talking about containers
言必及容器

理想状况下,运维人员甚至不该该关心到一个应用的 git 仓库。他们须要知道的只是到手的 Docker 镜像是否准备好了被推送到产品环境,而不是着眼于从新构建一个 git hash 以取得开发者已经在预发布环境使用过的相同镜像。

经过询问你所在机构中的运维人员,就能知道你是否吃了这种反模式的亏。若是运维人员要熟悉构建系统或测试框架这些和实际运行时无关的应用内部细节,将很大地拖累其平常运维工做。

反模式 8 – 在容器镜像中硬编码密钥和配置

这个反模式和反模式 5 关系密切(每一个环境一种镜像)。在大多数状况下,当我问起一些企业为什么他们的 QA/预发/生产 环境须要不一样的镜像时,答案一般是它们包含了不一样的配置和密钥。

这不光破坏了对 Docker 的主要期待(部署你所测试过的),同时也让全部 CI/CD 管道变得很是复杂 -- 它们不得不在构建时管理密钥和配置。

固然对于熟悉 12-Factor(译注:III - 在环境中存储配置)的人来讲,这个反模式不算新鲜事了。

Hardcoding configuration at build time
在构建时硬编码配置

应用应该在运行时而不是构建时请求配置。一个 Docker 镜像应该是与配置无关的。只有在运行时配置才应该被“附加”到容器中。有不少对此的解决方案,而且大部分集群化/部署系统都能集成一种运行时配置方案(如 configmaps、zookeeper、consul)和密钥方案(vault、keywhiz、confidant、cerberus)。

Loading configuration during runtime
在运行时加载配置

若是你的 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 关注公众号

转载请注明出处

相关文章
相关标签/搜索