logo

读书笔记 - 使用 Docker 和 Bash 搭建可持续的开发环境

上个月在网上冲浪,本想看看 David Bryant Copeland 的 《Sustainable Web Development with Ruby on Rails》一书有没有更新,发现他又写了一本新书:《Sustainable Dev Environments with Docker and Bash》(今年 3 月自出版的)。

https://devbox.computer/ 上大概浏览了一下目录,感觉似乎没有什么新鲜和高深的内容,Docker 和 Bash 我不精通,但日常开发需要的命令也算驾轻就熟。不过自从看了那本 Rails 的书就对他比较关注,因为他的书行文细致、讲解透彻、且都是来自多年一线实战的经验,所以我还是抱着支持的态度买了一本。

上周花了两天时间边做边学看完了,这两天趁着还没忘完赶紧写一篇笔记。

1. 书评

但凡在至少两家公司工作过的人应该都非常认同作者的观点:最熟悉一个公司开发环境的专家有两个,最初搭建环境的人和最近入职的人。那些疏于维护支离破碎的文档在每次新人入职后被修修补补,之后又再次被束之高阁等待不知道多少年后的下一位新人。

而 Docker 和 Bash 这两个平凡工具的结合无疑是一剂对症的良药:

在我看来,这本书的价值不在于有多少 Docker 和 Bash 的知识点,那些知识点在街头巷尾汗牛充栋。最重要的是字里行间作者:

  1. 对技术细节的严谨态度:比如 Docker 的 latest 标签,我一直都以为是表示最新的镜像,至少一直是以这个假设来使用的。但作者指出这是一个坑,把一个很老的镜像打上 latest 标签也没有任何关系,因为没有任何机制来验证或保证 “latest 表示最新的镜像” 这一点。
  2. 对开发体验的极致追求:这在稍后作者遵循的脚本设计原则里体现的淋漓尽致。
  3. 对可持续性的深度思考:为什么是 Docker 而不是 Podman/Nix/Devcontainers?为什么是 Bash 而不是 Ruby/Python/Lua/JavaScript?因为一个运行良好且被广泛理解的工具才是一个好的工具

“真正的大师都有一颗学徒的心”,我们以为的大师也有自己的知识盲区,也都在各自的荒原里一点一滴的探索。本书作为作者多年开发实践的经验结晶,我又有什么理由不站他肩膀上。

本文旨在记录在看书过程中遇到的值得一记的知识点和作者提炼出来的最佳实践,从一本书里提炼出一篇文章不可能像书本身那样循序渐进、面面具到,不过作为复习笔记也已足矣。

2. Docker

“无论你对 Docker 有什么先入为主的观念,我可以向你保证,让 Docker 安装软件比编写必须在许多不同操作系统和硬件配置文件上运行的脚本更简单”。

2.1 值得注意的细节

第一,Docker 镜像没有版本 (version) 的概念,它们只是活动的标签 (tag)。这意味着你今天使用的 debian:12 和昨天使用的 debian:12 可能是不同的东西,今天使用的 debian:12 实际是 12.1,而明天就可能指向的是 12.2。作者在写本书的时候遇到的情况是:他在没有更改镜像名的情况下,同样是 debian:12,第二次运行的镜像中 curl 命令却被删除了。所以在使用标签时,至少要使用 debian:12.1 这样的标签 (指定了副版本号)。

第二,永远永远永远不要使用 latest 标签。原因如前所述,latest 非常非常不靠谱,没有任何机制可以保证镜像是所谓的 latest,它只是一个普通的标签,而已 。这也是 Docker 的设计缺陷之一: Docker 规定所有的镜像名都必须包含一个冒号,如果没有,Docker 会默认添加一个 :latest。也因此,一定要明确指定如 debian:12.1 这样的标签名。

第三,应该始终使用可以被足够信任的镜像/软件链。如官方网站指向的镜像,或是 Docker 官方维护的镜像。

2.2 关于 Dockerfile

FROM debian:12.1

RUN apt-get update --quiet --yes

