1. 为什么选 CentOS Stream 9 装 Docker:先说清楚这张牌
在 CentOS Stream 9 上装 Docker,这事儿看着简单,实际动手时最容易翻车的恰恰是那些没人提的小坑——系统自带的 podman 跟 Docker 抢端口、SELinux 把你的数据卷挡在门外、cgroup v2 和 systemd 对不上导致服务根本拉不起来。这篇笔记就围绕 Docker 在 CentOS Stream 9 上的完整安装配置流程,我尽量把每一步的“为什么”讲清楚,再给一套可以直接照抄的配置方案。
1.1 Stream 9 的身份:不是“最稳的正式版”,但适合跑容器
先说选型。CentOS Stream 9 是 RHEL 9 的上游持续交付版本,你可以理解为“RHEL 9 的预览版/滚动版”,小版本会持续更新,生命周期跟 RHEL 9 对齐到 2027 年。对这个定位,很多人的第一反应是“不稳定,不敢用”,但我的实际体验恰恰相反:作为容器宿主系统,Stream 9 的内核和软件包比传统 CentOS 7/8 新得多,对新硬件、新内核特性、新容器运行时兼容性好,反而是很舒服的选择。
对比一下 CentOS 7,差别就很明显了。CentOS 7 默认为内核 3.10,cgroup 还是 v1,Docker 用老一套的配置跑没问题,但碰到新特性的容器镜像就尴尬;CentOS Stream 9 默认内核 5.14,cgroup 已经是 v2,overlay2 是默认存储驱动,SELinux 默认 Enforcing,nftables 替代了 iptables。这些变化不会让 Docker“跑不起来”,但会让很多从 CentOS 7 时代带过来的习惯失效。你需要知道新系统是怎么组织这些底层机制的,否则出了问题完全不知道往哪个方向查。
还有一个关键点:CentOS Stream 9 的软件仓库里自带 docker 包吗?自带,但版本偏旧,而且不带 buildx、compose 这些新组件。后文我会详细对比,但结论先告诉你:装 Docker 请绕开系统自带的包,直接上 Docker 官方仓库。
1.2 内核、cgroup 与防火墙的变化,直接影响 Docker 怎么配
cgroup v2 是 Stream 9 上 Docker 配置最大的变化点,这里展开说一下。cgroup v1 里,cpu、memory、io 这些控制器的挂载点是分开的,各管各的;cgroup v2 统一成一个层级,所有控制器挂在同一个 root cgroup 下。这个变化对 Docker 的影响是:Docker 守护进程要决定自己用哪种 cgroup 驱动来管理容器资源。
在 cgroup v2 系统上,如果 Docker 还用默认的 cgroupfs 驱动,会和 systemd 的 cgroup 管理打架,最典型的症状是容器启动慢、资源限制不生效、甚至报“Failed to start docker.service”之外的莫名错误。正确做法是在 daemon.json 里显式声明:
json复制{
"exec-opts": ["native.cgroupdriver=systemd"]
}
让 Docker 用 systemd 作为 cgroup driver,和系统保持一致。这个配置在 CentOS Stream 9 上不是可选项,是必选项。
另一个变化是防火墙体系。Stream 9 默认用 nftables 作为 netfilter 框架,firewalld 也是基于 nftables 的。Docker 自己会写 iptables 规则来管理容器网络(NAT、端口映射、隔离),它通过 iptables-nft 兼容层操作 nftables,大部分情况下没问题。但在你没装 firewalld 或者手动清过规则的环境里,Docker 的网络链可能被搞乱,常见表现是容器端口映射不通、容器之间互相 ping 不通。
1.3 官方仓库、系统仓库,为什么我只推荐前者
CentOS Stream 9 的 AppStream 仓库里确实有 docker 包,执行 dnf install docker 也能装出一个能用的 Docker。但我不推荐,原因有三个:
第一,版本严重滞后。系统自带 docker 包的主版本号跟 Docker 官方当前版本能差出好几代,很多新镜像、新特性用不了。第二,缺组件。系统包不带 docker-buildx-plugin,不带 docker-compose-plugin,你得额外折腾;Docker 官方仓库把 docker-ce、docker-ce-cli、containerd.io、buildx、compose 打包成一个完整的组,一次装齐。第三,升级节奏不同。Docker 的安全更新和补丁发布很频繁,官方仓库同步快,系统仓库往往几个月不动一次。
所以下面整个安装流程,我全部基于 Docker 官方 yum 仓库。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 安装前先把系统收拾干净:内核、SELinux、冲突包
2.1 动手前先做系统体检
这一节的内容看起来像走流程,但可以帮你免掉后面一半的排错时间。我每次在新的 Stream 9 机器上装 Docker,都会先跑这几条命令,确认一下系统底子:
bash复制# 查看内核版本
uname -r
# 查看系统版本
cat /etc/os-release
# 查看 SELinux 状态
sestatus
# 查看防火墙状态
systemctl status firewalld
在 Stream 9 上,内核版本正常应该是 5.14 或更高;SELinux 默认 Enforcing;firewalld 默认是开着的。这三个信息拿到手,你心里就有数了。
这里重点说 SELinux。很多帖子教人“setenforce 0”关掉 SELinux 再装 Docker,我不反对你在本地测试环境这么干,但如果这是生产机器,请务必保留 Enforcing。SELinux 和 Docker 的配合其实很好,前提是认准三件事:Docker 的数据目录要有正确的 SELinux 上下文;挂载数据卷时给容器加上 :z 或 :Z 标签;用 setsebool -P container_manage_cgroup on 放行容器管理 cgroup。这三件事都做好了,SELinux 根本不是拦路虎,反而是帮你隔离风险的屏障。
2.2 清理系统里自带的容器运行时
Stream 9 默认预装的东西里有三个容易跟 Docker 打架:podman、buildah、skopeo。它们跟 Docker 不是一个运行时,但会抢同一个 netfilter 链,也会占用 /var/lib/containers 这类目录。如果你机器上这些包还留着,我建议直接卸掉。
bash复制# 查一下有哪些相关包
rpm -qa | grep -E "podman|buildah|skopeo"
# 卸掉
dnf remove -y podman buildah skopeo
注意,这套操作不会影响 Docker 后续安装,因为 Docker 官方 repo 里的 containerd.io 是独立安装的,不依赖这些包。如果你在同一个系统里既装 Docker 又留 podman,短期看不出问题,但某次重启后外网访问容器端口失败、或者 firewalld reload 之后网络全断,排查起来会非常痛苦。
2.3 把基础工具包补齐
接下来装几个工具,其中 yum-utils 是为了用 yum-config-manager 命令,后面配置 Docker 官方 yum 源会用到。
bash复制dnf install -y yum-utils
另外,传统做法里安装 device-mapper-persistent-data 和 lvm2 是为了让 Docker 能使用 devicemapper 存储驱动。说实话,在 Stream 9 上用 overlay2 就够了,这两个包不是必需,但装上也不碍事,而且某些自动化脚本会检查它们,一次性装齐更省心。如果你对系统的干净度有洁癖,也可以只装 yum-utils,跳过这两个包。
2.4 防火墙的初始状态处理
firewalld 开着不会阻止 Docker 的安装和启动,但它会在 Docker 启动容器并添加 iptables 规则时产生影响。这里先记住一个原则:装完 Docker 后,容器端口映射主要靠 Docker 自己的 iptables 规则完成,firewalld 只管宿主机自身的访问控制。如果你不想让防火墙参与太多,一个常见做法是保持 firewalld 开启,但为特定端口放行:
bash复制# 放行某个端口(示例:容器映射到宿主机的 8080)
firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --reload
但要注意,容器映射端口后,外网访问本来就会经过 DNAT,所以这个操作通常在“宿主机自身也要直接暴露端口”时才需要。更常见的生产场景是让 Docker 管理全部流量,firewalld 不要过多介入,否则会出现规则冲突。我建议先保持默认,安装完成启动容器后,再根据实际访问情况决定要不要动防火墙。
3. 一步步装好 Docker 并在启动前调好核心参数
3.1 配置 Docker 官方 yum 源
准备工作做完,开始正式安装。这里直接采用官方 repo 文件方式,不依赖 yum-config-manager(虽然我们装了它,但写文件更直观):
bash复制vi /etc/yum.repos.d/docker-ce.repo
写入以下内容:
ini复制[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/centos/$releasever/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/centos/gpg
这里有个坑我必须重点讲:官方 repo 里的 baseurl 用了 $releasever,在 CentOS 7/8 上这个变量解析成 7、8 没问题;在 Stream 9 上,$releasever 解析出来可能是 9-stream,而 Docker 官方仓库路径里并没有 9-stream 这个目录,只有 9。如果你直接 dnf install 报 404 或者找不到包,十有八九就是这个原因。
解决办法是在 repo 文件里把变量写死,或者用 dnf --setopt=docker-ce-stable.baseurl=... 覆盖。最省事的方案是直接写:
ini复制[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/centos/9/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/centos/gpg
写完后执行:
bash复制dnf makecache
dnf repolist
确认能看到 docker-ce-stable 仓库,再进行下一步。
3.2 安装 docker-ce 全家桶但不踩版本坑
官方仓库的好处就是组件全,一条命令装齐:
bash复制dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
逐个解释一下这几个包的分工,方便你理解它们在容器体系里的角色:
docker-ce:Docker 守护进程本体,包含 dockerd、docker.service,以及容器运行时管理逻辑。docker-ce-cli:命令行客户端,也就是你敲docker ps、docker run用的工具。客户端和守护进程是分开打包的,你可以升级客户端而不动服务器里的守护进程。containerd.io:容器运行时管理工具,负责镜像拉取、容器生命周期、网络接口等底层操作。Docker 把 containerd 拆出来独立维护,好处是故障范围隔离,坏处是如果版本不匹配,会让 Docker 启动报错。docker-buildx-plugin:构建扩展,用来支持多平台构建、BuildKit,现代 Dockerfile 的构建基本离不开它。docker-compose-plugin:提供docker compose子命令,用来编排多容器应用。
安装时有一个容易踩的坑:如果系统里之前装过旧版本 docker,需要先 dnf remove 干净,或者用 dnf --setopt=obsoletes=0 install docker-ce docker-ce-cli 来控制旧包没有被自动替换。另外,containerd.io 有独立的版本节奏,有时 docker-ce 需要的版本和 yum 自动选出来的最新版本不一致,这时候不要盲目 dnf update containerd.io,先看 docker version 报什么错再决定。
装完验证一下:
bash复制which docker
docker version
只要命令存在,说明客户端装好了。但注意,此时守护进程还没启动,docker version 的 Server 部分会显示连接失败,这是正常的。
3.3 启动前先把 daemon.json 改好
很多人习惯装完直接 systemctl start docker,跑起来再说。我强烈建议先编辑 /etc/docker/daemon.json,因为你启动之后再去调整存储驱动、cgroup driver,往往需要重启 Docker 甚至清空数据目录,代价更大。
下面是适配 CentOS Stream 9 的完整配置模板,我会逐项解释为什么这么写:
json复制{
"registry-mirrors": [
"https://<你的加速地址>"
],
"data-root": "/data/docker",
"exec-opts": ["native.cgroupdriver=systemd"],
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "3"
},
"storage-driver": "overlay2",
"iptables": true,
"ip-forward": true
}
先看 registry-mirrors。Docker Hub 在国外,国内服务器直接拉镜像经常超时、断流,配置一个你能拿到的、可用性好的镜像加速地址非常必要。无论你用的是云厂商提供的加速地址,还是其他公共加速站点,格式都一样,填在数组里即可。这个配置不会改变你拉取镜像的逻辑,只是让 Docker 优先从加速地址拉,拉不到再回源。
再看 data-root。Docker 默认把所有镜像、容器、数据卷数据放在 /var/lib/docker,随着镜像和日志增长,这个目录很容易撑爆系统盘。生产服务器一般都有独立数据盘,我会把 Docker 数据迁到 /data/docker,这样即使容器日志炸了,也不会拖垮系统分区。变更这个路径后,旧数据不会自动迁移,如果你之前启动过 Docker,需要手动把 /var/lib/docker 的内容搬到新目录,或者直接让 Docker 重新初始化(测试环境可以这么干)。
然后是 exec-opts,这一项在 cgroup v2 系统上必须配成 systemd,前文已经说过理由。storage-driver 默认就是 overlay2,Stream 9 的内核完全支持,不需要额外加载模块。
log-driver 和 log-opts 这个配置值一定要重视。不限制容器日志大小时,一个日志量大点的服务能把磁盘几十 G 空间吃完。max-size: 100m 表示单个日志文件到 100MB 就滚动,max-file: 3 表示最多保留 3 个文件,也就是说单个容器日志最多占 300MB,这是我在生产环境比较常用的一组值,你可以根据容器数量调整。
写完后顺手格式化检查一下:
bash复制python3 -m json.tool /etc/docker/daemon.json
如果这条命令没有报错,说明 JSON 格式没问题,可以进入下一步。
3.4 启动 Docker 并用 hello-world 验证
现在启动服务:
bash复制systemctl enable --now docker
systemctl status docker
enable --now 的意思是设置开机自启并立刻启动。启动后看状态,如果是 active (running),说明守护进程起来了。然后跑一下验证:
bash复制docker run --rm hello-world
hello-world 是个极小的镜像,Docker 会先去本地找,找不到就去配置的镜像加速拉取。如果这一步成功,说明整个链路已经通了。再用两条命令确认关键配置是否生效:
bash复制docker info | grep -i "Cgroup Driver"
docker info | grep -i "Storage Driver"
Cgroup Driver 应该是 systemd,Storage Driver 应该是 overlay2。如果 Cgroup Driver 显示 cgroupfs,回去检查 daemon.json 是否加载成功,docker info 里也能看到当前配置文件的路径。
另外,确认一下 Docker 服务是不是真的开机自启:
bash复制systemctl is-enabled docker
输出 enabled 就对了。这一步虽然基础,但很多人在初始化脚本里漏掉,重启后 Docker 没起来还以为系统坏了。
4. 从权限到实战:跑通 MySQL 8.0 和 Redis 主从
4.1 让普通用户免 sudo 操作 Docker 的正确姿势
Docker 服务默认以 root 身份运行,普通用户执行 docker 命令会因为无权访问 /var/run/docker.sock 而报错。生产环境里,开发同学总不可能每个人都 sudo 一下,这时就需要把用户加入 docker 用户组:
bash复制groupadd docker # 如果 docker 组不存在(一般装完就有)
usermod -aG docker $USER # 把当前用户加到 docker 组
newgrp docker # 让组权限在当前终端立即生效
重新登录服务器后,docker ps 就不需要 sudo 了。这里有一句必须讲到的话:加入 docker 组,本质上等同于给这个用户 root 权限,因为 docker 可以挂载宿主机任意目录到容器里、绕过大部分权限检查。所以只在可信任的用户、开发环境里用,不要在生产服务器上给所有人加组。
如果你需要让程序(比如 CI/CD 工具)远程操作 Docker,不要开 Docker 的 TCP 端口暴露给内网,那等于裸奔。更安全的路子是配置 Docker 的 TLS 远程访问,或者干脆让程序跑在宿主机上用 socket 文件通信。
4.2 容器目录权限问题的底层逻辑,别再用 chmod 777 糊弄
热词里有个很常见的搜索:“docker容器怎么赋予目录读写权限”。这个问题十有八九不是权限命令的问题,而是你对容器内用户的 UID 和宿主机目录 owner 的关系理解不到位。
容器里跑的进程,虽然看起来像系统里的 root(默认是 uid 0),但在 Linux 上,宿主机看到的文件访问权限,用的还是 uid/gid 这套老机制。举个例子:MySQL 官方镜像里的 mysqld 进程是以 mysql 用户跑的,这个用户的 uid 在镜像里通常是 999;你把宿主机目录 /data/mysql 挂进容器后,这个目录如果在宿主机的 owner 是 root,那么 mysql 用户想在里面写文件,就会 Permission denied。
正确的解决方式是让宿主机目录的属主和容器内运行用户的 uid 对齐:
bash复制# 镜像内 mysql 用户一般是 999,如果你不确定可以先启动容器后 exec 进去 id 一下
chown -R 999:999 /data/mysql
更通用的做法是用 --user 参数直接指定容器内运行用户,例如希望容器以宿主机的某个普通用户运行:
bash复制docker run --user 1000:1000 -v /data/app:/app your-image
另外注意 SELinux 的上下文。挂载数据卷时,Docker 默认不会自动给宿主目录打 SELinux 标签,容器内部读起来可能被拒绝,报错里常常能看到 Permission denied 但没有明显的 ACL 信息。解决办法是挂载时加标签:
bash复制# :z 表示共享卷,多个容器可以同时读写;:Z 表示私有卷
docker run -v /data/app:/app:z your-image
热词里还有一条“应用程序-特定 权限设置并未向在应用程序容器 不可用 sid 中运行的地址”,这个描述其实是 Windows 容器环境下的 ACL/SID 问题,在 Linux 内核上是另一套逻辑。Windows 容器走的是 Windows ACL,和这里说的 uid/gid 是两码事,如果你在 Windows 上用 Docker Desktop 跑 Windows 容器,遇到类似报错就去检查文件夹共享权限而不是容器内 chmod。这里特意提一下,免得你被 Windows 和 Linux 两套权限体系折腾到怀疑人生。
4.3 完整跑一个 MySQL 8.0 容器
直接给一个我在测试环境常用的 MySQL 8.0 命令,并拆解关键参数:
bash复制docker run -d \
--name mysql8 \
--restart unless-stopped \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD='Your@StrongPassword' \
-e MYSQL_DATABASE=testdb \
-v mysql-data:/var/lib/mysql \
-v /data/mysql/conf:/etc/mysql/conf.d:z \
mysql:8.0
拆开了说:
--restart unless-stopped:容器意外退出或宿主机重启时自动拉起,除非你手动 stop。-e MYSQL_ROOT_PASSWORD:初始化时设置 root 密码。注意这个环境变量只在首次初始化数据目录时生效,如果之后删掉容器重建,密码不会跟着改,因为数据已经在数据卷里了。-v mysql-data:/var/lib/mysql:用一个 Docker 卷保存 MySQL 数据。这个比 bind mount 更推荐,因为卷由 Docker 管理,备份、迁移都方便。-v /data/mysql/conf:/etc/mysql/conf.d:z:挂载自定义配置目录,比如你想改字符集、调 buffer pool,往这个目录放一个 my.cnf 就行了。:z是为了让 SELinux 放行。
关于字符集,MySQL 8.0 默认字符集已经是 utf8mb4,但连接层参数可能还是 latin1,建议在自定义配置里放:
ini复制[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
然后在容器里验证一遍:
bash复制docker exec -it mysql8 mysql -uroot -p
输入密码后执行 show variables like 'character%';,确认所有 character_set_* 变量都是 utf8mb4。数据库初始化脚本的自动化导入也有一个很实用的功能:MySQL 官方镜像启动时会执行 /docker-entrypoint-initdb.d 目录下的 .sql 或 .sh 脚本,利用这一点,你可以把建表、初始化数据塞到启动流程里,不用手动导入。只需把脚本放进去挂载出来即可:
bash复制-v /data/mysql/init:/docker-entrypoint-initdb.d:z
4.4 用 Docker Compose 快速搭一套 Redis 主从
热词里反复看到“redis主从”“docker compose”,这里用一个 Redis 主从的例子把编排能力讲通。
创建项目目录:
bash复制mkdir -p /opt/redis-cluster && cd /opt/redis-cluster
vi compose.yaml
写入以下内容:
yaml复制services:
redis-master:
image: redis:7
container_name: redis-master
command: ["redis-server", "--appendonly", "yes", "--requirepass", "masterpass"]
ports:
- "6379:6379"
volumes:
- master-data:/data
redis-slave:
image: redis:7
container_name: redis-slave
depends_on:
- redis-master
command: ["redis-server", "--slaveof", "redis-master", "6379", "--masterauth", "masterpass", "--requirepass", "slavepass"]
ports:
- "6380:6379"
volumes:
- slave-data:/data
volumes:
master-data:
slave-data:
启动:
bash复制docker compose up -d
这个例子里有几个点值得展开。
depends_on 只解决容器的启动顺序,不保证 master 的 Redis 服务已经 ready。因为 Redis 启动很快,实战中 depends_on 基本够用,但如果主从之间数据量大、master 恢复慢,最好加上 healthcheck,让从库等主库真正可用了再启动。
从库连接主库用的是容器网络里的服务名 redis-master,而不是 IP,这是 Docker compose 的默认组网能力。两个容器在同一个默认网络里,互相通过服务名解析。这比手动指定 IP 灵活得多,也避免了下级容器重启后 IP 变化导致断连的麻烦。
主从同步在 Redis 7 默认开启,如果你用更老的版本,还需要在从库配置里显式开启 replicaof。密码方面,主库有 requirepass,从库同步时要用 --masterauth 把密码传过去,否则同步会一直报 NOAUTH Authentication required。
启动之后验证主从关系:
bash复制docker exec -it redis-master redis-cli -a masterpass info replication
看到 role:master,并且 connected_slaves 为 1,说明配置成功。
至于你经常看到的青龙容器(比如“青龙容器公益版 v3.60 在线安装”)、其他应用容器,其实都是同一套东西:先找一个可用的镜像,把数据目录和环境变量准备好,再用 docker run 或 compose 拉起来。流程跑通一次,后面所有类似的部署都是复制粘贴再改参数的事。
5. 生产环境常见问题:SELinux、日志、内存与 Desktop 限制
5.1 SELinux 和 firewalld 的经典故障链路
我在一张 Stream 9 机器上踩过最深的坑是这样的:Docker 装完,hello-world 也跑通,但挂载自定义目录启动 MySQL 后,日志里全是 Permission denied。第一反应是目录权限,chmod 777 之后还是不行,折腾半天才想到去看 SELinux 审计日志:
bash复制ausearch -m avc -ts recent
看到一串 avc: denied { read } 的条目,这才意识到是 SELinux 拦截。这种情况处理起来也简单:
bash复制# 恢复 Docker 数据目录的 SELinux 上下文
restorecon -Rv /var/lib/docker
挂载带标签的卷,用之前说的 :z 或 :Z。如果你想查某条具体规则导致的原因,可以用:
bash复制grep docker /var/log/audit/audit.log
如果 auditd 没开,提前安装并启用:
bash复制dnf install -y audit
systemctl enable --now auditd
firewalld 相关的问题也常见。典型症状是:容器跑起来了,docker ps 里端口映射正常显示 0.0.0.0:8080->80/tcp,但外部访问就是不通。这时候优先检查宿主机的 firewalld 是否干预了转发:systemctl status firewalld 开着的话,先 firewall-cmd --list-all 看看有没有 DROP 规则。很多时候是有人在 firewalld 里做了富规则或把 Docker 网段的流量拦了。
另一个隐藏很深的情况是:你手动清过 iptables(比如 iptables -F),把所有 DOCKER 链删了,防火墙规则全没,容器网络自然不通。解决办法是重启 Docker 让它重建规则:
bash复制systemctl restart docker
重启 Docker 会让所有容器中断,生产环境操作前先评估影响。
5.2 Docker 日志和镜像把磁盘吃满的排查与清理
容器日志把磁盘吃满,是生产环境被问得最多的问题之一。先检查:
bash复制# 看 Docker 占了多少空间
docker system df
# 看日志目录大小
du -sh /var/lib/docker/containers/*
如果日志文件已经很大,但 daemon.json 是启动后才改的,老的容器不会自动套用新日志策略,需要重建容器,或者直接清空日志文件:
bash复制# 清空某个容器的当前日志(容器不用重启)
truncate -s 0 /var/lib/docker/containers/<container-id>/<container-id>-json.log
truncate -s 0 比 rm 安全,因为 Docker 进程还持有这个文件句柄,删除后句柄不释放,空间一直占着,你也看不出哪个进程占用。
镜像和构建缓存也会悄悄吃空间。docker system df 里如果显示 Build Cache 很大,可以用:
bash复制docker builder prune
清理悬空镜像、停止的容器、无用的网络:
bash复制docker system prune -a
注意 -a 会把你不再被任何容器使用的镜像全删了,如果之后还要用,得重新拉取。我的建议是:生产环境别用 -a,就用默认的 docker system prune 清理悬空资源就行。
还有一点容易被忽略:df -i 查 inode 占用。日志文件是小文件时尤其危险,比如容器的临时文件疯狂创建,虽然单文件不大,但 inode 用完,整个盘就写不进新文件了。如果你看到 No space left on device 但 df -h 还有空间,先 df -i 准没错。
5.3 容器内存飙高时,我该怎么一步步查
热词里有一条很具体:“java docker 容器占用内存特别高,怎么排查”。这个问题也是生产环境的高频场景。
第一步是看整体占用,确认是否真的高:
bash复制docker stats --no-stream
这个命令输出每个容器的 CPU、内存、网络占用,先定位是哪个容器出了问题。如果某个容器内存冲到上限,看它的 limit 是多少:没有限制的容器会显示一个很大的数,说明你启动时没加 --memory 参数。
第二步是进容器内部看进程视图:
bash复制docker exec -it <container> bash
top
在 Java 场景下,top 里看到的内存不完全等于 JVM 堆内存。JVM 除了堆,还有元空间、线程栈、JIT 编译器、GC 等非堆内存,以及容器里的其他进程。所以第三步才是看 JVM 自己的视角:
bash复制# 容器里的 JDK 附带 jcmd、jmap
docker exec -it <container> jcmd 1 GC.heap_info
docker exec -it <container> jcmd 1 Thread.print
Thread.print 能看出哪个线程在疯狂干活,GC.heap_info 能看出堆使用和 GC 状态。
如果你要限制容器内存,启动时就得加参数:
bash复制docker run --memory=4g --memory-swap=6g your-java-image
--memory 是硬上限,超过就触发 OOM kill;--memory-swap 给了一些缓冲空间,但注意 swap 是写到磁盘的,性能会下降。Java 应用尤其要注意,JVM 默认的 MaxHeapSize 是按宿主机内存算的,容器里不加 -XX:MaxRAMPercentage 参数时,JVM 可能认为自己有几十 G 内存可用,直接就把容器撑爆。正确的做法是在启动命令里加:
bash复制java -XX:MaxRAMPercentage=75.0 -jar app.jar
让 JVM 按容器配额动态计算堆上限,不要给它固定一个超大的 -Xmx。
5.4 Docker Desktop 在 Stream 9 上为什么容易失败
Docker Desktop 这个产品,很多人习惯在 Windows/Mac 上用,然后想在 CentOS Stream 9 的图形界面环境里装一份。这里要先认清一个现实:Docker Desktop 在 Linux 上默认只支持特定的几个发行版,CentOS Stream 9 并不在它的官方支持列表里,强行装的话,问题不断是正常的。
最常见的一个报错是 Virtualization support is not detected。这个提示是 Docker Desktop 没找到 /dev/kvm 设备,它依赖 KVM 虚拟化来跑一个 Linux 虚拟机。如果你的机器是云服务器、虚拟机,没开启嵌套虚拟化,或者 BIOS 里没打开 VT-x/AMD-V,/dev/kvm 就不存在。
检查一下:
bash复制ls -l /dev/kvm
如果不存在,两个方向:一是去宿主机/云控制台看有没有开启嵌套虚拟化;二是别折腾 Desktop 了,在 Stream 9 上用 CLI Docker 更务实。我在 Linux 上的实际使用感受是:CLI + Docker Compose + VS Code 的容器开发插件,功能上完全够用,还少了 Desktop 那层虚拟机消耗,开发效率反而更高。Desktop 的图形界面优势主要体现在跨平台统一体验上,在服务器操作系统上并不是刚需。
6. 生产环境里值得养成的几个容器习惯
6.1 给容器加上资源限制和健康检查再上生产
开发环境里 docker run -d 一把梭没什么问题,但生产环境不行。至少要把三件事做到位:资源限制、重启策略、健康检查。
资源限制参考:
bash复制docker run -d \
--cpus=1.5 \
--memory=2g \
--restart unless-stopped \
--health-cmd="curl -f http://localhost:8080/health || exit 1" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
your-app
--cpus=1.5 限制容器最多用 1.5 个 CPU 核心;--memory=2g 限制内存上限。健康检查告诉 Docker 这个容器是否真的活着,而不是进程还挂着就算健康。容器健康状态可以配合重启策略,比如连续健康检查失败就自动重启,这在很多场景下比人工介入及时得多。
我踩过的一个教训是:一台机器上跑了好几个 Java 和 Redis 容器,没有设置任何限制,其中一个容器内存膨胀后,宿主机开始疯狂 swap,所有容器一起卡成幻灯片。从那之后,我的所有 compose 文件里都会给服务带上 mem_limit 或 deploy.resources.limits。
6.2 建立一套数据卷和镜像的清理节奏
Docker 运维不只是部署那一下,日常清理也是一项长期工作。我的节奏是:
每周执行一次悬空镜像和构建缓存清理:
bash复制docker system prune -f
docker builder prune -f
每月检查一次数据卷,看有没有已经不再使用的旧卷。这里要特别提醒:docker volume prune 会删掉所有未被容器使用的卷,一旦执行,里面的数据永久消失。所以在生产环境我从来不用 docker volume prune,都是手动 docker volume ls -f dangling=true 列出来后,人工确认哪些需要删、哪些是备份。
每次要升级镜像 tag 时,不要直接覆盖正在用的 tag。比如你原来用 mysql:8.0,新版本出来想升到 mysql:8.0.35,正确的做法是:先拉新 tag,起一个新容器,挂载同一个数据卷的副本,验证数据兼容后再切换。千万不要在生产环境直接 docker pull 一个同名 tag 然后重启容器,万一新版本有 breaking change,你的数据可能就回不去了。
6.3 升级 Docker 时,先把底兜好
Docker 的升级相对平滑,但也要注意节奏。小版本内升级,直接:
bash复制dnf update -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl restart docker
重启 Docker 会短暂中断所有容器,所以严格来说生产环境应该在低峰期操作。升级前先看一眼当前版本,记下来,方便出了问题回滚:
bash复制docker version
如果要跨大版本升级,比如从 Docker 24 升到 26,我的经验是先做备份:把关键的 compose 文件和数据卷的元数据整理好,在测试环境完整演练一遍,再去动生产机器。不要觉得容器化系统“可重装”,数据卷里的内容如果没有做好备份,裸奔的风险比想象中高。
另外,升级的时候留意 containerd.io 和 docker-ce 的版本匹配关系。有些版本组合里,dockerd 会和 containerd 在 API 版本上不兼容,表现是 docker run 卡住、容器无法创建。在 dev 环境升级后,先 docker run --rm hello-world 走一遍全链路,再在正式机器上并发操作。
最后分享一个我自己经常用的小技巧:给所有生产环境容器打上 --label environment=production 标签,然后用 docker ps --filter label=environment=production 就能一键筛出所有生产容器。排查问题的时候,这一条 filter 能帮你快速缩小范围,也方便自动化脚本分类处理。
从 CentOS Stream 9 的选型、Docker 安装、配置调优,到 MySQL、Redis 的容器化落地,再到沿途踩过的问题排查,这一套流程走下来,你会发现容器化的难度并不在命令本身,而在于对系统底层机制的理解。只要内核、cgroup、SELinux、存储驱动这几个底层问题想清楚了,剩下的部署工作其实都是重复劳动。
