了解背景

Docker 是一种轻量级的容器化技术,主要用于应用程序的环境迁移和一致性。你需要知道下面的基本概念:

OK,上面三个基本概念,你就足够使用docker了,然后我们来了解docker最实用的地方:

  1. 环境迁移,在不同的机器上保持一致性,一次创建或配置,可以在任意地方正常运行。。
  2. 直接在docker内利用linux环境和路径挂载来开发。
    1. 隔离依赖、统一开发环境、快速启动。
    2. 利用 Linux 容器作为开发环境:在不同平台(Windows、Mac、Linux)上保持一致的开发环境。
    3. 通过 volumes 实现容器与主机的文件共享,利用挂载路径在本地编辑代码,并在容器中即时运行。典型场景:在容器中运行应用,同时在本地编辑源代码(热重载),也可以使用vscode remote.

docker 还有更加高级的配置,比如说可以让容器直接绑定网卡,处理网络包的数据,进行网卡级别的数据转发工作。这在软路由或者旁路由中用处比较多。这里会涉及到很多的细节,比如使用网卡的时候,和host的网络冲突和隔离等。作为开发和运行环境,暂时不会接触到这些。

用起来

Dockerfile

这是镜像的配置文件,教程可以参考:https://yeasy.gitbook.io/docker_practice/image/build ,我们会更多的从例子学习。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# syntax=docker/dockerfile:1.4  # 指定Dockerfile的语法版本,确保使用最新功能

# -------------------------------------
# 第一阶段:构建阶段(Build Stage)
# 使用多阶段构建:减少最终镜像的大小,只保留构建完成后的应用程序,而不包含开发工具
# -------------------------------------

# 使用轻量级的 Python 基础镜像作为构建环境
FROM python:3.9-slim AS builder

# 设置环境变量,确保Python在无缓存模式下运行,减少镜像大小
ENV PYTHONDONTWRITEBYTECODE=1 # 防止Python写入.pyc文件
ENV PYTHONUNBUFFERED=1 # 确保日志信息(stdout和stderr)实时输出

# 设置工作目录
WORKDIR /app