# curl is needed to install NodeJS
RUN apt-get install --quiet --yes curl

# From https://www.ruby-lang.org/en/documentation/installation/#apt
# NOTE: build-essential is needed to install Ruby C extensions
RUN apt-get install --quiet --yes ruby-full build-essential

# Based on
# https://github.com/nodesource/distributions?tab=readme-ov-file#using-debian-as-root-nodejs-22
RUN curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh && \
    bash nodesource_setup.sh && \
    apt-get install --quiet --yes nodejs

# From https://rubygems.org/gems/bundler
RUN gem install bundler

# Copy developer-specific Bash configuration into the image
COPY .bashrc.dx.local /root/.bashrc.dx.local
# Modify the existing /root/.bashrc to source the customizations
RUN echo "# Dev-specific Bash customizations" >> ~/.bashrc && \
    echo ". ./.bashrc.dx.local" >> ~/.bashrc

# Source code will be available here
WORKDIR /root/catsay

# Stay busy when we start so we can run commands
CMD sleep infinity

上面是本书中的 Dockerfile 例子,有一些值得记录的地方:

  1. 由于是在开发环境,所以文件被显式命名为 Dockerfile.dev
  2. Dockerfile 中的 RUN 指令默认是以 root 运行的,所以不需要再添加 sudo
  3. --quiet--yes 虽然可以缩写为 -qy,但使用长格式选项对不熟悉这两个标志的团队成员更友好。
  4. 确保总是在指令前面添加注释,因为你的团队成员可能不清楚命令的作用,而你自己也可能会遗忘。
  5. 每个 RUN 指令都会创建一个所谓的 (layer),这些层被缓存后可以显著加快后续镜像的构建速度。要尽可能的使层可以被缓存,就需要深思熟虑 RUN 指令的放置顺序,作者这里有一些观点:
    1. 考虑工具的实际更新频率:比如 ChromeDriver 可能经常更改,而 Ruby 的改动没那么频繁。
    2. 考虑工具对应用稳定性的影响:对于 Ruby 程序来说,显然 Ruby 的版本变动对程序的影响最大,Nodejs 的重要性就次点,而 Bundler 的更改可能比 Nodejs 更频繁,所以 Ruby -> Nodejs -> Bundler。

有点好奇书中没有讨论 Docker 镜像的多阶段构建问题。

上述 Dockerfile 被构建之后,以这种方式运行:

docker container create --name catsay-container --publish \
    4242:3000 --mount \
    type=bind,source=$(pwd),target=/root/catsay \
    catsay-dev:debian-12.1

这里的一些观点是:

  1. 镜像的名字最好是冒号前使用 <<app>>-dev 形式,冒号后使用包含软件关键版本的字符串,如 catsay-dev:debian-12.1
  2. Docker 的三种 volume (volume,tmpfs,bind),作者选择了 bind。
    1. volume 是创建 Docker 管理的独立文件系统;tmpfs 是在主机上创建临时目录,无持久化;bind 是把主机目录挂载到 Docker 内的指定路径。
    2. -v--mount 都可以用来挂载,区别只是语法不同,以及 --mount 不会自动创建目标目录 (所以使用了 WORKDIR)。
    3. 强烈建议不要使用 -v 因为它令人困惑,而 --mount 使用逗号分割的键值对,更明确。
    4. --mount 的键:typesourcedestinationreadonlyvolumn-opts (文档不详)。

但我挂载卷一直用的 -v 而没有用过 --mount

2.3 Docker Compose

当应用和数据库或其他服务一起使用时,就需要使用 Docker Compose:

services:
  app:
    image: ${IMAGE}
    init: true
    volumes:
      - type: bind
        source: "."
        target: "/root/catsay"
    ports:
      - "4242:3000"
  redis:
    image: redis:7.2-alpine

init: true 的作用是让 Docker 在容器内运行一个初始化进程,这个初始化进程会接管容器中的 PID 1,负责信号处理和子进程。对于 app 服务来说,使用 init: true 可以提高容器的可靠性,如果没有初始化进程,容器内的主进程 (PID 1) 可能无法正确处理这些信号,导致容器无法正常关闭或子进程变成孤儿进程。

