容器化最佳实践
镜像构建
- BuildKit 首先推荐
- Buildah
- docker build
编排
即大量容器场景下的管理
功能: 发现注册, 依赖关系, 调度, 监控, 扩缩容, 负载均衡等
推荐引擎
- docker-compose
- docker swarm
- Kubernetes
编排基础规则
- 理论上一个容器只跑一个进程, 杜绝当虚拟机使用
- 使用和宿主机一个体系的基础镜像
- 避免使用特权模式, 避免在容器内部使用 root 用户
应用层改造(建议)
- 无状态化: 任何持久化的数据都在容器之外
- 不变性: 不用更新组件, 只需要重新发布
- 声明式: 减少中途的人工干预过程
- 日志: 标准输出 + json
- 版本: 固定容器所使用的镜像版本
- 状态: 对外暴露应用程序的健康状态接口
镜像管理
分层结构
区分维度: 几乎不变的, 偶尔变的, 经常变的
- 基础层: 即选择 debin, centos, alpin 的基础镜像, 或者是空镜像
- runtime 运行时: java, php, python 等环境层
- 应用镜像: 包含业务应用全部的依赖包
- 业务层: 最经常变更的代码层
如果依赖变化较小, 例如 go 的静态编译, 则可以将 runtime 和依赖层 进行合并; 但至少也需要分2层;
大小
相关分析命令
|
|
影响大小的主要因素
- 基础镜像过大, 不需要的命令或组件较多
- 分层太多
- 因为 copy-on-write, 如果上层镜像对下层镜像文件进行了修改, 因为会拷贝原内容, 所以实际是占用双份空间
- 包管理器的缓存, 构建过程中的临时文件等
优化方式
- 使用更小的基础镜像层, alpine, distroless, scratch 等
- 分阶段构建(multi-stage builds), 将构建阶段的中间物丢弃;
- 减少层数: 使用 && 链接多条指令
单层优化
- 避免在本地保留安装缓存, 如避免 pip 的 ~/.cache/pip 缓存, 可以使用 –no-cache-dir
- 避免安装不使用的包文档部分, 如 dnf 的 –nodocs
- 避免缓存包管理器的索引文件等, 如 yum 仓库的 repo 索引文件缓存, 可以使用 yum makecache 清理
- 在本层的最后进行其它清理工作
|
|
合并多个镜像层
在最终生成镜像时将所有镜像层合并成一层 –squash; 只包含最后实际存在的文件系统,
在合并所有镜像层的过程中, 相当于禁用了 copy-on-write 特性
优点是如果上层对下层有大量文件修改, 则在合并时会丢弃下层的副本,
缺点是 镜像传输时的分层 就用不上了, 每次拉取都需要全量
|
|
压缩已有镜像为一层
将多个层合并为 1 个, 区别于 –squash 是对已经存在的镜像进行压缩;
原理为将其导出到 tarball, 然后将其重新导入为新镜像
针对已经存在的大镜像, 且里面有大量文件变更, 由不想重新构建镜像时;
|
|
注意: 只是导出了文件系统, 所有会丢失元数据, 原镜像的 ENV、WORKDIR、CMD、ENTRYPOINT 等配置会全部丢失;
可以在导入时附加新信息
|
|
复制文件的同时修改元信息
原来的方式:
|
|
会产生 2 个镜像层, 也就是文件会存在 2 份; 在拷贝的时候就修改元信息
|
|
需要开启 docker 的 buildkit 特性, 即编译命令前添加 DOCKER_BUILDKIT=1
缩小程序的依赖库文件
也就是单独拷贝一个大库包中的少量依赖库文件
|
|
java 环境优化
- 考虑 Jre
- 将 jdk 等运行环境放在宿主机上, 容器通过挂载目录的方式使用;
- 做一个 jdk 的存储卷
镜像命名规范
镜像全名 = 仓库地址 / 命名空间 / 镜像名:标签
docker.io/library/nginx:1.25.3-alpine
不可变标签: 能定位到代码版本
|
|
可变标签: 不能完全肯定当前具体的代码是哪个版本
|
|
latest 是在 没有指定 tag 时的默认值, 约定为最新版本
策略一: 语义化版本
Semantic Versioning
遵循 “主版本 . 次版本 . 修订版本 格式” v1.2.3
推荐的标签组合:
构建 v1.2.3 时, 同时打以下标签
|
|
策略二: Git 提交哈希
|
|
策略三: 日期时间
或时间戳
|
|
策略四: 环境标签
不推荐, 因为现代构建物使用规范更推荐: 相同构建物, 通过传入的环境变量在内部区分运行环境;
|
|
策略五: 组合标签(推荐)
|
|
推荐策略
- 正式发布: v1.1.1
- 每日构建: 20260214
- CI 构建: branch-abc123d
- 预发布环境: 1.2.3-beta.1
- 调试版本: v1.1.1-debug
tag + 镜像digest
虽然打包时设定了 tag,
但现在主流推荐在使用时通过镜像的 digest 来固定版本;
|
|
企业内的镜像分层命名
-
底层镜像:
ostype + os 版本号 + 类型 : tags 本次构建时间
centos_7.3_base:20180501
centos_7.3_tools:20180501 -
中间层: 详细
应用名称 + 项目名称 + 类型: tag 版本
主应用名称 + 项目名称: 主应用版本-本次构建时间1 2java-jdk-project1:1.8-20230316 redis-project2:6.7-20240316 -
业务层
模块 + 应用: tag 版本1 2user_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 格式传参
|
|
Distroless
Google 提供的只包含应用和运行时依赖的镜像, 不含 shell, 包管理工具
有 glibc, ca-certificates
|
|
Debian
|
|
slim 是完整镜像的配对版本, 只包含最小工具集, 相对完整版本减少了一些依赖包
alpine
优点: 小, 构建快, 安全
注意因为默认使用的 musl, 所以 py 官方的二进制 wheel 包并不能支持,
所以 pip 几乎只能下载源码包, 并新编译, 导致最终的镜像大小远大于其它发行版
java 也需要选用 alpine-glibc 版本
|
|
busybox
|
|
其它优化事项
- 尽量使用 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 元数据
|
|
构建时动态添加方式
|
|
|
|
Python 容器镜像最佳实践
建议使用官方的 python slim 镜像作为基础镜像
一般情况下, Python 镜像构建不需要使用 “多阶段构建”
理由如下:
- Python 没有像 Golang 一样, 可以把所有依赖打成一个单一的二进制包
- Python 也没有像 Java 一样, 可以在 JDK 上构建, 在 JRE 上运行
- Python 复杂而散落的依赖关系, 在 “多阶段构建” 时会增加复杂度
如果有一些特殊情况, 可以尝试使用 “多阶段构建” 压缩镜像体积:
- 构建阶段需要安装编译器
- Python 项目复杂, 用到了其他语言代码(如 C/C++/Rust)
|
|
使用 .dockerignore 排除无关文件
|
|
java容器镜像
事件
启动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 身份的初始化工作
|
|
docker-entrypoint.sh
|
|
注解:
如果使用 su 或 sudo 切换用户, 会有一些信号传递或 tty 的问题;
造成新进程的 pid 不是 1, 退出时就很困难;
sudo 启动命令时先创建 sudo 进程, 然后该进程作为父进程去创建子进程,
导致 1 号 PID 被 sudo 进程占据;
在缺失 tty 的场景也会出现;
|
|
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 这类工具来持久化和恢复;
|
|
多ip场景限制目的ip
主机具有多ip的主机时, 限制访问
- 容器创建的时候只绑定内网 ip, 即 port: 192.x.x.x:80:80
- 到容器所创建那个链去添加拒绝策略(问题是 docker 重启的时候, 又会清除这个链的规则)
容器内的iptables命令失败
原因, 早期的 iptables 所使用的内核模块有差异
|
|
存在两个版本的 iptables, 他们分别是 iptables-nft 和 iptables-legacy 这两个 iptables 使用了不同的内核模块;
alpine 默认使用的是 iptables-legacy 而 Centos8 默认使用的是 iptables-nft