容器化最佳实践
容器化最佳实践
镜像构建
- BuildKit 首先推荐
- Buildah
- docker build
编排
即大量容器场景下的管理
功能: 发现注册, 依赖关系, 调度, 监控, 扩缩容, 负载均衡等
推荐引擎
- docker-compose
- docker swarm
- Kubernetes
编排基础规则
- 理论上一个容器只跑一个进程, 杜绝当虚拟机使用
- 使用和宿主机一个体系的基础镜像
- 避免使用特权模式, 避免在容器内部使用 root 用户
应用层改造(建议)
- 无状态化: 任何持久化的数据都在容器之外
- 不变性: 不用更新组件, 只需要重新发布
- 声明式: 减少中途的人工干预过程
- 日志: 标准输出 + json
- 版本: 固定容器所使用的镜像版本
- 状态: 对外暴露应用程序的健康状态接口
镜像管理
分层结构
区分维度: 几乎不变的, 偶尔变的, 经常变的
- 基础层: 即选择 debin, centos, alpin 的基础镜像, 或者是空镜像
- runtime 运行时: java, php, python 等环境层
- 应用镜像: 包含业务应用全部的依赖包
- 业务层: 最经常变更的代码层
如果依赖变化较小, 例如 go 的静态编译, 则可以将 runtime 和依赖层 进行合并; 但至少也需要分2层;
大小
相关分析命令
docker history
dive # 第三方分析工具
dive golang:1.16 # 分析某个镜像, 可以看到每层的内容大小影响大小的主要因素
- 基础镜像过大, 不需要的命令或组件较多
- 分层太多
- 因为 copy-on-write, 如果上层镜像对下层镜像文件进行了修改, 因为会拷贝原内容, 所以实际是占用双份空间
- 包管理器的缓存, 构建过程中的临时文件等
优化方式
- 使用更小的基础镜像层, alpine, distroless, scratch 等
- 分阶段构建(multi-stage builds), 将构建阶段的中间物丢弃;
- 减少层数: 使用 && 链接多条指令
单层优化
- 避免在本地保留安装缓存, 如避免 pip 的 ~/.cache/pip 缓存, 可以使用 --no-cache-dir
- 避免安装不使用的包文档部分, 如 dnf 的 --nodocs
- 避免缓存包管理器的索引文件等, 如 yum 仓库的 repo 索引文件缓存, 可以使用 yum makecache 清理
- 在本层的最后进行其它清理工作
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.0java 环境优化
- 考虑 Jre
- 将 jdk 等运行环境放在宿主机上, 容器通过挂载目录的方式使用;
- 做一个 jdk 的存储卷
镜像命名规范
镜像全名 = 仓库地址 / 命名空间 / 镜像名:标签
docker.io/library/nginx:1.25.3-alpine
不可变标签: 能定位到代码版本
myapp:1.2.3
myapp:abc123def_hash可变标签: 不能完全肯定当前具体的代码是哪个版本
myapp:latest
myapp:stable
myapp:1.2latest 是在 没有指定 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企业内的镜像分层命名
-
底层镜像: ostype + os 版本号 + 类型 : tags 本次构建时间 centos_7.3_base:20180501 centos_7.3_tools:20180501
-
中间层: 详细 应用名称 + 项目名称 + 类型: tag 版本 主应用名称 + 项目名称: 主应用版本-本次构建时间
java-jdk-project1:1.8-20230316 redis-project2:6.7-20240316 -
业务层 模块 + 应用: tag 版本
user_login:1.0 user_login:1.0
调度与分发
充分利用分层缓存: 最上层的业务代码层尽量小; 例如 java打包时避免生成太大的 jar 包, 而是 mvn 打包时依赖放一层, 最终的业务代码在顶层; 当依赖库未变时, 传输镜像可以有效利用缓存层;
调试工具
调试工具是否要放入业务镜像中, 如果要放, 那么该放哪些? 如果不放怎么调试?
推荐做法
- 只放一个 curl 工具, 最常用的万能调试工具, 可以用于下载其它工具;
- 只放必要基础工具, nslookup, ps, telnet, curl 等
- 完全不放, 最小化镜像; 推荐方式
无工具时的调试方法
- 在宿主机进入 容器 ns, 将宿主机工具集带进去;
- 新建一个容器附加到pod上去;
- 临时使用胖基础镜像(附带很多工具)打包业务镜像, 进行线上调试, 例如 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:debugDebian
Bullseye # 版本 11, 2021 年发布的版本
buster # Debian 10
stretch # Debian 9
jessie # Debian 8slim 是完整镜像的配对版本, 只包含最小工具集, 相对完整版本减少了一些依赖包
alpine
优点: 小, 构建快, 安全
注意因为默认使用的 musl, 所以 py 官方的二进制 wheel 包并不能支持, 所以 pip 几乎只能下载源码包, 并新编译, 导致最终的镜像大小远大于其它发行版
java 也需要选用 alpine-glibc 版本
docker pull alpine:3.15.0
docker pull python:3.9.1-alpine3.12busybox
busybox:1.35.0 # 默认是静态编译不带 libc 库
busybox:glibc # 包含 glibc 的版本
busybox:uclibc # 静态编译
busybox:musl其它优化事项
- 尽量使用 FROM 语句复用合适的上游镜像
- 在 FROM 指令中使用不可变标签 tag 或 digest
- 避免运行多个进程
- 在封装的启动脚本中使用 EXEC 指令, 避免主程序接收不到 外部的 TERM 或 SIGKILL 信号
- 构建基础镜像时可以附加代理信息加速国内依赖源下载
- 按正确的顺序放置指令: 不常变的在前面, 经常变的在后面, 充分利用构建缓存
- 使用 .dockerignore 排除无关文件
- 核心配置外置, 如密码密钥, token 等应该在外部
- 持久化数据放置在卷上
- 避免使用 root 运行程序, 如果发生逃逸, 宿主机上也是 root 权限
- 避免将文件放入 /tmp 中
- 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}}' | jqPython 容器镜像最佳实践
建议使用官方的 python slim 镜像作为基础镜像
一般情况下, Python 镜像构建不需要使用 "多阶段构建"
理由如下:
- Python 没有像 Golang 一样, 可以把所有依赖打成一个单一的二进制包
- Python 也没有像 Java 一样, 可以在 JDK 上构建, 在 JRE 上运行
- Python 复杂而散落的依赖关系, 在 "多阶段构建" 时会增加复杂度
如果有一些特殊情况, 可以尝试使用 "多阶段构建" 压缩镜像体积:
- 构建阶段需要安装编译器
- 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.mdjava容器镜像
事件
启动pid1进程
1号进程的特殊性:
- 它接收外部传来的停止信号和其它信号
方式
- ENTRYPOINT 或 CMD 或 docker-entrypoint.sh
- exec 保障 pid1 的转移;
- tini 或 dump-init
- bash -c 注意普通 bash 不能用于 pid1 的原因是因为 bash 不会转发信号给子进程; bash -c 可以转
例子 redis
- 创建了普通用户
- 定义入口点为 docker-entrypoint.sh 脚本
- 如果 CMD 是 redis-server 则以普通用户启动进程
- 如果 CMD 是其它, 则以 root 用户启动进程
- 在 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 实践
- 创建 group 和普通账号, 不要使用 root 账号启动进程
- 如果普通账号权限不够用, 建议使用 gosu 来提升权限, 而不是 sudo
- entrypoint.sh 脚本在执行的时候也是个进程, 启动业务进程的时候, 在命令前面加上 exec, 这样新的进程就会取代 entrypoint.sh 的进程, 得到 1 号 PID
防火墙问题
最好就不要启用 iptables 或 ufw, 因为2个应用接管 防火墙策略+系统自己的防火墙策略 就容易冲突;
最佳实践
- 不使用主机防火墙, 而使用前置的硬件或云防火墙或安全组;
- 必要情况下手工维护 DOCKER-USER 链策略;
- 使用 ufw-docker 这类插件性质的组件, 对策略进行转换; 这是一个后台服务, 会自动进行转换
原因:
docker 桥接模式的端口映射本质上是使用 iptable 的 nat 实现的; 当 docker 和 系统级别 iptable 策略共同使用时就难管理;
例如: iptables-restore < /etc/iptables.rules 会覆盖 docker 自有链的规则, 导致容器访问失败; 重启 iptable 服务时, 也需要一并重启 dockerd;
现象: INPUT 链不能限制外部访问某个容器端口; 原因: docker 容器监听的端口所使用 DNAT 策略优先级高于 INPUT 链; 优先到了 docker 链;
入数据流
- nat 表的 PREROUTING 链, 进行 DNAT 策略, 目标地址被修改为容器 ip 和端口
- 进入 filter 表的 FORWARD 链, 转发到容器网络栈中
出数据流
- 经过容器的网络栈, 然后进入宿主机的 mangle 表的 POSTROUTING 链,
- 接着是 nat 表的 POSTROUTING 链进行 SNAT
- 最后经过 filter 表的 OUTPUT 链离开宿主机
对于本地绑定的服务
- 进入 filter 表的 FORWARD 链时, 先进入的 DOCKER-USER 子链(空的, 留给用户自定义规则)
- 然后进入 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的主机时, 限制访问
- 容器创建的时候只绑定内网 ip, 即 port: 192.x.x.x:80:80
- 到容器所创建那个链去添加拒绝策略(问题是 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