而 redis 服务本身就是一个稳定的服务,通常已经内置了合适的信号处理机制,所以无需使用 init: true

这里 Docker Compose 的运行命令使用了 --ansi=never 以消除不支持彩色的终端可能带来的歧义性输出问题,这也太谨慎了。

docker compose --ansi=never --file docker-compose.dev.yml up \
    --detach --no-color

3. Bash

“Bash 会比我们所有人都活得更久。在你的职业生涯中,唯一比学习 Bash 更好的投资就是学习 SQL”。

“Bash 无处不在,尽管语法令人困惑,但它确实是开发环境自动化的绝佳选择。它的普遍性超过了你最喜欢的编程语言”。

Bash 有缺陷,很多缺陷,但通过一些严格和谨慎的处理,我们仍然可以用它制作一个出色的 UI。书中提出了四个 Bash 脚本的编写原则:

  1. 任何命令都不应该要求参数才能正确运行
  2. 每个命令都应该响应 -h 以提供详细的帮助信息
  3. 命令应该输出它们正在做什么的信息,以便理解它们的行为
  4. 命令应该验证它们所做的任何假设,并在假设无效的情况下向用户提供有用的错误信息

这些原则非常重要,在学习书中的例子或者自己写脚本的时候,回头看这些原则才更能明白其字字珠玑。

由于开发过程所经历的大体阶段是:

  1. build - 构建 Dokcer 镜像
  2. start - 启动服务
  3. exec - 在服务中执行命令
  4. stop - 停止服务

所以一般来说只需要创建四个脚本就可以覆盖整个开发流程。而首先要考虑的是:脚本应该放哪里?

无论哪里,不应该是 bin,因为 bin 是用来存放应用程序自己的脚本,是针对应用的。而我们的脚本是针对整个开发环境的,这是一个重要的区别。

一般来说,在程序根目录下创建一个 dx 目录来存放脚本是合理的,它表示 Developer Experience

3.1 build 脚本拆解

以下是 build 脚本:

#!/usr/bin/env bash

set -e
set -o pipefail

DIRNAME=$(dirname -- "${0}")
SCRIPT_DIR=$(cd -- "${DIRNAME}" > /dev/null 2>&1 && pwd)
. "${SCRIPT_DIR}"/shared.lib.sh
. "${SCRIPT_DIR}"/docker-compose.env

usage() {
    echo "usage: $0 [-h]"
    echo ""
    echo "Builds the development Docker image from Dockerfile.dev"
    echo ""
    echo "OPTIONS"
    echo ""
    echo "  -h - show this help"
}

while getopts ":h" opt "${@}"; do
  case ${opt} in
    h)
      usage
      exit 0
      ;;
    ?)
      log "Invalid option: ${OPTARG}"
      usage
      exit 1
      ;;
  esac
done
shift $((OPTIND -1))

check_for_docker
BASHRC_LOCAL="${ROOT_DIR}"/.bashrc.dx.local

if [ -e "${BASHRC_LOCAL}" ]; then
  log "${BASHRC_LOCAL} exists - not touching it"
else
  log "${BASHRC_LOCAL} missing - creating"
  echo "# Place custom Bash configuration to use" >> "${BASHRC_LOCAL}"
  echo "# inside the running container here" >> "${BASHRC_LOCAL}"
fi
if grep "${BASHRC_LOCAL}" .gitignore > /dev/null 2>&1; then
  log "${BASHRC_LOCAL} is already being ignored"
else
  log "${BASHRC_LOCAL} is not being ignored. Adding to .gitignore"
  echo "# This is for developer-specific customizations" >> .gitignore
  echo "${BASHRC_LOCAL}" >> .gitignore
fi

log "Building Docker image '${IMAGE}'"
docker buildx build \
  --quiet \
  --file "${ROOT_DIR}/Dockerfile.dev" \
  --tag "${IMAGE}" \
  "${ROOT_DIR}"

