跳至内容
容器化最佳实践

容器化最佳实践

容器化最佳实践

镜像构建

  1. BuildKit 首先推荐
  2. Buildah
  3. docker build

编排

即大量容器场景下的管理

功能: 发现注册, 依赖关系, 调度, 监控, 扩缩容, 负载均衡等

推荐引擎

  1. docker-compose
  2. docker swarm
  3. Kubernetes

编排基础规则

  1. 理论上一个容器只跑一个进程, 杜绝当虚拟机使用
  2. 使用和宿主机一个体系的基础镜像
  3. 避免使用特权模式, 避免在容器内部使用 root 用户

应用层改造(建议)

  1. 无状态化: 任何持久化的数据都在容器之外
  2. 不变性: 不用更新组件, 只需要重新发布
  3. 声明式: 减少中途的人工干预过程
  4. 日志: 标准输出 + json
  5. 版本: 固定容器所使用的镜像版本
  6. 状态: 对外暴露应用程序的健康状态接口

镜像管理

分层结构

区分维度: 几乎不变的, 偶尔变的, 经常变的

  1. 基础层: 即选择 debin, centos, alpin 的基础镜像, 或者是空镜像
  2. runtime 运行时: java, php, python 等环境层
  3. 应用镜像: 包含业务应用全部的依赖包
  4. 业务层: 最经常变更的代码层

如果依赖变化较小, 例如 go 的静态编译, 则可以将 runtime 和依赖层 进行合并; 但至少也需要分2层;

大小

相关分析命令

docker history
dive                    # 第三方分析工具
dive golang:1.16        # 分析某个镜像, 可以看到每层的内容大小

影响大小的主要因素

  1. 基础镜像过大, 不需要的命令或组件较多
  2. 分层太多
  3. 因为 copy-on-write, 如果上层镜像对下层镜像文件进行了修改, 因为会拷贝原内容, 所以实际是占用双份空间
  4. 包管理器的缓存, 构建过程中的临时文件等

优化方式

  1. 使用更小的基础镜像层, alpine, distroless, scratch 等
  2. 分阶段构建(multi-stage builds), 将构建阶段的中间物丢弃;
  3. 减少层数: 使用 && 链接多条指令

单层优化

  1. 避免在本地保留安装缓存, 如避免 pip 的 ~/.cache/pip 缓存, 可以使用 --no-cache-dir
  2. 避免安装不使用的包文档部分, 如 dnf 的 --nodocs
  3. 避免缓存包管理器的索引文件等, 如 yum 仓库的 repo 索引文件缓存, 可以使用 yum makecache 清理
  4. 在本层的最后进行其它清理工作
