ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

openEuler 上 Docker 安装、镜像管理与 Dockerfile 构建实践

openEuler 上 Docker 安装、镜像管理与 Dockerfile 构建实践

前阵子给一台 openEuler 22.03 的服务器装 Docker,本来以为一两条命令就能搞定,结果照着网上“通用教程”复制粘贴,光安装就折腾了一晚上。原因很简单:openEuler 虽然是 RPM 体系,但它不走 CentOS 那套软件源,很多企业环境还要求离线部署和干净卸载,这就让镜像管理、Dockerfile 构建这些后续操作变得格外讲究。这篇笔记就把我实际摸过的 Docker 安装、镜像管理和 Dockerfile 构建三条主线完整串起来,手把手带你在这个系统上把容器环境真正跑起来。

不管你之前用没用过 Docker,只要你手上有一台 openEuler 机器,不管是虚拟机还是物理机,这篇内容都适合你。我会尽量把命令、参数、配置文件都写到可以直接照抄的程度,同时会解释每一步为什么这么干,让你改环境的时候也知道怎么调整。

1. 为什么在 openEuler 上跑 Docker,要先想清楚这些

1.1 openEuler 和主流发行版到底差在哪

openEuler 说白了是一个面向服务器场景的 Linux 发行版,它底层的包管理用的是 RPM 那一套,所以你看到yum、dnf、rpm会觉得亲切。但真正动手装软件时会发现,它的软件仓库、包名编排和 CentOS 不一样。比如 CentOS 上可以直接装docker-ce,openEuler 官方仓库里带的是docker-engine,两者功能差不多,但名字和依赖细节有差异。

另外 openEuler 对 SELinux 的默认策略也比较严格,容器跑起来之后如果遇到权限类报错,十有八九和安全上下文有关。刚上手的人容易忽略这一点,所以这里先说清楚:在 openEuler 上折腾 Docker,不是“换个 yum 源”这么简单,而是要在理解它仓库体系的前提下,把安装、存储、网络、systemd 集成这四块都对齐。

1.2 这套实战笔记能帮你解决哪些问题

我归纳了一下,大多数人在 openEuler 上用 Docker 摔跟头,无非集中在三个点。

第一是安装环节:官方仓库里的 docker 包到底叫啥、装完怎么用 systemd 拉起来、要不要配 docker 组权限,很多人卡在这里。第二是镜像管理环节:镜像拉下来放哪、怎么给镜像打标签、离线环境怎么导出导入、怎么自建一个私有仓库,这些属于日常高频操作但细节很多。第三是 Dockerfile 构建环节:基础镜像用哪个、每条指令怎么编排才能利用缓存、多阶段构建怎么减小体积,这些直接决定你构建出来的镜像能不能上生产。

这篇笔记会把这三块挨个拆开,每一块都附上我在 openEuler 上实际跑通的命令和配置,你照着走基本不会踩坑。

2. 安装前检查与方案选型

2.1 系统版本、内核和磁盘状态确认

不管多简单的安装,先确认系统底子是个好习惯。在 openEuler 上打开终端,依次执行下面几条命令,花两分钟把环境摸清楚:

cat /etc/os-release uname -r df -h /var getenforce

第一行看系统版本,确认是 20.03 LTS、22.03 LTS 还是更新版本,不同版本仓库里的包会有细微差别。第二行看内核,Docker 的 overlay2 存储驱动对内核版本有要求,openEuler 默认内核通常是 5.10 或更高,问题不大,但如果是旧内核,建议先升级再装 Docker。第三行看磁盘剩余空间,Docker 默认把镜像和容器数据放在/var/lib/docker下,如果/var所在分区小于 20G,后面拉几个镜像就可能撑爆,建议提前挂在独立数据盘,或者后面通过>yum makecache

然后搜索一下仓库里有没有 docker 相关的包,这一步能确认你自己的软件源是否正常,也能搞清楚准确的包名:

yum search docker

在 openEuler 22.03 官方仓库里,通常能看到docker-engine或docker这个包。直接把docker-engine拉下来安装:

yum install -y docker-engine

安装过程会拉下来一串依赖,比如containerd、runc等,这些是 Docker 运行容器必需的底层组件,看到它们一起装是正常的。装完后执行which docker或者docker version验证一下二进制文件是否存在。