log "Docker image '${IMAGE}'" is built

以及它引用的 shared.lib.sh 脚本内容:

log() {
    echo "[ ${0} ]" "${@}"
}

if [ -z $SCRIPT_DIR ]; then
  log "SCRIPT_DIR was not defined"
  exit 1
fi

check_for_docker() {
    if ! command -v "docker" > /dev/null 2>&1; then
      log "Docker is not installed."
      log "Please visit https://docs.docker.com/get-docker/"
      exit 1
    fi
    log "Docker is installed!"
}

ROOT_DIR=$(cd -- "${SCRIPT_DIR}"/.. > /dev/null 2>&1 && pwd)

诚实地说,我之前写脚本一般“能跑就行”,没写过这么优美而健壮的脚本。上面的例子严格遵守作者设计的原则,考虑了很多失败的可能性:

  1. 如果脚本不是从应用根目录运行的怎么办?所以我们不能使用相对路径 (用 dirname 获取脚本本身的目录)。
  2. 如果 Docker 没有安装怎么办? 提取出来 check_for_docker 这个公共函数检查 Docker 是否安装。
  3. 如果我们需要调试脚本怎么办
    1. 调试脚本最重要的是要理解脚本在做什么,有常见的三种方式可以提供相关信息:
      1. 只打印错误信息,即“没有消息就是最好的消息”。
      2. 打印所有信息。
      3. 根据 --verbose--quiet 控制是否打印消息。
    2. 一般来说第三种方式是最好的,但就我们的目的而言,我们需要确保自己始终知道脚本正在做什么以及发生了什么,是否有错误产生。所以第二种方案更合适,采用第二种方案需要:
      1. 在执行任何操作前,都以人类可理解的语言打印出要执行的操作,包括任何派生值。
      2. 确保该操作的任何一种可能的结果都会生成另一种打印消息。
      3. 确保每条消息都明确标记为来自我们的脚本,以区别于任何其他输出。
    3. 因此我们封装了 log 函数而不是直接使用 echo 命令。
  4. 如果我们不知道这个脚本会干什么所以不敢运行它怎么办?所以我们提供了 -h 命令。

除此之外,上述脚本中还有一些值得注意的技术点:

除了上面的知识之外,usage 函数使用 echo 命令而不是我们的 log 函数也是经过考量的:这种情况下用户知道自己在使用 -h 获取帮助命令,如果还添加前缀会使输出变得混乱,无疑画蛇添足。

shared.lib.sh 脚本中有这样一些代码:[ -z $SCRIPT_DIR ],我也是才知道 [ 居然是个命令,这个命令接受一些参数,但要求最后一个参数是 ],确实是有点狂野了。这里 -z 标志要求一个参数,如果参数为空那么就返回 0 作为退出码。