RUN dnf install -y --nodocs <PACKAGES> && dnf clean all && rm -rf /var/cache/dnf
RUN apt-get update && apt-get install -y <PACKAGES> && rm -rf /var/lib/apt/lists/*

合并多个镜像层

在最终生成镜像时将所有镜像层合并成一层 --squash; 只包含最后实际存在的文件系统, 在合并所有镜像层的过程中, 相当于禁用了 copy-on-write 特性 优点是如果上层对下层有大量文件修改, 则在合并时会丢弃下层的副本, 缺点是 镜像传输时的分层 就用不上了, 每次拉取都需要全量

docker build -t squash-image --squash -f Dockerfile .

压缩已有镜像为一层

将多个层合并为 1 个, 区别于 --squash 是对已经存在的镜像进行压缩; 原理为将其导出到 tarball, 然后将其重新导入为新镜像

针对已经存在的大镜像, 且里面有大量文件变更, 由不想重新构建镜像时;

    docker build -t my-app .
    docker container create --name temp-container my-app
    docker export temp-container | docker import - my-app:squashed
    docker container rm temp-container

注意: 只是导出了文件系统, 所有会丢失元数据, 原镜像的 ENV、WORKDIR、CMD、ENTRYPOINT 等配置会全部丢失; 可以在导入时附加新信息

docker import --change 'ENV PATH=/usr/local/bin:$PATH' \
             --change 'WORKDIR /app' \
             --change 'CMD ["node", "server.js"]' \
             my-app-squashed.tar my-app:squashed

复制文件的同时修改元信息

原来的方式:

COPY output/hello /usr/bin/hello
RUN chmod +x /usr/bin/hello && chown normal:normal /usr/bin/hello

会产生 2 个镜像层, 也就是文件会存在 2 份; 在拷贝的时候就修改元信息

COPY --chmod=755 --chown=normal:normal output/hello /usr/bin/hello

需要开启 docker 的 buildkit 特性, 即编译命令前添加 DOCKER_BUILDKIT=1

缩小程序的依赖库文件 也就是单独拷贝一个大库包中的少量依赖库文件

# 查看在一个容器中运行执行程序需要哪些静态库
docker exec -it golargeapp /usr/bin/ldd hello

# 将需要的静态库复制出来
docker cp -L golargeapp:/lib/x86_64-linux-gnu/libpthread.so.0 ./build

# 在 dockerfile 中添加这些库文件
ADD libpthread.so.0 /lib/x86_64-linux-gnu/libpthread.so.0

java 环境优化

  1. 考虑 Jre
  2. 将 jdk 等运行环境放在宿主机上, 容器通过挂载目录的方式使用;
  3. 做一个 jdk 的存储卷

镜像命名规范

镜像全名 = 仓库地址 / 命名空间 / 镜像名:标签

docker.io/library/nginx:1.25.3-alpine

不可变标签: 能定位到代码版本

myapp:1.2.3
myapp:abc123def_hash

可变标签: 不能完全肯定当前具体的代码是哪个版本

myapp:latest
myapp:stable
myapp:1.2

latest 是在 没有指定 tag 时的默认值, 约定为最新版本

策略一: 语义化版本

Semantic Versioning

遵循 "主版本 . 次版本 . 修订版本 格式" v1.2.3

推荐的标签组合:

构建 v1.2.3 时, 同时打以下标签

myapp:1.2.3         # 精确版本
myapp:1.2           # 次版本(指向最新 1.2.x)
myapp:1             # 主版本(指向最新 1.x.x)
myapp:latest        # 最新版

策略二: Git 提交哈希

myapp:abc123def             # Git commit SHA 前 7-8 位
myapp:main-abc123           # 分支 + 提交

策略三: 日期时间

或时间戳

myapp:20260214              # 日期
myapp:20260214-103022       # 日期时间
myapp:20260214103022        # 日期时间
myapp:2026.02.14            # 点分隔

策略四: 环境标签

不推荐, 因为现代构建物使用规范更推荐: 相同构建物, 通过传入的环境变量在内部区分运行环境;

myapp:dev           # 开发环境
myapp:uat           # 预发环境
myapp:prod          # 生产环境

策略五: 组合标签(推荐)

完整信息标签

myapp:1.2.3-abc123def-20260214
myapp:1.2.3-20260214

分解

myapp:1.2.3         # 语义版本
myapp:abc123def     # Git SHA
myapp:main-latest   # 分支最新

推荐策略

  • 正式发布: v1.1.1
  • 每日构建: 20260214
  • CI 构建: branch-abc123d
  • 预发布环境: 1.2.3-beta.1
  • 调试版本: v1.1.1-debug

tag + 镜像digest

虽然打包时设定了 tag, 但现在主流推荐在使用时通过镜像的 digest 来固定版本;

harbor.chenwx.top/ghcr.io/xxxx/xxx:v1.6.1@sha256:99aa3350b6d06fd99c3f10b00380843fc1bdaeec5ec15e91dbbaaf7bb2d8313f

企业内的镜像分层命名

  1. 底层镜像: ostype + os 版本号 + 类型 : tags 本次构建时间 centos_7.3_base:20180501 centos_7.3_tools:20180501

  2. 中间层: 详细 应用名称 + 项目名称 + 类型: tag 版本 主应用名称 + 项目名称: 主应用版本-本次构建时间

    java-jdk-project1:1.8-20230316
    redis-project2:6.7-20240316
  3. 业务层 模块 + 应用: tag 版本

    user_login:1.0
    user_login:1.0

调度与分发

充分利用分层缓存: 最上层的业务代码层尽量小; 例如 java打包时避免生成太大的 jar 包, 而是 mvn 打包时依赖放一层, 最终的业务代码在顶层; 当依赖库未变时, 传输镜像可以有效利用缓存层;

调试工具

调试工具是否要放入业务镜像中, 如果要放, 那么该放哪些? 如果不放怎么调试?

推荐做法

  1. 只放一个 curl 工具, 最常用的万能调试工具, 可以用于下载其它工具;
  2. 只放必要基础工具, nslookup, ps, telnet, curl 等
  3. 完全不放, 最小化镜像; 推荐方式

无工具时的调试方法

  1. 在宿主机进入 容器 ns, 将宿主机工具集带进去;
  2. 新建一个容器附加到pod上去;
  3. 临时使用胖基础镜像(附带很多工具)打包业务镜像, 进行线上调试, 例如 app:v1.2-debug

推荐的基础镜像

scratch

空镜像, 没有任何内容, 大小为 0, 只能运行静态编译的二进制文件

在使用 CMD/RUN 语句时, 不使用使用字符串, 只能使用 json 格式传参

# 会失败, 因为没有 shell 来运行;
CMD ./hello

# 成功, docker 会直接运行程序, 不会使用 shell 来运行程序
CMD ["./hello"]

Distroless

Google 提供的只包含应用和运行时依赖的镜像, 不含 shell, 包管理工具 有 glibc, ca-certificates

    FROM gcr.io/distroless/static-debian12

    # debug 镜像, 包含 busybox shell
    FROM gcr.io/distroless/static-debian12:debug

Debian

    Bullseye        # 版本 11, 2021 年发布的版本
    buster          # Debian 10
    stretch         # Debian 9
    jessie          # Debian 8

slim 是完整镜像的配对版本, 只包含最小工具集, 相对完整版本减少了一些依赖包

alpine

优点: 小, 构建快, 安全

注意因为默认使用的 musl, 所以 py 官方的二进制 wheel 包并不能支持, 所以 pip 几乎只能下载源码包, 并新编译, 导致最终的镜像大小远大于其它发行版

java 也需要选用 alpine-glibc 版本

docker pull alpine:3.15.0
docker pull python:3.9.1-alpine3.12

busybox

busybox:1.35.0      # 默认是静态编译不带 libc 库
busybox:glibc       # 包含 glibc 的版本
busybox:uclibc      # 静态编译
busybox:musl

其它优化事项

  1. 尽量使用 FROM 语句复用合适的上游镜像
  2. 在 FROM 指令中使用不可变标签 tag 或 digest
  3. 避免运行多个进程
  4. 在封装的启动脚本中使用 EXEC 指令, 避免主程序接收不到 外部的 TERM 或 SIGKILL 信号
  5. 构建基础镜像时可以附加代理信息加速国内依赖源下载
  6. 按正确的顺序放置指令: 不常变的在前面, 经常变的在后面, 充分利用构建缓存
  7. 使用 .dockerignore 排除无关文件
  8. 核心配置外置, 如密码密钥, token 等应该在外部
  9. 持久化数据放置在卷上
  10. 避免使用 root 运行程序, 如果发生逃逸, 宿主机上也是 root 权限
  11. 避免将文件放入 /tmp 中
  12. EXPOSE 标记重要端口

避免将文件放入 /tmp 中

在宿主机上的 /tmp 会通过 tmpfs 文件系统存储在内存中,但 docker 上的 /tmp 默认是在磁盘上的, 所以可能有性能问题,正确的做法是放置在 /dev/shm 路径下

EXPOSE 标记重要端口

EXPOSE 虽然只起到标记作用, 不强制要求, 但是有利于管理和排查; 会暴露在 docker ps 输出下, docker inspect 返回的镜像的元数据中也会显示暴露的端口 当将一个容器链接到另一个容器时, 会链接暴露的端口;

LABEL 元数据


LABEL maintainer="cuikaidong@foxmail.com"

LABEL org.opencontainers.image.title="MyApp"
LABEL org.opencontainers.image.description="My awesome application"
LABEL org.opencontainers.image.version="1.2.3"
LABEL org.opencontainers.image.created="2026-02-14T10:30:00Z"

# github 中推送的镜像默认是属于 用户或组织 的, 需要手动关联到代码仓库
# 如果设置这个 label 则可以在推送时自动关联到代码仓库
LABEL org.opencontainers.image.source=https://github.com/your-username/my-app

构建时动态添加方式

ARG BUILD_DATE
ARG GIT_COMMIT
ARG VERSION

LABE Lorg.opencontainers.image.created=$BUILD_DATE
LABE Lorg.opencontainers.image.revision=$GIT_COMMIT
LABE Lorg.opencontainers.image.version=$VERSION


dockerbuild\
--build-arg BUILD_DATE=$(date-u+"%Y-%m-%dT%H:%M:%SZ")\
--build-arg GIT_COMMIT=$(gitrev-parseHEAD)\
--build-arg VERSION=1.2.3\
-tmyapp:1.2.3.
# 查看元数据
dockerinspectmyapp:1.2.3--format'{{json .Config.Labels}}' | jq

Python 容器镜像最佳实践

建议使用官方的 python slim 镜像作为基础镜像

一般情况下, Python 镜像构建不需要使用 "多阶段构建"

理由如下:

  1. Python 没有像 Golang 一样, 可以把所有依赖打成一个单一的二进制包
  2. Python 也没有像 Java 一样, 可以在 JDK 上构建, 在 JRE 上运行
  3. Python 复杂而散落的依赖关系, 在 "多阶段构建" 时会增加复杂度

如果有一些特殊情况, 可以尝试使用 "多阶段构建" 压缩镜像体积:

  1. 构建阶段需要安装编译器
  2. Python 项目复杂, 用到了其他语言代码(如 C/C++/Rust)
# 基于官方基础镜像
FROM python:3.10-slim

LABEL maintainer="cuikaidong@foxmail.com"

EXPOSE 8000

# 设置工作目录
WORKDIR /app

# 设置环境变量

# 防止 python 将 pyc 文件写入硬盘
ENV PYTHONDONTWRITEBYTECODE 1

# 防止 python 缓冲 stdout 和 stderr
ENV PYTHONUNBUFFERED 1

# install dependencies
COPY ./requirements.txt .
# RUN pip install -r requirements.txt
# 使用 --no-cache-dir 关闭缓存
RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt

# copy project
COPY . /app

# 使用非 root 用户运行容器进程
RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser

# run gunicorn
CMD ["uvicorn", "shortener_app.main:app", "--host", "0.0.0.0"]

使用 .dockerignore 排除无关文件

**/__pycache__
**/*venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
*.db
.python-version
LICENSE
README.md

java容器镜像

事件

启动pid1进程

1号进程的特殊性:

  • 它接收外部传来的停止信号和其它信号

方式

  1. ENTRYPOINT 或 CMD 或 docker-entrypoint.sh
  2. exec 保障 pid1 的转移;
  3. tini 或 dump-init
  4. bash -c 注意普通 bash 不能用于 pid1 的原因是因为 bash 不会转发信号给子进程; bash -c 可以转

例子 redis

  1. 创建了普通用户
  2. 定义入口点为 docker-entrypoint.sh 脚本
  3. 如果 CMD 是 redis-server 则以普通用户启动进程
  4. 如果 CMD 是其它, 则以 root 用户启动进程
  5. 在 docker-entrypoint.sh 脚本内还可以做其它需要 root 身份的初始化工作
FROM alpine:3.4
RUN addgroup -S redis && adduser -S -G redis redis
ENTRYPOINT ["docker-entrypoint.sh"]

EXPOSE 6379
CMD [ "redis-server" ]

docker-entrypoint.sh

#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
	find . \! -user redis -exec chown redis '{}' +
	exec gosu redis "$0" "$@"
fi

exec "$@"

注解:

如果使用 su 或 sudo 切换用户, 会有一些信号传递或 tty 的问题; 造成新进程的 pid 不是 1, 退出时就很困难; sudo 启动命令时先创建 sudo 进程, 然后该进程作为父进程去创建子进程, 导致 1 号 PID 被 sudo 进程占据; 在缺失 tty 的场景也会出现;

exec gosu redis "$0" "$@"

exec 表示替换当前 shell, 保证了新进程的 pid 为 1

gosu 表示切换用户, 替代 su 和 sudo, gosu 启动命令时只会启动一个进程

entrypoint 实践

  1. 创建 group 和普通账号, 不要使用 root 账号启动进程
  2. 如果普通账号权限不够用, 建议使用 gosu 来提升权限, 而不是 sudo
  3. entrypoint.sh 脚本在执行的时候也是个进程, 启动业务进程的时候, 在命令前面加上 exec, 这样新的进程就会取代 entrypoint.sh 的进程, 得到 1 号 PID

防火墙问题

最好就不要启用 iptables 或 ufw, 因为2个应用接管 防火墙策略+系统自己的防火墙策略 就容易冲突;

最佳实践

  1. 不使用主机防火墙, 而使用前置的硬件或云防火墙或安全组;
  2. 必要情况下手工维护 DOCKER-USER 链策略;
  3. 使用 ufw-docker 这类插件性质的组件, 对策略进行转换; 这是一个后台服务, 会自动进行转换

原因:

docker 桥接模式的端口映射本质上是使用 iptable 的 nat 实现的; 当 docker 和 系统级别 iptable 策略共同使用时就难管理;

例如: iptables-restore < /etc/iptables.rules 会覆盖 docker 自有链的规则, 导致容器访问失败; 重启 iptable 服务时, 也需要一并重启 dockerd;

现象: INPUT 链不能限制外部访问某个容器端口; 原因: docker 容器监听的端口所使用 DNAT 策略优先级高于 INPUT 链; 优先到了 docker 链;

入数据流

  1. nat 表的 PREROUTING 链, 进行 DNAT 策略, 目标地址被修改为容器 ip 和端口
  2. 进入 filter 表的 FORWARD 链, 转发到容器网络栈中

出数据流

  1. 经过容器的网络栈, 然后进入宿主机的 mangle 表的 POSTROUTING 链,
  2. 接着是 nat 表的 POSTROUTING 链进行 SNAT
  3. 最后经过 filter 表的 OUTPUT 链离开宿主机

对于本地绑定的服务

  1. 进入 filter 表的 FORWARD 链时, 先进入的 DOCKER-USER 子链(空的, 留给用户自定义规则)
  2. 然后进入 DOCKER-ISOLATION-STAGE-1 链, 然后进入 DOCKER 链

当使用 宿主机 ip+ 映射端口 或 127.0.0.1+ 映射端口 的方式进行访问容器时, 流量被视为本地流量, 不会经过 FORWARD 链, 也就不会被 FORWARD 下的几个 docker 子链所处理;

DOCKER-USER

必要情况下在 DOCKER-USER 链配置自定义的规则;

DOCKER-USER 链的规则在重启后默认不会保留, docker 或 ipatble 重启后都会丢失;

需要用外部工具, 如 iptables-persistent, ufw-docker 这类工具来持久化和恢复;


iptables -L -n -v --line-numbers    # 查看策略

iptables -L DOCKER-USER -n --line-numbers

# 拒绝某ip访问
iptables -I DOCKER-USER -s 192.168.0.254 -j DROP

iptables -D DOCKER-USER -j RETURN   # 删除默认规则
iptables -F DOCKER-USER             # 清除规则


# 插入拒绝规则, 看具体在哪行, 是存在问题的;
iptables -I DOCKER-USER -j DROP

# 设定默认规则, 需要针对网卡, 否则似乎会影响 vet 子网卡, 导致后续规则不生效
iptables -I DOCKER-USER -i enp6s18 -j DROP

# 设定允许已经建立的连接进行通讯, 保障出去连接的回包正常
iptables -A DOCKER-USER -m state --state RELATED,ESTABLISHED -j RETURN

# 允许规则; 因为是 FORWARD 类的链, 所以直接 ACCEPT 或 RETURN 都是可以的
iptables -I DOCKER-USER -s 192.168.0.254/32 -j ACCEPT
iptables -I DOCKER-USER -s 10.2.2.0/24 -j RETURN

#===================================================================

# 测试
docker run --rm -it alpine:3.20.3 ping 8.8.8.8

# 示例
iptables -L DOCKER-USER -n --line-numbers
iptables -I DOCKER-USER 7 -m state --state RELATED,ESTABLISHED -j RETURN
iptables -I DOCKER-USER -s 171.102.145.66/32 -j RETURN
iptables -I DOCKER-USER 8 -s 192.168.86.0/24 -j RETURN
iptables -I DOCKER-USER 8 -i eno2 -j DROP

多ip场景限制目的ip

主机具有多ip的主机时, 限制访问

  1. 容器创建的时候只绑定内网 ip, 即 port: 192.x.x.x:80:80
  2. 到容器所创建那个链去添加拒绝策略(问题是 docker 重启的时候, 又会清除这个链的规则)

容器内的iptables命令失败

原因, 早期的 iptables 所使用的内核模块有差异

modprobe iptable_filter
modprobe iptable_nat

iptables-nft -L -n

存在两个版本的 iptables, 他们分别是 iptables-nft 和 iptables-legacy 这两个 iptables 使用了不同的内核模块; alpine 默认使用的是 iptables-legacy 而 Centos8 默认使用的是 iptables-nft

最后更新于