这里有个小细节:openEuler 仓库里装的 Docker 版本可能不是最新的,但胜在稳定,和系统库兼容。如果你一定要用最新版,才需要考虑二进制安装路线。我个人更推荐先用仓库版跑通整个链路,再考虑升级。

3.2 启动服务、配置权限和自启

安装完成后,Docker 不会自动启动,你需要用 systemd 把它拉起来,并且设置开机自启:

systemctl enable --now docker systemctl status docker

看到active (running)就说明服务起来了。如果你不信,可以再跑一个最小的验证命令:

docker run hello-world

这个命令会让 Docker 从镜像仓库拉取一个hello-world测试镜像并运行它,如果输出一段带 “Hello from Docker!” 的说明文字,说明整套环境已经能正常工作了。

我建议你顺手把当前用户加进docker组,这样以后执行docker命令时,不需要每次都加sudo:

groupadd docker usermod -aG docker 你的用户名 newgrp docker

注意,usermod之后,当前登录的 shell 不会立刻生效,执行newgrp docker或者干脆重新登录一次。如果忘了这一步,后面执行 Docker 命令时,遇到 “permission denied while trying to connect to the Docker daemon socket” 这种报错,不要慌,先看看自己是不是不在 docker 组里。

3.3 用 daemon.json 统一管理镜像加速与日志

Docker 的核心配置都集中在/etc/docker/daemon.json里,这个文件默认不存在,需要自己创建。我通常在安装完成后的第一件事就是把整个文件写好,避免后期改一处就重启一次服务。

下面这份配置是我在 openEuler 上的常用模板:

