编写Docker Compose时要注意的五大常见错误_解决方案


在构建容器化的应用时,开发人员往往需要某种方法来引导启动目标容器,以对其进行代码级别的测试。尽管业界有许多方法可以实现该目的,但Docker Compose是目前最受欢迎的一种方法。它能够让如下两个方面变得容易实现:


  • 指定在开发过程中需要启动的容器。
  • 设置一套快速的代码测试调试(code-test-debug),以方便开发循环。


通常情况下,团队事先编写一个docker-compose.yml文件,指定开发所需的所有内容,并将其提交给存储库。然后,每个开发人员只需运行docker-compose up,即可启动测试其代码所需的所有容器。


不过,要让docker-compose的设置能够达到最佳性能状态,例如:在不到一分钟之内启动开发环境,并且在几秒钟内完成对每个更改的测试,这些都需要团队花费大量的工作。在这些准备过程中,由于各个开发人员每天花费在测试其代码上的时间各不相同,而且任何细微的改动,都可能会对整个开发团队的生产力产生巨大的影响。因此,我们有必要在此讨论他们在编写Docker Compose时常见的五大错误,及其对应的解决方法。


错误1:频繁地进行容器重建


Docker的构建往往比较耗时,特别是每次针对代码的变更开展测试的时候。如果能够节省此方面的时间,那么对于加快开发周期来说是十分有益的。过去,对于非容器化的应用,我们通常会采取如下传统的工作流程:


  • 编写代码
  • 构建
  • 运行


多年来,业界持续优化该流程,并提出了诸如:针对编译语言的增量构建和热重载(hot reloading)等实用技巧。随着容器技术的出现,我们在现有的工作流程中增加了docker构建的步骤,如下图所示。


  • 编写代码
  • 构建
  • Docker构建
  • 运行


当然,如果构建得不好,那么docker构建步骤也可能会带来额外的时间开销。例如:使用apt-get进行依赖项的重载步骤。有时候,这些步骤可能会让整个测试过程比添加Docker之前还要慢。


解决方案:在Docker外部运行代码


第一种解决方法是在Docker Compose中启动所有的依赖项,然后在本地运行测试代码。此举模仿了非容器化应用开发的工作流程。


您只需向localhost公布依赖关系,然后将正在使用的服务指向所有的localhost:地址即可。但是,该方法并非永远可行,如果您正在使用的是代码依赖容器镜像中的内置元素时,那么用户电脑就不一定能够访问到具体内容。


解决方案:最大化缓存,以优化Dockerfile


如果必须构建Docker镜像,那么我们可以编写Dockerfile,通过最大化缓存,将Docker的构建时间从原来的10分钟压缩至1分钟。


在生产环境中,Dockerfile的典型模式是通过将单个命令链接到一条RUN语句中,来减少层级的数量。毕竟,在开发过程中镜像的大小并不重要,重要的是层级的数量。下面展示的是在生产环境中的一个Dockerfile文件:


RUN \

go get -d -v \

&& go install -v \

&& go build


不过,该命令在每次被重新运行时,Docker都会重新下载所有的依赖项,并重新安装它们。我们可以通过增量构建(incremental build)来提供效率。