# 安装系统依赖
# 使用 `--no-install-recommends` 以避免安装额外不需要的包,保持镜像精简
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/* # 清理缓存以减小镜像大小

# 复制requirements.txt文件到容器,并安装依赖
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt # 使用--no-cache-dir避免缓存

# -------------------------------------
# 第二阶段:运行阶段(Runtime Stage)
# 这里是最终生成的镜像,只保留运行所需的内容,确保镜像尽量小
# -------------------------------------

# 使用一个更小的基础镜像作为运行时环境
FROM python:3.9-slim

# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 # 防止Python写入.pyc文件
ENV PYTHONUNBUFFERED=1 # 确保日志信息(stdout和stderr)实时输出

# 设置工作目录
WORKDIR /app

# 复制已安装的依赖,从build阶段复制
COPY --from=builder /root/.local /root/.local

# 确保pip的安装路径可用
ENV PATH=/root/.local/bin:$PATH

# 复制应用源代码到工作目录
COPY . .

# 暴露应用程序端口(例如Flask默认端口5000)
EXPOSE 5000

# 设置运行时的用户为非root用户,提升安全性
RUN useradd -m appuser
USER appuser

# 设置应用程序的入口点,容器会运行这个命令。
CMD ["python", "app.py"]

# 这会定期检查 Flask 应用是否在端口 5000 正常运行,如果检查失败,Docker 会认为容器不健康。
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl --fail http://localhost:5000 || exit 1

基础命令

  • FROM 设置基础镜像,
  • ENV 设置环境变量,构建和运行的时候都会生效。
  • WORKDIR 指定工作目录,
  • USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。
  • COPY 复制项目文件到镜像,
  • EXPOSE 指令是声明容器运行时提供服务的端口,这只是一个声明,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。
  • CMD 主要用于定义容器启动时的默认命令或参数,可以被用户在 docker run 命令行中覆盖。注意建议使用这种列表的格式,因为能够避免一些多重引号的冲突。
  • ENTRYPOINT:用于固定的默认启动,指定了一个容器启动时必须执行的命令,这个命令不容易被覆盖,除非使用 --entrypoint 选项。应该放在CMD前面,并且用于完成固定的初始化任务和运行服务。
  • HEALTHCHECK 指令是告诉 Docker 应该如何进行判断容器的状态是否正常,
    • -interval=<间隔>:两次健康检查的间隔,默认为 30 秒;
    • -timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;
    • -retries=<次数>:当连续失败指定次数后,则将容器状态视为 unhealthy,默认 3 次。

多级构建

这里采用了多阶段构建,builder这个镜像实际上只使用了 /root/.local 里构建好的东西,这个镜像会保存作为缓存,但是不会用于运行应用。python:3.9-slim 是实际运行应用的镜像,因为构建时安装的很多依赖,在运行的时候不需要。这么做有如下好处:

  1. 减少镜像大小:builder 阶段安装了所有开发工具和依赖,但它不会包含在最终的运行镜像中。最终镜像只包含最少的运行环境和应用程序本身。例如,python:3.9-slim 镜像的体积非常小(约 55MB),相比于完整的 python:3.9 镜像(约 885MB),这在部署生产环境时极大减少了资源占用。

  2. 提高构建速度:通过 Docker 的缓存机制,当你修改代码但没有修改依赖时,Docker 只需要重新复制代码,而不必重新安装依赖。如果依赖没有变化,Docker 会使用 builder 阶段的缓存,加速后续的构建。

构建、运行、停止、删除容器

构建命令

1
docker build -f docker/Dockerfile -t my-app ..

这里指定构建的镜像的命名,还有上下文 .. 是上级目录。-t 选项为生成的镜像指定一个名称,果你想要指定版本或标签,可以这样使用 my-app:1.0

构建镜像后,你可以通过 docker run 命令来运行容器

1
2
3
4
docker run -d -p 8080:80 \
-v /path/to/api_key.txt:/run/secrets/api_key.txt \
-e API_KEY=my-secret-key \
--name my-app-container my-app

-d:-d 表示容器将在后台运行(“detached mode”),即以守护进程的方式运行。这对于运行长期服务(如 Web 服务器)很有用。

-p 8080:80:-p 选项将宿主机的端口映射到容器的端口。在这个例子中,将宿主机的 8080 端口映射到容器的 80 端口,假设你的应用在容器内的 80 端口上运行。访问 http://localhost:8080 就相当于访问容器内的服务。

-v 是挂载文件到容器,

-name 是自定义容器的名字,

-name my-app-container:为容器指定一个名称,方便后续操作和管理。你可以通过容器名称来启动、停止、删除或查看日志。如果不指定名称,Docker 会随机生成一个名称。
my-app:这是刚才构建的镜像名称。Docker 会基于这个镜像启动容器。

这里是运行默认的命令,我们可以在 my-app 后面加上自己的命令,用于自定义的运行。例如 docker run -d -p 8080:80 --name my-app-container my-app python [app.py](http://app.py/)

如果希望交互式的运行

1
docker run -it --name my-app-container my-app bash

-it:-i 表示交互模式,-t 表示为容器分配一个伪终端。使用这两个选项可以进入容器并与容器进行交互。

bash:启动容器时运行 bash,这样你可以获得容器内的命令行。

当你不再需要容器时,可以通过以下命令停止容器

1
docker stop my-app-container

docker stop:停止一个运行中的容器。你可以通过容器的名称或 ID 来指定它。

当你不再需要容器时,可以通过以下命令删除容器

1
docker rm my-app-container

导入密钥的最佳实践

有时候容器里需要导入密钥来运行某些应用,一般的做法可以:

  1. 使用卷挂载,然后读取,可以参考之前的命令。
  2. 运行的时候指定环境变量,可以参考之前的命令。
  3. 使用docker compose 来运行

❗不要在 dockerfile 里写入隐私的密钥,环境变量是可以查看的

Docker compose

简介 | Docker — 从入门到实践

Compose 定位是 「定义和运行多个 Docker 容器的应用(Defining and running multi-container Docker applications)」,其前身是开源项目 Fig。一个 Dockerfile 模板文件,可以让用户很方便的定义一个单独的应用容器。然而,在日常工作中,经常会碰到需要多个容器相互配合来完成某项任务的情况。例如要实现一个 Web 项目,除了 Web 服务容器本身,往往还需要再加上后端的数据库服务容器,甚至还包括负载均衡容器等。

Compose 恰好满足了这样的需求。它允许用户通过一个单独的 docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器为一个项目(project)。

Compose 中有两个重要的概念:

  • 服务 (service):一个应用的容器,实际上可以包括若干运行相同镜像的容器实例。
  • 项目 (project):由一组关联的应用容器组成的一个完整业务单元,在 docker-compose.yml 文件中定义。

Compose 的默认管理对象是项目,通过子命令对项目中的一组容器进行便捷地生命周期管理。

编写docker compose

主要规定了每个服务怎么启动,以及依赖关系。学习下面的例子,模仿配置的格式:

  1. container_name,image,ports这些都是固定的写法。
  2. 默认从当前目录的 .env文件读取环境变量,并且相对路径是相对命令行所在的命令,而不是dockerfile里那样,手动设置的上下文。所以从哪里启动 docker compose,是比较重要的。
  3. healthcheck 也比较常用,因为其他服务可能依赖这个服务正常运行,所以设置了检查健康的方式。用的方式是命令的返回码为 0。比如curl返回结果非2xx,或者超时;命令行命令执行异常。
  4. volumes 有两种类型,一种是卷,类似esdata01,直接挂载到目录。一种是本地文件,用相对位置寻找,./init.sql:/docker-entrypoint-initdb.d/init.sql,有些特定位置的文件会执行特定操作。比如说当数据库未初始化时,会执行。

对于项目,build会根据指定的dockerfile构建,得到镜像。但是除了build之外context路径毫无影响,其他的配置都是用命令行路径作为相对路径的参考。如果要设置默认的命令,可以参考

1
2
entrypoint: ["python", "/prompt-engine/src/main.py"]
command: ["-fpath", "projects/shanxuan", "-id", "1000shanxuan", "-cmd", "detect", "-o", "output/shanxuan.xlsx"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
services:
prompt-engine:
build:
context: ..
dockerfile: ./docker/Dockerfile
volumes:
- ../projects:/prompt-engine/projects # 挂载项目根目录的 src 目录到容器中
- ../output:/prompt-engine/output # 挂载项目根目录的 output 目录到容器中
env_file: ./.env
depends_on:
postgres:
condition: service_healthy
# command: python /prompt-engine/src/main.py -fpath projects/shanxuan -id 1000shanxuan -cmd detect -o output/shanxuan.xlsx
networks:
- prompt
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER_NAME}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB_NAME}"
OPENAI_API_BASE: "${OPENAI_API_BASE}"
OPENAI_API_KEY: "${OPENAI_API_KEY}"
VUL_MODEL_ID: "${VUL_MODEL_ID}"
AZURE_API_KEY: "${AZURE_API_KEY}"
AZURE_API_BASE: "${AZURE_API_BASE}"
AZURE_API_VERSION: "${AZURE_API_VERSION}"
AZURE_DEPLOYMENT_NAME: "${AZURE_DEPLOYMENT_NAME}"
AZURE_OR_OPENAI: "${AZURE_OR_OPENAI}"
BUSINESS_FLOW_COUNT: "${BUSINESS_FLOW_COUNT}"
SWITCH_FUNCTION_CODE: "${SWITCH_FUNCTION_CODE}"
SWITCH_BUSINESS_CODE: "${SWITCH_BUSINESS_CODE}"

postgres:
image: postgres:13
container_name: Prompt-Postgres
volumes:
- prompt_postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # 添加初始化 SQL 脚本
environment:
- POSTGRES_DB=${POSTGRES_DB_NAME}
- POSTGRES_USER=${POSTGRES_USER_NAME}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- ${POSTGRES_PORT}:5432 # 将容器内的5432端口映射到主机的5433端口
networks:
- prompt
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER_NAME} -d ${POSTGRES_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s

networks:
prompt:
driver: bridge

volumes:
prompt_postgres_data:
driver: local

常用命令

  • docker-compose up命令用于构建和启动所有在 docker-compose.yml 中定义的服务。

    作用:构建镜像(如果需要),并启动所有的服务。启动后,所有服务会在前台显示日志,直到你停止它们。

    -d:在后台启动容器(detached mode)。

    –build:强制重新构建镜像。

  • docker-compose down 删除所有容器,并且会删除由 Compose 创建的网络。

    –volumes:同时删除与容器关联的持久化数据卷。

  • docker-compose build 命令用于显式地构建所有服务的镜像,而不启动容器。

    –no-cache:不使用缓存,强制重新构建镜像。

    –pull:在构建时拉取最新的基础镜像。

  • docker-compose run 命令用于在一个特定的服务上运行一个命令,通常适用于需要交互式调试或运行一次性任务的场景。它只会启动你指定的服务,而不是整个 docker-compose.yml 中的所有服务。

    1
    docker-compose run --rm prompt-engine python /prompt-engine/src/main.py -fpath projects/data -id 1234 -cmd detect -o output/result.xlsx

    –rm:在命令执行完毕后自动删除容器,这对于命令行工具或一次性运行的任务非常有用。

    -d:后台运行容器,适合需要持久运行的服务(但不适合一次性任务)。

  • docker-compose start 和 docker-compose stop 这些命令用于启动和停止已经存在的服务容器。

  • docker-compose exec 命令允许你进入正在运行的容器,执行命令或者开启交互式终端。它类似于 docker exec,但在 Docker Compose 环境中使用。

    1
    docker-compose exec prompt-engine bash
  • docker-compose config 来查看 Compose 文件中定义的环境变量和配置。

最佳实践

常规加快构建

选择合适的基础镜像。基础镜像的选择对镜像大小和应用性能影响很大。通常建议选择精简版镜像,如 python:3.9-slim 或 alpine,以减小镜像大小,减少冗余的包依赖。镜像的东西越少,安全漏洞越少。

使用多阶段构建。这个参考前面的章节。

避免不必要的层。因为每一层都是有缓存的,层越多,复杂性极速上升。一般来说做法如下:

  • 合并要执行的shell命令,并且在执行完命令后删除不必要的缓存和依赖。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 不优化的写法:每个 RUN 都会创建一个新层
    RUN apt-get update
    RUN apt-get install -y curl
    RUN apt-get install -y git
    RUN rm -rf /var/lib/apt/lists/* # 清理缓存

    # 优化的写法:合并到一个 RUN 中,只创建一个层
    RUN apt-get update && \
    apt-get install -y curl git && \
    rm -rf /var/lib/apt/lists/*
  • Copy 指令越少越好,合并起来,而且只copy必要的文件。

    1
    2
    3
    4
    5
    6
    # 不优化的写法:每个 COPY 都会创建一个新层
    COPY file1.py /app/
    COPY file2.py /app/

    # 优化的写法:合并到一个 COPY 指令,减少层数
    COPY file1.py file2.py /app/
  • **将不常变动的依赖放在前面。**Docker 使用分层缓存,如果某一层没有变化,Docker 会重用缓存层。将依赖安装、基础配置等不常变化的指令放在 Dockerfile 的前面,而将代码的 COPY 操作放在后面,这样可以避免每次修改代码时重新安装依赖。

    1
    2
    3
    4
    5
    6
    # 先安装依赖(通常不变动)
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt

    # 再复制应用代码(经常变化)
    COPY . .
  • **使用 --no-install-recommends 安装软件包。**使用 apt-get install 时,默认会安装许多推荐包(recommends),但这些推荐包可能并不是必要的。通过 --no-install-recommends 选项,你可以避免安装不需要的软件包。

    1
    2
    3
    RUN apt-get update && \
    apt-get install -y --no-install-recommends curl git && \
    rm -rf /var/lib/apt/lists/*

使用BuildKit

额外缓存

BuildKit | Docker — 从入门到实践

手动增加缓存数据,除了volumes这种持久化的给挂载给容器的数据,还有一些用于加速构建的特殊缓存设置。这些缓存执行完命令就会卸载,因此不会增加容器大小。并且缓存还会自动的更新。

1
2
3
4
5
6
7
8
9
10
11
12
RUN npm i --registry=https://registry.npm.taobao.org \
&& rm -rf ~/.npm
RUN npm run build
# 改造成:
# syntax = docker/dockerfile:experimental

RUN --mount=type=cache,target=/app/node_modules,id=my_app_npm_module,sharing=locked \
--mount=type=cache,target=/root/.npm,id=npm_cache \
npm i --registry=https://registry.npm.taobao.org
RUN --mount=type=cache,target=/app/node_modules,id=my_app_npm_module,sharing=locked \
# --mount=type=cache,target=/app/dist,id=my_app_dist,sharing=locked \
npm run build

以上的改变,在执行每个RUN的时候,都会挂载缓存数据到target目录,然后再去执行。

在多阶段构建的时候,还有特殊优化,用于获取上一次构建产生的缓存,而不是直接COPY。这和使用id标识缓存的用途是不一样的,但是实际效果差不多。

1
RUN --mount=type=cache,target=/tmp/dist,from=builder,source=/app/dist \

传递多级构建的产物

类似这样的代码,多级构建的时候可以挂载上一级的产物。和cache的区别在于,它不会缓存。

1
2
3
# syntax = docker/dockerfile:experimental
RUN --mount=type=bind,from=php:alpine,source=/usr/local/bin/docker-php-entrypoint,target=/docker-php-entrypoint \
cat /docker-php-entrypoint

优化IO密集的临时操作

tmpfs 是一种基于内存的文件系统,数据存储在 RAM(随机存取存储器)中,而不是持久存储设备(如硬盘)。在 Linux 系统中,/tmp 目录通常使用 tmpfs。

那么构建过程中有很多的临时数据读写,而且不大(不超过物理内存的一半),执行完了就不需要,那么就比较时候。

1
2
3
RUN --mount=type=tmpfs,target=/tmpfs \
tar -xzf large-file.tar.gz -C /tmpfs && \
cp -r /tmpfs/some-folder /app/some-folder

一般的场景有:

  • 编译中间文件:如果在构建过程中有些中间文件(如编译产物)只需要暂时存储,且不需要在构建结束后保留,tmpfs 是很好的选择。Rust就很多的那种target中间文件。
  • 解压缩与临时数据处理:先把数据解压到 tmpfs,然后再使用。或者下载到tmpfs,再操作。

这也是许多脚本选择下载和解压的位置是 /tmp 的原因。

挂载密钥文件

这个的特点就是,不会缓存,执行RUN结束之后就消失了。它的特点就是,这个文件处于隐私文件系统,构建的时候安全性更高。

1
2
3
# syntax = docker/dockerfile:experimental
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
cat /root/.aws/credentials

构建的时候要提供密钥: docker build --secret id=aws,src=$HOME/.aws/credentials -t my-app .

1
2
3
4
5
6
7
8
9
services:
app:
build:
context: .
dockerfile: Dockerfile
secrets:
- aws_credentials # 用于构建阶段
secrets:
- aws_credentials # 用于运行时,默认在/run/secrets/