{ "data-root": "/var/lib/docker", "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "5" }, "registry-mirrors": [ "https://你的镜像加速地址.example" ], "storage-driver": "overlay2" }

这里几个参数逐个解释。>systemctl restart docker

重启完可以执行docker info看看关键信息,比如Storage Driver是不是显示overlay2,日志驱动是否正确。注意,daemon.json里的修改只对之后创建的容器生效,已经有数据的老容器如果不重建,日志参数不会变,这一点排查问题的时候经常被忽略。

4. 镜像管理:每天都会用到的 8 个操作

4.1 先搞懂镜像和容器的分层关系

镜像管理听起来高大上,其实可以把它理解成“软件安装包”的升级版。普通的安装包装完就完事了,Docker 镜像则是一层层只读文件叠起来的,每一层对应 Dockerfile 里的一条指令。容器运行的时候,Docker 在最上面加一层可写层,你的写入操作都发生在这层。镜像层不变,所以多个容器可以共享同一个镜像底下的层,不重复占空间。

这个设计意味着什么呢?意味着你删除镜像时,如果多个镜像共享某些底层,Docker 只会删除引用计数为零的层。也意味着你拉取一个新镜像的某几层,如果本地已经存在,Docker 会直接复用,这就是“增量拉取”的原理。

在 openEuler 上,镜像是放在/var/lib/docker/overlay2目录下的,除非你把>docker pull nginx:1.27

nginx是镜像名称,1.27是标签。如果不写标签,Docker 会默认拉取latest标签。这里建议每次显式写明版本号,尤其是生产环境,因为latest会随上游更新而变化,今天拉的和明天拉的可能是两个东西。

查看本地镜像:

docker images docker image ls

两条命令效果相同,输出镜像名、标签、镜像 ID、创建时间和大小。如果加了-a参数,会把中间层镜像也显示出来,平时不用加。想要了解一个镜像的构建历史,可以用docker history nginx:1.27,能看到它每一层执行的指令,这也是判断镜像体积来源的好方法。

删除镜像:

docker rmi nginx:1.27

如果这个镜像还有正在运行的容器引用,删除会报错。这时候你可以加-f强制删除,但我一般不建议,因为-f可能会让运行中的容器出现奇怪行为。正确做法是先把相关容器停掉删掉,再删镜像。

清理一堆无用的悬空镜像,可以这样:

docker image prune -a

这个命令会删除所有没有被容器引用的镜像,执行前先看清楚哪些会被删,避免误删。

4.3 镜像的标签、导入导出和离线分发

镜像的全名格式是仓库地址/命名空间/镜像名:标签,docker tag这个命令就是为了给同一个镜像打上不同的“门牌号”。

比如,我们想把本地openeuler/openeuler:22.03-lts这个基础镜像改个便于辨识的名字:

docker tag openeuler/openeuler:22.03-lts mybase/openeuler:22.03

这个操作只是增加一个引用标签,不会产生新的镜像数据,它占用的还是同一份镜像存储。打标签的目的通常是为了推送,因为 Docker Hub 或者私有仓库要求镜像路径必须符合特定格式。

离线环境里,镜像的传输需要用到save和load两个命令。把镜像导出成一个 tar 文件:

docker save -o openeuler-base.tar openeuler/openeuler:22.03-lts

然后把这个 tar 文件拷贝到另一台 openEuler 机器上,再导入:

docker load -i openeuler-base.tar

这里提醒一下,docker save会保留镜像的完整层数据和历史信息,所以导出的文件可能很大。如果你只想要最终文件系统,不考虑历史,可以使用docker export结合容器来做,但那样导出的就不叫镜像了,而是容器文件系统快照,导入回来不能直接保留原有命令配置,不要搞混。

4.4 裸跑一个私有镜像仓库

镜像分发还有一个更常用的方式:自建私有仓库。在 openEuler 上搭一个 minimal 的镜像仓库其实相当简单,Docker 官方提供了一个叫registry的镜像,跑起来就是一个仓库服务端。

先拉镜像,再启动容器:

docker pull registry:2 docker run -d --name my-registry \ -p 5000:5000 \ --restart=always \ -v /opt/registry-data:/var/lib/registry \ registry:2

上面这个命令把仓库的存储目录挂载到了宿主机的/opt/registry-data,这样仓库容器即使被删掉重建,里面的镜像数据也不会丢。5000端口就是仓库对外开放的端口。

使用私有仓库时,镜像名的格式必须是宿主机IP:5000/镜像名:标签,否则 Docker 不认。先给一个镜像打上私有仓库的完整标签:

docker tag openeuler/openeuler:22.03-lts 192.168.1.10:5000/openeuler-base:22.03

推送到私有仓库:

docker push 192.168.1.10:5000/openeuler-base:22.03

第一次推送时,Docker 默认是不信任非 HTTPS 的仓库的,如果仓库地址是裸 IP 且没有配 HTTPS,需要修改/etc/docker/daemon.json,添加:

{ "insecure-registries": ["192.168.1.10:5000"] }

然后重启 Docker。推完之后,你可以用docker pull 192.168.1.10:5000/openeuler-base:22.03在任何一台配置了相同 insecure 地址的机器上拉取这个镜像,实现内网分发。

5. Dockerfile 构建实战:从基础指令到多阶段优化

5.1 把 Dockerfile 常用的指令一次讲清楚

安装 Docker 和管理镜像只是热身,真正体现功夫的是写 Dockerfile。Dockerfile 是描述“镜像怎么构建”的配方文件,每一条指令会生成镜像的一个新层。

我先把最常用的指令串一遍,用一个小案例说明。

假设我们要构建一个基于 openEuler 的 Nginx 镜像,目录里有一个index.html文件。Dockerfile 这样写:

# 指定基础镜像,必须是第一行有效指令 FROM openeuler/openeuler:22.03-lts # 维护者信息,现在更推荐用 LABEL 代替 LABEL maintainer="openeuler@example.com" # 环境变量 ENV NGINX_VERSION=1.24 # 构建时执行 shell 命令 RUN yum install -y nginx && yum clean all # 把宿主机的文件拷贝进镜像 COPY index.html /usr/share/nginx/html/ # 声明容器对外暴露的端口,主要起说明作用 EXPOSE 80 # 指定容器启动时执行的默认命令 CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

这条 Dockerfile 里几个重点说明一下。FROM指定基础镜像,所有操作都基于这个镜像叠加。RUN用来在构建期间执行命令,注意这里的命令是在镜像内部的临时容器里执行的,和宿主机无关。COPY是把构建上下文中的文件复制进镜像,它和ADD的区别在于,ADD还支持自动解压 tar 包和拉取远程 URL,但实际项目里我一般只用COPY,因为行为更可预期。

CMD和ENTRYPOINT是新手最容易混淆的。简单理解:CMD是容器启动时的默认命令参数,它会被docker run后面的参数覆盖;ENTRYPOINT是固定入口,不会被覆盖。如果两个都用,CMD的内容会作为参数传给ENTRYPOINT。一般场景下,用CMD就可以了,需要固定进程入口时才组合用。

构建这个镜像的命令是:

docker build -t my-nginx:1.0 .

别小看最后这个点,它代表“构建上下文”,告诉 Docker 把当前目录作为打包给构建进程的内容。构建时,Docker 会把上下文里面的所有文件都传输给 Docker daemon,所以尺寸大的目录、临时文件都应该排除,这就是5.2的内容。

5.2 构建上下文和 .dockerignore 的坑

构建上下文是个非常容易被忽略的坑。很多人以为docker build .里的.只是指定 Dockerfile 路径,其实它是在告诉 Docker:把当前目录整个打包发送给 Docker 服务端,再在服务端完成构建。

这意味着什么呢?如果你的目录里有几个 GB 的视频文件或者node_modules,即使 Dockerfile 里根本没有用到它们,这些文件也会被打包传输,构建速度慢到离谱,还会产生环境敏感信息泄露的风险。

解决办法是写.dockerignore文件,作用类似.gitignore:

.git node_modules build *.log *.tar

构建时 Docker 会自动读取.dockerignore,把这些文件排除在上下文之外。我建议从一开始建项目就顺手加上这个文件,不然以后目录一复杂,构建性能下降到一个层次,你都不知道是哪来的。

还有一点,构建上下文只包含当前目录,Dockerfile 里的COPY ../something这种写法是不被允许的,因为构建上下文之外的文件 Docker 根本看不到。遇到这种需求,要把需要拷贝的文件放进当前目录再构建。

5.3 多阶段构建:让镜像体积小一半

我之前给一个 Go 服务写 Dockerfile,第一版镜像打完 900 多 MB,后来用多阶段构建压缩到 40 多 MB,效果非常夸张。多阶段构建的核心理念是:用一套完整的构建环境完成编译,再把编译产物复制到一个干净的运行环境镜像里,中间产生的临时文件全都丢掉。

看一个实际例子。项目目录下有一个main.go:

package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, openEuler") }) http.ListenAndServe(":8080", nil) }

Dockerfile 这样写:

# 第一阶段:编译环境 FROM golang:1.21 AS build WORKDIR /src COPY main.go . RUN CGO_ENABLED=0 go build -o myapp . # 第二阶段:运行环境 FROM openeuler/openeuler:22.03-lts WORKDIR /opt COPY --from=build /src/myapp . EXPOSE 8080 ENTRYPOINT ["./myapp"]

关键在COPY --from=build /src/myapp .这一句,它直接从前面的编译阶段镜像里把二进制拷贝过来,不需要经过宿主机中转。最终生成的运行镜像只包含一个静态二进制文件,加一个精简的 openEuler 基础系统,体积自然小很多。

这里有一个细节值得说:Go 程序我加了CGO_ENABLED=0来编译成静态二进制,这样运行阶段镜像里就不用装 glibc 依赖了。如果用 CGO 编译,动态链接的二进制放到精简镜像里会报not found之类的问题,排查起来很麻烦。非 Go 语言项目也一样,比如 Java 可以用多阶段先编译出 jar 再拷到 JRE 基础镜像里,Node 可以先把依赖安装完再做npm prune。

5.4 openEuler 基础镜像的构建优化技巧

针对 openEuler 上的 Dockerfile 构建,我额外总结了几个优化点。

第一,善用镜像层缓存。Docker 构建时会缓存每一步结果,只要 Dockerfile 中的指令和上下文文件没变,缓存就能命中。基于这个原理,应该把变化频率最低的指令放在前面。常见的做法是把yum install这类安装依赖的指令放在COPY代码之前。如果先把代码复制进去再安装依赖,那么每次修改代码都会导致依赖安装步骤重新执行,白白浪费几分钟。

第二,安装完软件包记得清理缓存。openEuler 的yum在安装时会下载元数据和 rpm 包到本地,不清理的话这些内容会进镜像层,拉大体积。所以在 Dockerfile 的RUN里一条命令完成安装和清理,比如:

RUN yum install -y nginx \ && yum clean all \ && rm -rf /var/cache/yum

第三,基础镜像标签要写具体版本,不要写latest。比如只写FROM openeuler/openeuler:latest,今天构建出来的镜像和半年后构建的镜像可能底层不一致,出了问题很难复现。用FROM openeuler/openeuler:22.03-lts这种带明确版本的标签,可追溯性才有保障。

第四,生产环境尽量启用--no-cache=false之前先想清楚。默认的缓存机制在本地操作很舒服,但在构建机或者 CI 流水线里,旧缓存可能让镜像产生不符合预期的历史遗留,需要主动用docker build --no-cache强制重新构建时,直接加参数即可。

6. 常见问题速查与排查思路

6.1 启动失败和权限类问题

我见过最多的是这个报错:

Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock

这就是用户不在docker组里,或者 Docker 服务没有启动。顺序排查:先systemctl status docker看服务状态,再用id 你的用户名看用户是否在 docker 组。服务没启动就启动它,用户不在组里就用usermod -aG docker 你的用户名加组,然后重新登录。

另一个常见报错是:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

基本就是 docker 服务没起来。用journalctl -u docker -n 50看日志,常见原因是daemon.json配置错误,比如 JSON 格式不对、路径没有权限等。

6.2 磁盘、存储驱动和日志增长

容器跑久了,磁盘满了,这是生产环境最常见的事故之一。先定位是谁占了空间:

du -sh /var/lib/docker/*

如果发现containers目录异常大,多半是容器日志没设轮转。修改daemon.json加上日志大小限制后,对存量容器要重建才生效,这个我前面强调过。如果发现overlay2目录特别大,多半是镜像和容器层堆积太多,用docker system df查看空间占用情况,再用docker system prune清理悬空资源。

偶尔会遇到 overlay2 存储驱动初始化失败的情况。openEuler 默认内核一般能支持 overlay2,但如果报错说内核模块不支持,检查一下内核是否加载了overlay模块:

modprobe overlay

如果你用的是老内核又不方便升级,临时方案是把存储驱动改成vfs,但vfs性能和存储利用率都比较差,只适合应急。

6.3 容器网络和 DNS 问题

容器里访问外网失败,但宿主机没问题,常见的坑是 DNS 配置。容器默认从宿主机继承 DNS 配置,如果宿主机的systemd-resolved状态不对,容器里解析域名就会失败。快速验证:

docker run --rm --dns 你本地可用的 DNS 地址 你的镜像 ping 某个域名

如果指定 DNS 后可以解析,说明默认 DNS 有问题。可以在daemon.json里增加:

{ "dns": ["你本地的 DNS 地址"] }

然后重启 Docker,让所有容器默认使用这个 DNS。另外,如果容器需要和宿主机网络互通,用--network host可以让容器直接用宿主机网络,单容器场景非常好用,但注意端口占用冲突问题。

6.4 systemd 集成和开机自启的边界情况

openEuler 上 Docker 服务是通过 systemd 管理的,开机自启用systemctl enable docker就能配置好。但如果你的机器配置了私有仓库,依赖的存储盘是外挂的,比如网络文件系统,systemd 启动顺序可能导致 Docker 先于挂载完成启动,结果数据目录不可用。处理办法是写一个 systemd 的RequiresMountsFor=/opt/registry-data配置,或者把挂载做成开机自动挂载,保证目录就绪再启动服务。

还有一种场景是机器上同时装了多个容器运行环境,比如既有 docker 又有其他容器引擎,它们的 systemd 服务文件可能会互相覆盖。遇到docker.service被占用或者服务文件路径被篡改的情况,先用systemctl cat docker查看当前生效的服务文件内容,确认 ExecStart 指向的路径是否正确,再用systemctl daemon-reload重新加载。

7. 我踩过坑之后留下的几点实操建议

最后分享几条实际体会,不算什么高深理论,但都是真金白银换来的经验。

第一个建议是别急着在生产环境装最新版。openEuler 仓库里的 docker-engine 虽然版本老,但它是和系统整体兼容过的,你先用它把业务逻辑跑通,再把“用新版”作为一个单独的目标去推进,不要把两个变量一起引入。第二个建议是每天看一眼docker system df和磁盘使用率,很多故障都不是突然发生的,而是在日志和镜像堆积中缓慢逼近临界点。第三个建议是凡是改动/etc/docker/daemon.json之后,一定记得备份原文件,确认 Docker 服务能正常启动后再继续,避免手滑把配置写坏导致整套环境瘫痪。

容器这条路,一旦跑通第一个镜像,后面就是正反馈了。希望这篇基于 openEuler 的实战笔记能帮你少走一点弯路,把时间花在真正有业务价值的事上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进