同时,您可以将开发专用的Dockerfile其分成几个短小的步骤,从而使得那些经常更改的代码步骤被排到最后,而将鲜少更改的步骤(例如拉式依赖关系)被放在首位。因此,在重建Dockerfile时,您不必构建整个项目,而只需构建那些被已更改的少量末尾块即可。有关此方面的案例,您可以参阅以下用于Blimp(请参见--https://kelda.io/blimp)开发的Dockerfile。通过遵循上述方法,您可以将繁琐的构建过程缩减到了几秒钟之内完成。


FROM golang:1.13-alpine as builder

RUN apk add busybox-static

WORKDIR /go/src/github.com/kelda-inc/blimp

ADD ./go.mod ./go.mod

ADD ./go.sum ./go.sum

ADD ./pkg ./pkg

ARG COMPILE_FLAGS

RUN CGO_ENABLED=0 go install -i -ldflags "${COMPILE_FLAGS}" ./pkg/...

ADD ./login-proxy ./login-proxy

RUN CGO_ENABLED=0 go install -i -ldflags "${COMPILE_FLAGS}" ./login-proxy/...

ADD ./registry ./registry

RUN CGO_ENABLED=0 go install -i -ldflags "${COMPILE_FLAGS}" ./registry/...

ADD ./sandbox ./sandbox

RUN CGO_ENABLED=0 go install -i -ldflags "${COMPILE_FLAGS}" ./sandbox/...

ADD ./cluster-controller ./cluster-controller

RUN CGO_ENABLED=0 go install -i -ldflags "${COMPILE_FLAGS}" ./cluster-controller/...

RUN mkdir /gobin

RUN cp /go/bin/cluster-controller /gobin/blimp-cluster-controller

RUN cp /go/bin/syncthing /gobin/blimp-syncthing

RUN cp /go/bin/init /gobin/blimp-init

RUN cp /go/bin/sbctl /gobin/blimp-sbctl

RUN cp /go/bin/registry /gobin/blimp-auth

RUN cp /go/bin/vcp /gobin/blimp-vcp

RUN cp /go/bin/login-proxy /gobin/login-proxy

FROM alpine


COPY --from=builder /bin/busybox.static /bin/busybox.static

COPY --from=builder /gobin/* /bin/


最后值得一提的是:随着多阶段构建(multi-stage builds,请参见--https://docs.docker.com/develop/develop-images/multistmuage-build/)的引入,我们如今可以创建各种具有良好分层和较小镜像的Dockerfile。不过,我们在此并不会展开详细的讨论。


解决方案:使用主机卷(host volumes)


大多数语言都会提供一种方法来监视程序代码,并在代码发生更改时自动重新运行。例如,nodemon就是JavaScript语言的一种Node自动重启工具(请参见--https://www.npmjs.com/package/nodemon)。由于主机卷可以将您电脑上的目录,镜像到正在运行的容器之中,因此您在使用文本编辑器来编辑文件时,各种更改将会被自动同步到容器中,并在容器内被立即执行。


最初,您可能需要花点时间进行前期准备,之后在Docker中,您可以在1-2秒内马上看到代码的更改结果。因此,我们会选择使用主机卷将代码直接挂载到容器中,以便以原生的方式,在包含其了运行时依赖项的Docker容器中运行自己的代码。


错误2:缓慢的主机卷


如果您使用过主机卷,那么是否已经注意到:在Windows和Mac上读写文件的速度可能会非常缓慢?其实,对于诸如Node.js和具有复杂依赖性的PHP应用程序之类,需要读写大量文件的命令而言,这是一个已知的问题。其背后的原因是:Docker主要运行在Windows和Mac上的VM中。而我们在进行主机卷的挂载时,它必须经过大量的转换,才能使文件夹进入容器,这有点类似于网络文件系统。而此类额外的开销,在Linux本地运行Docker时,则不会出现。


解决方案:放宽强一致性


该问题的一个关键原因是:文件系统在默认挂载时,需要保持强一致性。也就是说:所有特定文件的读写进程都必须统一对于文件修改的顺序,以便让文件的内容达成最终的一致。可是,强一致性的代价非常昂贵,它需要所有文件的写入进程之间持续保持协调,以确保它们不会干扰或破坏彼此的更改。


虽然在生产环境中的数据库需要保持强一致性。但是在开发过程中,由于写入进程就是代码文件本身,目标就是我们的存储库,因此强一致性就不那么必需了。那么,我们就可以考虑Docker在挂载卷时,放宽强一致性。例如:在Docker Compose中,我们可以简单地将此cached关键字添加到卷挂载中,以获得显著的性能保证。对应的代码如下:


volumes:

- "./app:/usr/src/app/app:cached"


注意:此举仅适合开发环境,不适合生产环境。


解决方案:代码同步


另一种处置方法是设置代码的同步。您可以使用工具侦测主机和容器之间的变化,通过复制文件来解决差异(类似于rsync),而不是挂载卷。Docker在最新的版本中内置了用来替代卷的缓存模式--Mutagen(请参见--https://mutagen.io/)。此外,上文提到的Blimp则使用Syncthing(请参见--https://http//syncthing.net/)实现了类似的功能。


解决方案:不要挂载软件包


Node之类的语言通常会把大部分文件操作放在packages目录中(如node_modules)。那么,我们可以试着从卷中去除此类目录,以显著提高性能。下列示例是一个将代码挂载到容器中的专属卷,它覆盖了node_modules目录。


volumes:

- ".:/usr/src/app"

- "/usr/src/app/node_modules"


该挂载操作会告诉Docker去使用node_modules目录下的标准卷,以使得在npm install运行时,不再使用慢速的主机挂载方式。为了使该工作能够正常进行,我们应该在容器首次启动时,在entrypoint中执行npm install,以安装依赖项,并更新node_modules目录。具体代码如下:


entrypoint:

- "sh"

- "-c"

- "npm install && ./node_modules/.bin/nodemon server.js"


如果您想查看并运行上述完整的示例,请参考--https://kelda.io/blimp/docs/examples/#nodejs。


错误3:脆弱的配置


如果您曾深入研究过代码,您可能会发现Docker Compose中也充斥着各种大量复制和粘贴而来的代码。显然,我们需要干净整洁的Docker Compose文件,以方便轻松地按需做出修改。


解决方案:使用各种env文件


Env文件能够将环境变量与Docker Compose主配置分开,以实现:


  • 避免将代码泄露到git的历史记录中。
  • 开发人员都能按需自定义设置。例如,每个开发人员都可以持有一个唯一的访问密钥。他们通过将配置保存在.env文件中,以实现不必修改已提交的docker-compose.yml文件,也不必在文件更新时处理各种冲突问题。


如果您想使用环境文件,只需添加一个.env文件,或设置带有env_file字段的显式路径即可(请参见--https://docs.docker.com/compose/environment-variables/#the-env_file-configuration-option)。


解决方案:使用替代文件


替换文件(请参见--https://docs.docker.com/compose/extends/)可以方便您在具有基本配置的基础上,在其他文件中指定各项修改。该功能非常适合Docker Swarm及其YAML文件。您可以将生产环境的配置存储在docker-compose.yml中,然后在替代文件中,指定开发所需的任何修改(例如:使用主机卷)。


解决方案:使用extends


如果您使用的是Docker Compose v2,那么就可以使用extends关键字,在多个位置导入YAML片段。例如,您可能会定义:公司里所有的服务都需要在开发的Docker Compose文件中带有某五个特定的配置。然后您可以使用extends关键字将其放置到任何需要的地方,以实现模块化。当然,如果仅在YAML中执行此项操作可能比较繁琐,我们完全可以通过编程来实现。


虽然Compose v3删除了对于extends关键字的支持。但是,您仍然可以使用YAML anchors(请参见--https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/)来实现类似的结果。


错误4:乱序启动(Flaky Boots)


如果docker-compose出现了崩溃,我们能够仅使用docker-compose restart来重启服务吗?其实此类问题主要与服务错误的启动顺序有关。例如,您的Web应用可能依赖于数据库,那么在Web应用启动时,如果数据库尚未准备就绪,就会出现崩溃。


解决方案:使用depends_on


depends_on使您可以控制启动的顺序。默认情况下,depends_on仅判断依赖项是否已经创建,而不会判断依赖项是否“健康”。虽然Docker Compose v2能够支持将depends_on与运行状况的检查相结合。不过,该功能也在Docker Compose v3中被去除了。当然,您可以使用诸如wait-for-it.sh之类的脚本,来手动实现类似的功能。


和上面提到的放宽强一致性相同,虽然Docker文档不建议在生产环境中使用depends_on和wait-for-it.sh,来为容器指定特定的启动顺序。但是对于开发而言,我们完全可以用到depends_on。


错误5:资源管理不善


如果您碰到开发流程受阻,Docker无法全速运行,或是无法平稳地获取运行所需的资源,那么您可以考虑以下几个方面:


解决方案:更改Docker Desktop的分配


Docker Desktop需要大量的RAM和CPU,尤其是在Mac和Windows的VM上。Docker Desktop的默认配置往往不会分配足够的RAM和CPU,因此我们通常需要调整相关的设置。在开发时,我经验是:为Docker分配大约8GB的RAM和4个CPU,并且在不使用Docker Desktop时,及时关闭之。


解决方案:删除未使用的资源


人们在使用Docker时经常会出现数百个卷与旧的容器镜像。这在无形中浪费了各种资源。为了释放这些资源,我们建议通过间或运行docker system prune的方式,以删除当前未使用到的所有卷、容器和网络。


总结


总的说来,为了改善开发人员在使用Docker Compose时的体验,我建议您做到如下五点:


  • 最小化容器的重建。
  • 使用主机卷。
  • 像对待代码那样,认真配置文件,以便于维护。
  • 让启动更加可靠。
  • 认真分配管理资源。


此外,您还可以通过链接--https://kelda.io/blog/docker-volumes-for-development/,以获悉如何设置主机卷,并加快Docker开发。



Java帮帮


编写Docker Compose时要注意的五大常见错误_强一致性_02