[ 是一个命令,所以一直令人 (wo) 困惑的 [-z 之间是否应该有空格的问题,答案是显而易见的。

经过一番努力,我本机在应用根目录运行 dx/build 命令的输出结果如下:

[ dx/build ] Docker is installed!
[ dx/build ] /home/o/github/backend/b7-docker-bash/catsay/.bashrc.dx.local exists - not touching it
[ dx/build ] /home/o/github/backend/b7-docker-bash/catsay/.bashrc.dx.local is already being ignored
[ dx/build ] Building Docker image 'catsay-dev:debian-12.1'
sha256:2f3927f40ffeff7a1c016b1c6163c1150ed019a7a6d5ca79f6ef55ffa1959c5e
[ dx/build ] Docker image 'catsay-dev:debian-12.1' is built

是真的漂亮!

3.2 start/stop 脚本

上面的 build 脚本中有一行 . docker-compose.env,即导入 Docker Compose 所需要的环境变量文件,以这种方式管理环境变量避免了使用 .env 文件或者 export 方式声明环境变量所带来的复杂的、难以观察的隐式行为,且和 Docker Compose 的 --env-file 标志合作的天衣无缝:

# dx/start

# ...

docker compose \
  --ansi=never \
  --env-file "${SCRIPT_DIR}"/docker-compose.env \
  --file "${ROOT_DIR}"/docker-compose.dev.yml \
  up \
    --detach \
    --no-color

log "Dev environment started"

除此之外,start 和 stop 脚本就没有什么值得注意的了。

3.3 exec 脚本

Exec 脚本有点不一样,首先它需要接受命令才能运行,其次它应该有一个标志来指定要运行命令的容器。但我们仍然可以优化它!

目前的 Docker Compose 有 appredis 两个服务,考虑到毫无疑问我们需要频繁执行命令的是 app 服务,所以我们可以app 指定为默认运行的服务

# Default service commands are exec'ed in
SERVICE=app

usage() {
    echo "usage: $0 [-h] [-s service] command"
    echo ""
    echo "Executes a command inside a container"
    echo ""
    echo "OPTIONS"
    echo ""
    echo "  -s service - Set service name for the"
    echo "               command (default to '${SERVICE}')"
    echo "  -h         - show this help"
    # ...
}

经过这种构造,团队开发环境的文档可以简化为:

  1. 安装 Docker
  2. Clone 应用代码
  3. dx/build
  4. dx/start
  5. dx/exec <command> (dx/exec bin/setup)

当然可以根据需要进一步定制,这里作者通过在项目根目录内添加 .bashrc.dx.local 使得团队成员可以根据需要在此文件内添加自己的 Bash 自定义,相关代码在 build 脚本有所体现。

而最终的项目结构大概就是:

├── bin
│   ├── ci
│   ├── run
│   ├── seed
│   ├── setup
│   └── test
├── docker-compose.dev.yml
├── Dockerfile.dev
├── dx
│   ├── build
│   ├── docker-compose.env
│   ├── exec
│   ├── shared.lib.sh
│   ├── start
│   └── stop
├── Gemfile
├── Gemfile.lock
 ...

4. 结语

当然这只是一切的开始,别忘了我们的目标是“可持续的开发环境”,没有什么是一劳永逸的,要想可持续,就需要在日常开发中随着时间的推移而不断维护它。

4.1 如何解决日常开发中的 Dockerfile 问题

作者解决 Dockerfile 问题的方式是:

4.2 如何控制脚本质量

测试脚本是非常困难的,因为一些脚本命令会对计算机产生不可逆的影响 (想想 rm -rf),有三种技术或许可以帮到我们:

4.3 为什么搭建开发环境是核心能力

重点是你应该自动化你的开发环境、控制它、了解它的工作原理,且有设计合理的灵活性。你必须始终理解当前抽象的下一层: 比如编写汇编很烦人,所以发明了 C;但如果你只学习 C 而不是汇编,你最终会达到极限。你不知道 C 是为了解决什么而创建的,而你最终会遇到一个仅靠 C 知识无法解决的问题,那就是你需要汇编的时候。

开发环境也是如此,无论你使用什么机制来管理它,它最终都是为了管理你的环境而精心设计的其他技术之上的抽象,当出现问题时,你需要打开引擎盖看看里面有什么。因此你需要了解你的开发环境是基于什么而构建的。这也意味着你的开发环境应该使用你可以理解或者能够理解的技术,被普遍理解、经过实战经验的技术比功能似乎更丰富但更深奥且小众的技术更有价值。

想想看,如果你无法设置操作系统,或者无法安装、配置编写测试和运行应用程序所需的工具,那么一切开发都无从谈起。当遇到问题时,你不知道该怎么做,因为你对开发环境的各个部分都没有任何概念,更不用说如何找出哪些方面出了问题。

对于像开发环境这样的东西,你不会想要调试或者维护你无法控制的东西。比如 DevBox (https://www.jetify.com/devbox) 这样的产品声称你拥有你的开发环境,但如果它出了问题,你就只能先去学习 Nix (DevBox 底层使用 Nix),再去研究那 200 多行指令了。