一、构建企业级应用镜像

本章将构建两个企业级应用镜像:NginxTomcat

1.1 构建 Nginx 镜像(通过远程yum仓库)

创建构建环境
mkdir /nginx
cd /nginx
创建 Nginx 配置文件

创建 Nginx 配置文件,覆盖默认配置,用自定义的配置来启动 Nginx

#这个配置文件名称是固定的,用来替换容器中原先的配置文件
[root@hd1 nginx]# vim default.conf
server {
        listen 80 default_server;
        listen [::]:80 default_server;

        location / {
                root /var/lib/nginx/html;  #网页根目录
                index  index.html;  #默认首页
        }

        # You may need this to prevent return 404 recursion.
        location = /404.html {
                internal;
        }
}

这个文件我们会在构建镜像时拷贝到Alpine下的/etc/nginx/conf.d/目录

编写 Dockerfile

当构建文件名为Dockerfile,且就在本目录时,构建镜像可以不使用 -f 指定构建文件
写Dockerfile的时候,我们只需要按照:FROMRUNCOPYENTRYPOINT 的套路来写就行了

[root@hd1 nginx]# vim dockerfile

注意,alpine是基于deban系的,所以我们

FROM alpine:latest
# 替换国内源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk update    #更新 Alpine 系统的软件包索引。相当于: yum clean all && yum -y makecache
RUN apk add --no-cache nginx  #安装一个nginx软件
COPY  default.conf   /etc/nginx/http.d/default.conf  #拷贝并覆盖原配置文件
EXPOSE 80
# 入口命令必须是长久命令,前台运行
# -g 指定运行方式,daemon off (关闭守护程序)表示前台运行。因为容器要求主进程前台运行,否则容器会立刻退出
ENTRYPOINT ["/usr/sbin/nginx","-g","daemon off;"]

文中为了方便写注释,将run拆开写了。真实最佳实践是:RUN sed -i ‘s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g’ && apk update && pk add --no-cache nginx

将镜像内部:的源dl-cdn.alpinelinux.org替换为mirrors.aliyun.com/g(国内源)。这一步替换的是 Alpine 系统内部的软件包源(即 apk 源)

--no-cache:不缓存这些包,从而减少镜像体积。

注意:如果 Dockerfile 文件名为 dockerfile,构建时不需要 -f 指定文件名。

构建并运行
# 构建镜像(-t 指定镜像名和标签,. 表示当前目录)
docker build -t nginx:v20 .

# 查看镜像是否构建成功
docker images | grep nginx

# 基于刚才的镜像启动容器
docker run -d -p 8000:80 --name html2 nginx:v20

# 查看具体入口命令(不截断)
docker ps --no-trunc -a

假如我们的基础镜像是rocky:

FROM rocky:latest
#这是阿里云镜像官方的语法
RUN sed -i 's|mirror.rockylinux.org|mirrors.aliyun.com/rockylinux|g' /etc/yum.repos.d/*.repo && \
    yum clean all && \
    yum makecache -y && \
    yum -y install nginx 
EXPOSE 80
COPY index.html /usr/share/nginx/html/
#入口命令必须是长久命令,前台运行,-g指定运行方式,daemon off表示前台运行
ENTRYPOINT ["/usr/sbin/nginx","-g","daemon off;"]
将镜像导出为离线压缩包
#-o 指定输出文件的路径和名称。这里指定为当前目录下的 alpine2026.tar.gz
[root@hd1 ~]# docker save -o alpine2026.tar.gz  alpine:latest

1.2 构建 Tomcat 镜像(通过宿主机压缩包)

创建构建环境
mkdir tomcat8
cd tomcat8

apache-tomcat-8.5.69.tar.gzjdk-8u301-linux-x64.rpm 放到 tomcat8 目录下。

编写 Dockerfile
[root@hd1 tomcat8]# cat dockerfile
FROM centos:v1
LABEL author=kisaki
ADD jdk-8u301-linux-x64.rpm /usr/local/
ADD apache-tomcat-8.5.69.tar.gz /usr/local/
RUN cd /usr/local && rpm -ivh jdk-8u301-linux-x64.rpm  #切入软件包所在目录并执行安装
RUN mv /usr/local/apache-tomcat-8.5.69 /usr/local/tomcat8  #将tomcat程序放入一个名称简单的目录
#启动tomcat。因为是短期命令会导致退出,所以加上一个tail -F 持续查看日志,变成一个持续命令
ENTRYPOINT /usr/local/tomcat8/bin/startup.sh && tail -F /usr/local/tomcat8/logs/catalina.out
EXPOSE 8080

注意:基础镜像centos:v1需要提前构建好,也可使用其他可用基础镜像,如 centos:7,可直接使用docker pull从官网拉取

构建并运行

注意:当构建文件名为Dockerfile,且就在本目录时,构建镜像可以不使用 -f 指定构建文件

# 构建镜像
docker build -t="tomcat8:v1" .

# 查看生成的镜像
docker images | grep tomcat
REPOSITORY                                             TAG       IMAGE ID       CREATED          SIZE
mytomcat                                               v1        57e91fcd0e64   11 seconds ago   707MB

# 运行容器
# --ulimit 设置文件描述符和进程数限制
docker run -d -p 9090:8080 --ulimit nofile=65536:65536 --ulimit nproc=65536:65536 --name=tomcat1 tomcat8:v1

–ulimit nofile:文件描述符限制,表示最大打开的文件数
–ulimit nproc:进程/线程数限制

踩坑记录

如果我们在运行容器的时候,没有加上 –ulimit 进行资源限制,我们会发现无法访问到tomcat的默认首页

使用 docker logs ,却提示tomcat正常运行。

[root@hd1 tomcat]# docker run -dt -p 9090:8080 --name mytomcat1 mytomcat:v1 
eb8ad3c6066e117501e7f0eb878dd59b2820eccc211830336647b7ef093375d5
[root@hd1 tomcat]# docker logs mytomcat1 
Using CATALINA_BASE:   /usr/local/tomcat8
Using CATALINA_HOME:   /usr/local/tomcat8
Using CATALINA_TMPDIR: /usr/local/tomcat8/temp
Using JRE_HOME:        /usr
Using CLASSPATH:       /usr/local/tomcat8/bin/bootstrap.jar:/usr/local/tomcat8/bin/tomcat-juli.jar
Tomcat started.

登录进入容器,查看日志,发现报错显示“无法分配文件描述符表”(文件描述符用尽),原因正是系统或容器限制了最大打开文件数(nofile),而 Tomcat 在启动时需要的文件描述符数量超过了限制

[root@eb8ad3c6066e /]# cat /usr/local/tomcat8/logs/catalina.out 
library initialization failed - unable to allocate file descriptor table - out of memory[root@eb8ad3c6066e /]# 

请添加图片描述
这就是为什么我们在运行tomcat容器的时候要使用–ulimit 进行资源限制

1.3 练习:构建 Apache 镜像

#创建构建环境
mkdir /dockerfile
cd /dockerfile
#拉取centos7镜像
ocker pull centos:7
#编写Dockerfile
vim Dockerfile

FROM centos:7
RUN curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo && yum clean all &&  yum makecache -y && RUN yum -y install httpd &&  yum clean all 
#yum clean all用来清空缓存, yum makecache -y 用来下载最新的软件包列表和元数据到本地缓存。这两个属于优化项,不影响功能
EXPOSE 80 
#FOREGROUND:前台运行
ENTRYPOINT ["/usr/sbin/httpd","-D","FOREGROUND"]

踩坑记录:centos:7没有预装wget,所以使用curl -o

开始构建:

[root@hd1 httpd]# docker build -t myapache:v1 .
#可以看得到Dockerfile中的命令按次序的执行了
[+] Building 163.4s (7/7) FINISHED                                                      docker:default
 => [internal] load build definition from Dockerfile                                              0.0s
 => => transferring dockerfile: 326B                                                              0.0s
 => [internal] load metadata for docker.io/library/centos:7                                       0.0s
 => [internal] load .dockerignore                                                                 0.0s
 => => transferring context: 2B                                                                   0.0s
 => CACHED [1/3] FROM docker.io/library/centos:7                                                  0.0s
 => [2/3] RUN curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-  134.1s
 => [3/3] RUN yum -y install httpd                                                               28.1s 
 => exporting to image                                                                            1.2s 
 => => exporting layers                                                                           1.2s 
 => => writing image sha256:9a01ddd08880290a0294baf393bd2e84190752e2fcee8d986819281a38cc8518      0.0s 
 => => naming to docker.io/library/myapache:v1                                                    0.0s

启动容器:

[root@hd1 httpd]# docker run --name http1 -dt -p 8000:80 myapache:v1   

接下来,我们登录进容器,修改它的网页首页:

[root@hd1 httpd]# docker run --name http1 -dt -p 8000:80 myapache:v1        
[root@964d07d0aec0 /]# vi /var/www/htmlindex.html 
<h1>Hello! root</h1>

请添加图片描述
导出为离线包:

docker save -o httpd2026.tar.gz  httpd:v1

导入离线包为镜像:

docker load -i httpd2026.tar.gz

二、Docker 文件系统驱动

容器文件系统是 overlayFS(联合文件系统,说是unionfs也可以)

容器的默认存储驱动是 overlay2。overlay2 是 Docker 在 Linux 上的默认存储驱动(是overlay升级版),支持写时复制(CoW),高效且稳定

容器文件系统基于联合文件系统(UnionFS)技术,Docker 当前默认使用 overlay2 存储驱动(基于 Linux 内核的OverlayFS)

Docker 镜像由一系列层组成,每层代表 Dockerfile 中的一条指令。除最后一层外的每一层都是只读的

┌─────────────────────────────────────────┐
│              容器层(可读写)             │  ← 容器运行时添加
├─────────────────────────────────────────┤
│             镜像层3(只读)              │  ← CMD / ENTRYPOINT
├─────────────────────────────────────────┤
│             镜像层2(只读)              │  ← RUN / COPY / ADD
├─────────────────────────────────────────┤
│             镜像层1(只读)              │  ← 基础镜像
└─────────────────────────────────────────┘

核心概念:

  • 镜像层是只读的,所有容器共享
  • 容器层是可读写的,每个容器独立
  • 写时复制(Copy-on-Write)机制保证效率

共享的镜像数据(只读层),只有在容器首次尝试写入(修改)文件时,才会按需将该文件复制到该容器的可写层(容器层)中,此后该容器内的所有读写操作都基于这个副本进行,而其他容器继续共享原始镜像数据。
简单点说,就是镜像大家共享,生成容器的时候会根据这个镜像生成副本,这个方便就是容器的可写层,此后这个可写层就相当于是这个容器(镜像+可写层是一个完整的容器,这里是打一个比方)

每一条“顶级 Dockerfile 指令”(如 FROM、RUN、COPY)都会产生一个新层。但 RUN 指令内部用 && \ 连接多个Shell 命令,并不会增加层数。
所以在构建镜像时,多条 RUN 指令应该尽可能合并成一条(使用&& \ )

#查看宿主机,容器和镜像的信息,比如宿主机运行了多少容器,有多少镜像,文件系统类型等
docker info

......
Server:
 Containers: 2
  Running: 2
  Paused: 0
  Stopped: 0
 Images: 8
 Server Version: 26.1.3
 Storage Driver: overlay2 #联合文件系统
  Backing Filesystem: extfs
......
 Insecure Registries:
  127.0.0.0/8
 Registry Mirrors:
  https://docker.m.daocloud.io/
  https://docker.1ms.run/
  https://docker.xuanyuan.me/
 Live Restore Enabled: false

xfs文件系统:擅长处理大文件,比如大模型参数
ext4文件系统:文件系统更稳定

三、Docker 容器网络

3.1 容器网络介绍

启动Docker服务的时候,会生成一个 docker0 的虚拟网桥(相当于逻辑上虚拟的交换机和网卡的双重角色)。这个网桥会被分配 172.17.0.1/16的网段

可以看到,我们的宿主机上存在一个docker0的网桥和veth开头的端口:

[root@hd1 ~]# ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.1  netmask 255.255.0.0  broadcast 172.17.255.255
        inet6 fe80::42:f3ff:fe37:84ca  prefixlen 64  scopeid 0x20<link>
        ether 02:42:f3:37:84:ca  txqueuelen 0  (Ethernet)
        RX packets 21223  bytes 989261 (966.0 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 24498  bytes 275566303 (262.8 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.1.11  netmask 255.255.255.0  broadcast 192.168.1.255
        inet6 fe80::20c:29ff:fe8e:b6e3  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:8e:b6:e3  txqueuelen 1000  (Ethernet)
        RX packets 1437467  bytes 1531542402 (1.4 GiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 811611  bytes 1050670571 (1001.9 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 11600  bytes 697024 (680.6 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 11600  bytes 697024 (680.6 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

veth7c8f0a8: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet6 fe80::4cd0:acff:fed7:353a  prefixlen 64  scopeid 0x20<link>
        ether 4e:d0:ac:d7:35:3a  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 17  bytes 1366 (1.3 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

vethd47c032: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet6 fe80::445c:6ff:fe75:d062  prefixlen 64  scopeid 0x20<link>
        ether 46:5c:06:75:d0:62  txqueuelen 0  (Ethernet)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 14  bytes 1076 (1.0 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

这是容器中的网络,除了回环地址,还有一个eth0的接口。

/ # ipaddr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
100: eth0@if101: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP 
    link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

那么eth0和vethxxxx又是什么呢?
请添加图片描述
网桥docker0位于宿主机,docker0可以通过宿主机物理网卡连接外网。图中的veth pair是一条虚拟网线,用于连接网桥和容器,这条虚拟网线在网桥(或者说在宿主机上)的端口叫veth xxx,在容器的端口叫eth0

每个容器通过 veth pair (虚拟网线)连接到宿主机上的 docker0 网桥,容器间通过网桥通信,访问外网时通过 NAT 转发。这张图准确反映了这一流程

图中三个容器都在同一网段。docker0的地址也是容器的网关地址,所以在功能上,docker0相当于一个虚拟的三层交换机

Docker 网络模式(网络驱动)
网络模式配置说明
bridge(桥接)模式--net=bridge默认值,在 Docker 网桥 docker0 上为容器创建新的网络栈
none 模式--net=none不配置网络,用户可以稍后进入容器自行配置
container 模式--net=container:name/id容器和另一个容器共享 Network Namespace。Kubernetes 中的 Pod 就是多个容器共享一个 Network Namespace
host 模式--net=host容器和宿主机共享 Network Namespace
用户自定义--net=mynet用户使用 network 命令自定义网络,容器可通过容器名相互访问

Bridge 模式是 Docker 默认的网络模式。容器通过 veth pair 连接到宿主机的 docker0 虚拟网桥。容器访问外网时,依赖宿主机的 IP 转发(ip_forward) 和 NAT 伪装(MASQUERADE) 将源 IP 转换为宿主机 IP;外网访问容器则需依赖 -p 参数配置的 端口映射(DNAT)。该模式简单易用,但容器间无法通过容器名直接通信,生产环境更推荐使用用户自定义的桥接网络。”

默认情况下,桥接模式的容器都是和 docker0 网桥连接的

但是,两个独立的自定义 bridge 网络中的容器是不互通的,因为这两个自定义的bridge 网络相当于两个不同的网桥。如果想要这两个容器进行通信,建议把他们放到同一个自定义网络之中

3.2 容器网络实战

none 模式

Docker 网络 none 模式是指创建的容器没有网络地址,只有 lo 网卡。

在运行容器时通过 –net=none指定none模式

[root@hd1 ~]# docker run -td --name none --net=none alpine
[root@hd1 ~]# docker exec -it none /bin/bash
[root@05dbf3f2daaf /]# ip addr
# 只有 lo 本地回环地址

none模式的容器可以完全隔绝外部网络风险,也可以在容器启动后手动配置网络。适合那些需要精细控制网络的极少数场景

container 模式

新容器与已存在的容器共享 Network Namespace(IP 地址相同)。

这个模式主要用来保护后端的容器

# 先运行一个普通容器
[root@hd1 ~]# docker run --name container1 -td alpine

# 再运行一个 container2,与 container1 共享网络空间
# 它们的 IP 地址是一样的
[root@hd1 ~]# docker run --name container2 --net=container:container1 -it alpine

假设a是web容器,b是数据库容器,b共享了a的网络,但外人只能访问到a,不能访问到b,只有a能通过127.0.0.1:3306 来访问到b容器,由此实现对数据库的保护
(注意,本模式下,不能通过socket文件访问数据库)

host 模式

共享宿主机的网络,与 bridge 模式对比,host 有更好的网络性能(因为不用做nat转换,节省了cpu性能),适合生产环境的 Web 服务器。

# 运行一个 host 模式容器
[root@hd1 ~]# docker run --name host -t -d --net=host alpine

# 查看容器网络空间,与宿主机一致
[root@hd1 ~]# docker exec -it host /bin/sh
/ # ifconfig
# 可以看到 docker0、ens33 等宿主机网卡
docker0   Link encap:Ethernet  HWaddr 02:42:F3:37:84:CA  
          inet addr:172.17.0.1  Bcast:172.17.255.255  Mask:255.255.0.0
          inet6 addr: fe80::42:f3ff:fe37:84ca/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:21223 errors:0 dropped:0 overruns:0 frame:0
          TX packets:24498 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:989261 (966.0 KiB)  TX bytes:275566303 (262.7 MiB)

ens33     Link encap:Ethernet  HWaddr 00:0C:29:8E:B6:E3  
          inet addr:192.168.1.11  Bcast:192.168.1.255  Mask:255.255.255.0
          inet6 addr: fe80::20c:29ff:fe8e:b6e3/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1438040 errors:0 dropped:0 overruns:0 frame:0
          TX packets:812061 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:1531589551 (1.4 GiB)  TX bytes:1050714957 (1002.0 MiB)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:11736 errors:0 dropped:0 overruns:0 frame:0
          TX packets:11736 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:705184 (688.6 KiB)  TX bytes:705184 (688.6 KiB)

veth7c8f0a8 Link encap:Ethernet  HWaddr 4E:D0:AC:D7:35:3A  
          inet6 addr: fe80::4cd0:acff:fed7:353a/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:17 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:0 (0.0 B)  TX bytes:1366 (1.3 KiB)

vethd47c032 Link encap:Ethernet  HWaddr 46:5C:06:75:D0:62  
          inet6 addr: fe80::445c:6ff:fe75:d062/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:14 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:0 (0.0 B)  TX bytes:1076 (1.0 KiB)

3.3 自定义桥接网络(了解)

自定义网络中,容器不仅能通过 IP 地址通信,还能将容器名解析到 IP 地址,这种功能称为自动发现。Docker 默认的网络驱动是 bridge

# 创建一个自定义桥接网络 my-net
docker network create my-net

# 删除网络 my-net
docker network rm my-net

# 查看所有网络
docker network ls

# 查看网络的详细信息
docker network inspect my-net

四、镜像瘦身

镜像瘦身方法

方法说明
1. 使用 Alpine 此类小体型基础镜像Alpine 镜像极小(约 5MB)
2. 合并 RUN 指令(通过&&合并语句)减少镜像层数
3. 删除构建期间的临时文件&& rm -rf 清理缓存
4. 多阶段构建第一阶段编译,第二阶段只复制产物
5. 使用 DockerSlim第三方工具自动瘦身镜像
知识补充:jar包和war包

jar 包和 war 包都是 Java 生态中的压缩文件包,里面装着编译好的 Java 程序(.class 文件)、配置文件、依赖库等

二者区别:

  • JAR 包(Java Archive)
    • 自带嵌入式 Web 服务器(如 Tomcat、Jetty)。只要安装了 JRE(Java 运行环境),就能直接启动
  • WAR 包(Web Archive)
    • 必须放置在外部的 Web 服务器(如 Tomcat、Jetty、WebLogic)的 webapps 目录下才能运行。它自己不带“房子”

4.1 多阶段构建jar包

多阶段构建是指:第一阶段用maven工具构建/编译jar包;第二阶段将jar包通过dockerfile方式到精简基础镜像中。

案例:Java 应用多阶段构建

# ========== 第一阶段:构建阶段 ==========
# 使用 Maven 镜像来构建应用程序
FROM maven:3.8.6-openjdk-17 AS builder
# 设置工作目录
WORKDIR /app
# 首先只复制 pom.xml 文件(利用 Docker 缓存层),pom.xml 是 Maven 项目的核心配置文件
COPY pom.xml .
# 下载依赖(如果 pom.xml 没有变化,这层缓存会被复用)
RUN mvn dependency:go-offline -B
# 复制源代码
COPY src ./src
# 构建应用程序(打包并跳过测试)
RUN mvn package -DskipTests

# ========== 第二阶段:运行阶段 ==========
# 使用更小的基础镜像来运行应用程序
FROM openjdk:17-jre-slim
# 设置工作目录
WORKDIR /app
# 从构建阶段复制构建好的 JAR 文件
COPY --from=builder /app/target/*.jar app.jar
# 创建一个非 root 用户来运行应用程序(增强安全性)
RUN groupadd -r javaapp && useradd -r -g javaapp javaapp \
    && chown -R javaapp:javaapp /app
USER javaapp
# 暴露应用程序端口(根据应用程序调整)
EXPOSE 8080
# 设置 JVM 参数(根据需要进行调整)
ENV JAVA_OPTS="-Xms256m -Xmx512m"
# 运行应用程序
ENTRYPOINT java $JAVA_OPTS -jar app.jar

4.2 DockerSlim 瘦身

DockerSlim 是一个开源工具,可以对已经存在的镜像进行瘦身。
它可以自动分析容器运行时行为,剔除不需要的文件,大幅减小镜像体积。这个工具并非对所有容器都会生效

语法:mint build --target 要瘦身的镜像名 --tag 瘦身后的镜像名

下载与安装:

# 下载(从 GitHub Releases 获取最新版本)
wget https://github.com/slimtoolkit/slim/releases/download/1.40.11/dist_linux.tar.gz

# 解压
[root@hd1 ~]# tar xf dist_linux.tar.gz 

# 将 mint 和 mint-sensor 二进制文件移动到系统 PATH 下,方便后续使用
[root@hd1 ~]# cd dist_linux
[root@hd1 dist_linux]# mv mint /usr/local/bin/
[root@hd1 dist_linux]# mv mint-sensor /usr/local/bin/

使用 DockerSlim 瘦身:

# 将 nginx:latest 镜像瘦身为 nginx-slim:latest
[root@hd1 dist_linux]# mint build --target nginx:latest --tag nginx-slim:latest

# 查看瘦身效果
[root@hd1 ~]# docker images | grep nginx
nginx-slim                latest    bb0c8065d246   About a minute ago   11.7MB
nginx                     latest    d1a364dc548d   4 years ago          133MB

效果:从 133MB 瘦身到 11.7MB,体积减少约 91%

五、容器编排工具

Docker 公司为容器编排开发了两款工具:

工具说明
Docker Compose(docker软件自带)单台物理主机上编排容器
Docker Swarm跨主机容器编排工具(已逐渐被 K8s 取代,但仍在部分环境中使用)

5.1 Docker Compose 基础

  • 软件的架构分为两种:单体架构和微服务架构
    • 单体架构:对外体现为一个程序。
    • 微服务架构:大软件分成几个或多个单独的程序,每个功能一个独立容器,协同工作(适合复杂、高并发业务)(各个程序之间通过端口进行通信)

对于Java开发来说,单体架构主流开发框架是spring boot,微服务开发框架是spring cloud

一个复杂应用 = Nginx + Web + MySQL + Redis,不是启动一个容器就能完成的,需要多个不同的容器协作。

基本步骤:

  1. 创建一个目录
  2. 创建一个 docker-compose.yaml 文件,定义服务
  3. 使用 docker-compose 命令启动服务

一个容器对应一个应用 —— 微服务架构。

安装 Docker Compose

Docker Compose安装非常简单,下载一个compuse二进制文件,放到 PATH 目录,加执行权限即可。
因为 Docker Compose 是一个静态编译的二进制文件,它本身就是一个完整的可执行程序,下载后直接就能运行

compose文件可以从github上下载:https://github.com/docker/compose/releases/download/v5.1.2/docker-compose-linux-x86_64

# 上传 docker-compose 二进制文件
[root@hd1 ~]# mv docker-compose-linux-x86_64 /usr/bin/docker-compose
[root@hd1 ~]# chmod +x /usr/bin/docker-compose

5.2 Compose 文件的核心结构

一个标准的 docker-compose.yml 文件,主要包含以下四个顶级配置:

version: "3.8"  # 1. 版本声明
services:       # 2. 服务定义 (核心)
  web:
    # ... 服务配置
  db:
    # ... 服务配置
networks:       # 3. 网络配置 (可选)
  frontend:
volumes:        # 4. 卷配置 (可选)
  db-data:
1. 版本声明 (version,建议省略)
  • 作用:指定 Compose 文件格式的版本,不同版本支持的特性不同。
  • 说明:目前最常用的是 '3' 或更高版本(如 '3.8')。version 必须放在文件的第一行。

在现版本中建议省略版本声明,因为会警告,告诉你这个写了也会被忽略,不如删掉。这也是官方强烈建议的

2. 服务定义 (services,必须)

这是 Compose 文件的核心,用于定义需要运行的每一个容器

  • 基本配置
    • image: 指定容器使用的镜像,是必须的
    • container_name: 为容器指定一个自定义名称。如果不指定,Compose 会自动生成。
    • ports: 进行端口映射。
    • volumes: 挂载数据卷或宿主机目录。
    • environment: 设置环境变量。
    • depends_on: 定义服务之间的依赖关系和启动顺序
    • restart: 定义容器的重启策略。
    • command: 覆盖容器启动时的默认命令。
    • links: 在两个容器之间建立“通信链路”,让当前容器能够通过容器名(或自定义别名)访问另一个容器(也可以定义容器别名)。如果进行了网络定义,则不需要links
  • 高级配置
    • build: 指定从 Dockerfile 构建镜像。
    • networks: 指定容器连接到哪个网络(需要和顶层字段networks进行区分,顶层networks主要用来定义/声明网络)。

Nginx + PHP + MySQL 的示例:

services:
  # =============================================
  # 员工1:Nginx(前台接待)
  # =============================================
  nginx:
    image: nginx:alpine           # 用哪个系统镜像(装了什么软件)
    container_name: web-nginx     # 给容器起个固定名字(不写则自动生成)
    ports:                        # 门牌号映射(宿主机:容器内)
      - "8080:80"                 # 宿主机访问 8080,就等于进了容器的 80 端口
    volumes:                      # 文件共享(宿主机文件夹 ↔ 容器文件夹)
      - ./html:/usr/share/nginx/html   # 宿主机当前目录的 html 文件夹,挂载成容器的网站根目录
    depends_on:                   # 依赖关系(先启动谁)
      - php                       # 必须先启动 php,再启动我(nginx)
      - mysql
    networks:                     # 加入哪个局域网
      - webnet                    # 加入名叫 webnet 的网络,大家用容器名互相访问

  # =============================================
  # 员工2:PHP(后厨大师傅)
  # =============================================
  php:
    image: php:8.2-fpm-alpine     # 用 PHP-FPM 专用镜像
    container_name: web-php
    volumes:                      # 共享同一个 html 文件夹(和 nginx 看同一份菜谱)
      - ./html:/var/www/html      # 挂载路径和 nginx 不一样,但指向宿主机同一个目录
    networks:
      - webnet

  # =============================================
  # 员工3:MySQL(仓库管理员)
  # =============================================
  mysql:
    image: mysql:8.0
    container_name: web-mysql
    environment:                  # 环境变量(相当于入职时给的“初始配置”)
      MYSQL_ROOT_PASSWORD: 123456   # 设置 root 密码
      MYSQL_DATABASE: wordpress     # 自动创建一个名叫 wordpress 的数据库
    volumes:
      - mysql_data:/var/lib/mysql   # 重要!这里用的是 Docker 卷(不是 ./开头),数据由 Docker 管理,删除容器数据还在
    networks:
      - webnet

# =============================================
# 员工们的“公司内部网络”(大家通过名字互相找)
# =============================================
networks:
  webnet:                         # 定义这个网络(默认就是 bridge 模式)
    driver: bridge

# =============================================
# Docker 管理的仓库(存放 MySQL 的数据,防止丢失)
# =============================================
volumes:
  mysql_data:                     # 只声明一个名字,让 Docker 帮我们管理存储位置
3. 网络配置 (networks,可选)
  • 作用:定义容器之间通信的网络。如果不自定义,Compose 会为项目创建一个默认网络,服务可以通过服务名相互访问。

Web 服务 + Redis 缓存的微型架构示例:

services:
  web:
    image: python:3.9-alpine
    # 模拟一个简单的 Web 服务(这里用 sleep 保持运行,便于演示)
    command: sh -c "while true; do echo 'Web running...'; sleep 10; done"
    ports:
      - "5000:5000"
    networks:
      - app-net
    # 不写 links,直接通过 redis 容器名访问

  redis:
    image: redis:alpine
    networks:
      - app-net
    # 暴露端口给其他容器,但不映射到宿主机
    # 不需要 ports,只在内部网络通信

# 定义自定义网络
networks:
  app-net:
    driver: bridge   # 默认就是 bridge,可以省略

这里的网络模式和我们前文的网络模式一致

4. 卷配置 (volumes,可选)
  • 作用:定义数据卷,用于持久化存储数据。Bind Mount 还是 Docker Volume,具体看写法:
    • 路径以 . 或 / 开头,指定宿主机具体目录,为Bind Mount
    • 没有 / 或 .,只是一个名字,为Docker Volume具名卷。只写了容器路径,就是匿名卷

示例:

services:
  # Web 应用(Nginx)
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      # 【Bind Mount】挂载宿主机当前目录下的 ./html 到容器网页目录
      # 修改本地 html 文件,容器内实时生效(适合开发调试)
      - ./html:/usr/share/nginx/html
      # 【命名卷】挂载 nginx-logs 卷到日志目录
      # 容器删除后日志依然保留,方便排查问题
      - nginx-logs:/var/log/nginx

  # 数据库(MySQL)
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: myapp
    volumes:
      # 【命名卷】mysql-data 存放数据库文件
      # 即使容器删除重建,数据也不会丢失(持久化存储)
      - mysql-data:/var/lib/mysql
      # 【Bind Mount】挂载宿主机的 ./init-sql 目录到容器初始化目录
      # 把 SQL 文件放进去,容器首次启动时会自动执行(方便初始化)
      - ./init-sql:/docker-entrypoint-initdb.d

# ==============================================
# 顶层声明(Define Volumes)
# 这里声明的卷,会被服务里的挂载引用
# ==============================================
volumes:
  # 声明一个名为 mysql-data 的卷(由 Docker 管理,存储位置在 /var/lib/docker/volumes/)
  mysql-data:
    driver: local

  # 声明一个名为 nginx-logs 的卷
  nginx-logs:
    driver: local

volumes在顶层配置时只具备声明的作用,声明我要配置哪些卷,方便在后续进行引用


5.3 常用配置详解

1. 端口映射 (ports)

ports 用于将容器内部的端口映射到宿主机上。两种常用写法:

写法示例说明
短语法- "8080:80"宿主机端口 8080 映射到容器端口 80
长语法- target: 80
published: 8080
protocol: tcp
mode: host
更明确地指定目标端口、发布端口、协议和模式。
2. 数据卷挂载 (volumes)

volumes 用于持久化数据或在宿主机和容器间共享文件。

写法示例说明
短语法- ./html:/usr/share/nginx/html宿主机目录 ./html 挂载到容器目录。
短语法- db-data:/var/lib/mysql使用名为 db-dataDocker 数据卷进行持久化。
长语法- type: volume
source: db-data
target: /var/lib/mysql
明确指定挂载类型、源和目标的配置。
3. 环境变量 (environmentenv_file)

用于向容器传递配置信息。

  • environment: 直接在文件中定义环境变量。
  • env_file: 从外部文件(如 .env)加载环境变量。环境变量通常以 KEY=VAL 的格式写在 .env 文件中,然后在 Compose 文件中引用,例如 DB_PASSWORD: ${DB_PASS}
4. 依赖控制 (depends_on)
  • 作用:控制服务的启动顺序。例如,先启动 db 再启动 web
  • 注意depends_on 只控制启动顺序,不保证依赖的服务已经完全就绪(比如数据库已准备好接受连接)。如果需要更健壮的就绪检查,可以配合 condition 使用。

(WordPress + MySQL)常用配置完整示例(注意,仅是方便理解的示例):

services:
  # ============================================================
  # 服务1:WordPress(网站应用)
  # ============================================================
  wordpress:
    image: wordpress:latest
    container_name: my-wordpress
    ports:
      # 【知识点1:端口映射 - 短语法】
      - "8080:80"
      # 宿主机 8080 → 容器 80
    environment:
      # 【知识点3:环境变量 - environment 方式(直接定义)】
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress123
      WORDPRESS_DB_NAME: wordpress
    env_file:
      # 【知识点3:环境变量 - env_file 方式(从外部文件加载)】
      - ./wp.env
      # 可以把重复的变量放外部文件,避免写太长
    volumes:
      # 【知识点2:数据卷挂载 - bind mount(宿主机目录)】
      - ./wp-content:/var/www/html/wp-content
      # 宿主机 ./wp-content → 容器插件/主题目录(方便开发调试)
    depends_on:
      # 【知识点4:依赖控制】
      - db
      # 先启动 db,再启动 wordpress
    networks:
      - wp-net

  # ============================================================
  # 服务2:MySQL(数据库)
  # ============================================================
  db:
    image: mysql:8.0
    container_name: my-db
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress123
    volumes:
      # 【知识点2:数据卷挂载 - Docker 命名卷(短语法)】
      - db-data:/var/lib/mysql
      # db-data 是 Docker 管理的卷,删除容器数据还在
    ports:
      # 【知识点1:端口映射 - 长语法(更明确)】
      - target: 3306
        published: 33060
        protocol: tcp
        mode: host
      # 宿主机 33060 → 容器 3306
    networks:
      - wp-net

# ============================================================
# 网络定义
# ============================================================
networks:
  wp-net:
    driver: bridge

# ============================================================
# 卷定义(声明 Docker 管理的命名卷)
# ============================================================
volumes:
  db-data:
    # 只声明名子,不指定路径,Docker 自动在 /var/lib/docker/volumes/ 下创建

配套的外部环境变量文件(wp.env)内容:

# wp.env 内容(和 environment 配合使用)
WORDPRESS_TABLE_PREFIX=wp_
WORDPRESS_DEBUG=true

这样可以把敏感信息或环境差异放在外部,不用改 Compose 文件

5.4 案例1:Haproxy 负载均衡 Web 应用

架构图: Haproxy 作为负载均衡器,将请求分发到 web1 和 web2 两个 Nginx 节点。

请添加图片描述

[root@hd1 ~]# mkdir h1
[root@hd1 ~]# cd h1
创建 Haproxy 配置文件
[root@hd1 h1]# mkdir haproxy
[root@hd1 h1]# vim haproxy/haproxy.cfg
#添加下面的配置
global  #全局 配置段
    log         127.0.0.1 local2
    maxconn     4000     #最大并发连接数
    user        haproxy
    group       haproxy
defaults   #默认配置段,自动继承给后面的 frontend 和 backend,除非它们在各自段里重写
    mode                    http
    log                     global
    option                  httplog
    option                  dontlognull
    timeout http-request    10s
    timeout queue           1m
frontend balancer
    bind 0.0.0.0:8081       #前端暴露的端口号,Haproxy 在所有网卡(0.0.0.0)的 8081 端口上监听请求
    default_backend   app   #跳向后端的集群名
backend app                 #后端集群
    balance     roundrobin
    server  web1 web1:80 check
    server  web2 web2:80 check
编写 docker-compose.yaml
[root@hd1 h1]# vim docker-compose.yaml 
version: '3'  #版本号,可以去掉
services:     #定义三个微服务,web1,web2,haproxy_load
  web1:
    image: nginx:latest #指定镜像
    restart: always     #设置重启策略
    volumes:
    - ./web1:/usr/share/nginx/html/   #映射
  web2:
    image: nginx:latest
    restart: always
    volumes:
    - ./web2:/usr/share/nginx/html/

  haproxy_load:
    image: haproxy
    user: "0"    #用户为root
    privileged: true
    volumes:
    - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro   #只读权限
    links:   #表示连接,连接两个容器
    - web1
    - web2
    ports:   #端口映射
    - "8081:8081"
    expose:
    - "8081"

注意,这里的volumes 写法,本质上就是 Bind Mount(绑定挂载)

links 已过时:在新版 Docker Compose 中,links 不是必需的,服务可以通过服务名直接通信(自定义网络中默认支持)。

需注意,在配置容器的 Haproxy 负载均衡时,后端服务器要使用容器名进行指定,不能使用 IP 地址进行指定。

准备 Web 页面
[root@hd1 h1]# mkdir web1 web2
[root@hd1 h1]# echo "111" > web1/index.html
[root@hd1 h1]# echo "222" > web2/index.html
启动与测试

注意!docker-compose命令必须在docker-compose.yaml当前目录才能使用

# 导入 Haproxy 镜像(如果有离线包)
[root@hd1 h1]# docker load -i haproxy.tar.gz
#或者直接从官网拉取haproxy
[root@hd1 h1]# docker pull haproxy

# 启动 compose(前台运行)。注意!前台运行会出现类似“卡住”的现象,建议使用后台启动
[root@hd1 h1]# docker-compose up

# 后台启动
[root@hd1 h1]# docker-compose up -d

# 停止容器
[root@hd1 h1]# docker-compose stop

# 删除容器
[root@hd1 h1]# docker-compose rm

# 查看 compose 启动的服务
[root@hd1 h1]# docker-compose ps --services

测试负载均衡:

[root@hd1 h1]# curl 192.168.1.11:8081
111
[root@hd1 h1]# curl 192.168.1.11:8081
222

5.4 案例2:Python Flask + Redis 访问计数器

创建一个 Python Web 应用,使用 Flask 框架,将用户访问次数写入 Redis,通过 Web 首页显示访问次数。

创建工程目录
mkdir pythondir
cd pythondir
创建 Flask 应用
cat > app.py << 'EOF'
from flask import Flask
from redis import Redis
app = Flask(__name__)
redis = Redis(host='redis',port=6379)

@app.route('/')
def hello():
    redis.incr('hits')
    return 'Hello world! I have been seen %s time.' % redis.get('hits')

if __name__ == "__main__":
    app.run(host="0.0.0.0",debug=True)
EOF
编写 Dockerfile
cat > Dockerfile << 'EOF'
FROM python:3.6-alpine
ADD . /code
WORKDIR /code
RUN pip install redis flask
CMD python app.py
EOF
编写 docker-compose.yml
cat > docker-compose.yml << 'EOF'
version: '3'
services:
  web:
    build: .
    ports:
    - "5000:5000"
  redis:
    image: "redis:alpine"
EOF
启动与测试
# 启动两个容器(-d 表示后台启动)
docker-compose up -d

# 查看状态
docker-compose ps

# 查看日志
docker-compose logs

访问测试:
浏览器访问 http://192.168.1.11:5000,每次刷新页面,访问次数会累加。

Hello world! I have been seen 1 time.
Hello world! I have been seen 2 time.

5.5 常用 Compose 命令

所有命令都需要在 docker-compose.yml 文件所在的目录下执行。

  • docker compose up -d:在后台创建并启动所有服务。-d 代表“detach”模式。
  • docker compose down:停止并删除所有容器、网络等资源。
  • docker compose logs -f:实时查看所有服务的聚合日志。
  • docker compose ps:列出当前项目所有容器的状态。
  • docker compose exec <服务名> <命令>:在指定的运行中容器内执行命令。

六、Docker Compose 常用命令速查

命令说明
docker-compose up启动所有服务(前台运行)
docker-compose up -d启动所有服务(后台运行)
docker-compose stop停止所有服务
docker-compose start启动已停止的服务
docker-compose restart重启所有服务
docker-compose down停止并删除所有容器
docker-compose rm删除已停止的容器
docker-compose ps查看服务状态
docker-compose logs查看服务日志
docker-compose logs -f实时查看日志
docker-compose exec <服务名> <命令>在服务容器中执行命令
docker-compose build重新构建镜像
docker-compose pull拉取最新镜像

七、核心知识点总结

7.1 构建镜像要点

  • FROM:指定基础镜像,尽量选择 Alpine 以减小体积
  • RUN:构建时执行的命令,多条 RUN 应合并以减小层数
  • COPY vs ADD:优先使用 COPYADD 有自动解压功能
  • CMD vs ENTRYPOINTCMD 可被覆盖,ENTRYPOINT 固定主进程
  • EXPOSE:声明端口(仅文档作用,实际映射需用 -p

Dockerfile我们按照:FROMRUNCOPYENTRYPOINT 的套路来写

7.2 网络模式选择

--net=<模式>

场景推荐模式
默认单机容器bridge(默认)
需要高性能网络host
调试/测试隔离none
多个容器共享网络container
生产环境多服务用户自定义 bridge

7.3 镜像瘦身原则

  1. 使用 Alpine 基础镜像
  2. 合并 RUN 指令,减少层数
  3. 构建后删除临时文件
  4. 多阶段构建(编译产物分离)
  5. 使用 DockerSlim 工具自动瘦身

7.4 Docker Compose 使用场景

  • 多容器本地开发环境
  • 微服务架构的单机部署
  • CI/CD 测试环境
  • 生产环境建议使用 K8s 替代 Swarm

八、踩坑记录

以下是我在实操过程中遇到的问题,以及问题解决的记录

1. Alpine 镜像的 sed -i 替换源失败(apk 源格式差异)

现象
在基于 alpine:latest 的 Dockerfile 中执行:

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories

构建时报错 sed: can't read /etc/apk/repositories: No such file or directory,或替换后 apk update 依然超时。

原因

  • 新版 Alpine(如 v3.18+)的 /etc/apk/repositories 文件内容可能不是简单的 http://dl-cdn.alpinelinux.org/... 格式,还可能包含 @edge 等标签。
  • sed 的正则替换要求原字符串必须完全匹配,如果格式有细微变化(如 https 替换 http),替换就会失效。
  • 有些版本 Alpine 的仓库源写在 /etc/apk/repositories,但格式是 http://dl-cdn.alpinelinux.org/alpine/v3.18/main,直接替换域名即可。

解决方案

方案一:使用 sed 进行更通用的域名替换

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories

先进入容器用 cat /etc/apk/repositories 查看实际内容,确认域名是否是 dl-cdn.alpinelinux.org

方案二:直接覆盖整个 repositories 文件(最稳妥)

RUN echo "http://mirrors.aliyun.com/alpine/v3.18/main" > /etc/apk/repositories && \
    echo "http://mirrors.aliyun.com/alpine/v3.18/community" >> /etc/apk/repositories

方案三:使用 --repository 参数临时指定源(不修改源配置文件)

RUN apk add --no-cache --repository http://mirrors.aliyun.com/alpine/v3.18/main nginx

如果 Alpine 版本固定,直接查清该版本的官方源格式,再针对性替换。或者直接用 sed 只替换域名部分,不要整行替换。

2. RUN 指令拆开写导致镜像体积膨胀

现象
最初构建镜像的时候,将RUN指令分散拆开,结果构建出的镜像体积比预期大很多,一个简单的 Nginx 镜像居然有 200+ MB。

原因
Dockerfile 中的每条 RUNCOPYADD 指令都会生成一个独立的镜像层。如果多个 RUN 没有合并,每一层都会保留中间状态的文件系统快照,即使后面删除了文件,这些文件在之前的层中依然存在,导致镜像体积无法真正减小。

错误写法

FROM alpine:latest
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk update
RUN apk add --no-cache nginx
RUN rm -rf /var/cache/apk/*

正确写法(合并 RUN)

FROM alpine:latest
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
    apk update && \
    apk add --no-cache nginx && \
    rm -rf /var/cache/apk/*

记住RUN 指令内部的 && 只是 Shell 语法,不会增加层数;但每条独立的 RUN 都会增加层数。

查看层数

docker history 镜像名:标签

可以看到每一层的大小,从而定位是哪一层产生了大文件。

3. OverlayFS 的“上层遮蔽下层”导致调试困惑

现象
进入容器后,发现某个目录下明明有文件,但在宿主机对应的 OverlayFS 下层目录里却找不到,或者修改了某个文件后,宿主机上对应镜像的层却没有变化,让人怀疑文件到底存不存在。

原因
OverlayFS 的机制是上层(容器层)会遮蔽下层(镜像层)。当你修改了容器内 /etc/nginx/nginx.conf 后:

  • 这个文件被**写时复制(CoW)**到了容器层(/var/lib/docker/overlay2/<容器ID>/diff/)。
  • 底层的镜像层文件依然保持原样,没有变化。
  • 从容器内看,文件是修改后的;从宿主机看镜像层,文件还是旧的。

验证方法

# 1. 查看容器的 OverlayFS 挂载信息
docker inspect 容器名 | grep -A 10 "GraphDriver"

# 2. 进入容器的联合挂载点查看(merged 目录是“合并视图”)
cd /var/lib/docker/overlay2/<容器ID>/merged/
# 这里看到的是合并后的文件系统(镜像层 + 容器层的合并结果)

# 3. 查看容器的“差异层”(只有容器修改过的文件)
cd /var/lib/docker/overlay2/<容器ID>/diff/

容器内看到的文件 = 镜像层(只读) + 容器层(可写,优先遮蔽下层同名文件)。修改文件后,只是容器层多了一份副本,镜像层纹丝不动。

4. --net=host 模式下端口冲突导致服务无法启动

现象
执行 docker run --net=host nginx,发现 Nginx 报错 Address already in use,容器启动失败。

原因
host 模式让容器直接使用宿主机的网络栈,容器内的端口直接绑定在宿主机上。如果宿主机上已经有一个 Nginx 占用了 80 端口,容器里的 Nginx 再去绑定 80 端口,就会因为端口冲突而失败。

解决方案

  • 方案一:确认宿主机端口未被占用,或停止占用该端口的进程。
  • 方案二:如果宿主机的 80 端口被其他服务占用,可以在容器内改用其他端口,但 host 模式下无法像 bridge 模式那样做端口映射(如 -p 8080:80),因为 host 模式下容器直接使用宿主机端口,没有“映射”一说。
  • 方案三(推荐):不需要极致网络性能时,直接使用默认的 bridge 模式 + -p 端口映射,避免端口冲突。
# 检查宿主机端口占用
ss -tlnp | grep 80

5. 自定义桥接网络中容器无法访问外网

现象
创建了自定义桥接网络 docker network create my-net,并将容器加入该网络后,容器内 ping 8.8.8.8 无响应,并且 curl 外网超时。

原因
自定义桥接网络默认也是 bridge 驱动,理论上应该和默认 docker0 一样具备 NAT 转发能力。但可能的原因有:

  1. 宿主机未开启 IP 转发net.ipv4.ip_forward=1)。
  2. 自定义网络创建时指定了 --internal 参数,导致该网络无法访问外网。
  3. 防火墙/iptables 规则被清空或覆盖(如 iptables -F 会删掉 Docker 添加的 NAT 规则)。

排查步骤

# 1. 检查 IP 转发是否开启
sysctl net.ipv4.ip_forward
# 应该输出 1,如果是 0 则需开启

# 2. 检查自定义网络是否有 internal 标记
docker network inspect my-net | grep internal
# 如果为 true,则该网络完全隔离,无法访问外网

# 3. 检查 NAT 规则是否存在
iptables -t nat -L -n | grep MASQUERADE
# 应该有针对容器网段的 MASQUERADE 规则

解决方案

# 开启 IP 转发(临时)
sysctl -w net.ipv4.ip_forward=1
# 永久开启(写入 /etc/sysctl.conf)
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p

# 如果是 internal 网络,重建网络(不加 --internal)
docker network rm my-net
docker network create my-net

6. DockerSlim 瘦身后容器启动失败或功能缺失

现象
使用 mint build --target nginx:latest --tag nginx-slim:latest 瘦身完成后,基于瘦身镜像启动容器,发现 Nginx 报错缺少某些文件或动态链接库(如 error while loading shared libraries),或者某些功能(如 SSL、Gzip)失效。

原因
DockerSlim 的工作原理是动态分析:它会在容器运行时监控所有被访问的文件和系统调用,然后只保留“被使用”的文件,删除“未被使用”的文件。

  • 如果某个文件只有在特定条件下才会被访问(如错误页面、某些模块懒加载),DockerSlim 在分析期间没有触发该条件,就会认为该文件是“无用”的并删除。
  • 动态链接库(.so 文件)如果只在特定模块中被依赖,也可能被误删。

解决方案

  • 方案一:在瘦身时添加 --include-path--include-file 参数,强制保留某些目录或文件。
    mint build --target nginx:latest --tag nginx-slim:latest \
      --include-path /usr/lib/nginx/modules \
      --include-path /etc/nginx/conf.d
    
  • 方案二:如果瘦身导致功能严重缺失,说明该镜像不适合用 DockerSlim 自动瘦身,考虑手动优化 Dockerfile(如用 Alpine 基础镜像、多阶段构建)。
  • 方案三(调试):用 --exec 参数进入瘦身后的容器,手动排查缺失文件。
    # 瘦身时添加 --exec 参数,在瘦身完成后可以进入容器检查
    mint build --target nginx:latest --tag nginx-slim:latest --exec
    

DockerSlim 适合“通用型”镜像(如纯静态 Nginx、Node.js 应用),对于依赖复杂、需要特定运行时环境的镜像(如 Python 科学计算、Java 应用),效果可能不理想,优先选择多阶段构建。

7. 镜像层数过多导致 docker push/pull 极慢

现象
构建了一个镜像,docker push 到私有仓库或 docker pull 到其他机器时,速度异常缓慢,且推送/拉取过程中出现大量 layer 传输。

原因
镜像的每一层在推送/拉取时都是独立的压缩包。如果 Dockerfile 写得不好(比如每安装一个软件包就写一个 RUN),会导致镜像层数非常多。Docker 在传输时需要逐个计算校验和、压缩、传输、解压,层数越多,耗时就成倍增加。

查看层数

docker history 镜像名:标签

如果输出超过 10 行(不含 <missing> 的基础镜像层),说明层数偏多,需要进行瘦身优化。

解决方案

  • 合并 RUN 指令(见踩坑记录 2)。
  • 使用 --squash 参数构建镜像(实验性功能,合并所有层为一个层,但会丢失层缓存优势):
    # 需要开启 Docker 实验性功能(/etc/docker/daemon.json 添加 "experimental": true)
    docker build --squash -t 镜像名:标签 .
    
  • 多阶段构建(生产推荐):最终阶段只 COPY 必要的产物,层数很少。

8. 容器 hostname 无法通过 /etc/hosts 修改

现象
进入容器后执行 echo "127.0.0.1 myapp" >> /etc/hosts,发现修改不生效或容器重启后丢失。

原因
容器的 /etc/hosts 文件由 Docker 运行时动态管理,它不是存储在容器层中的普通文件,而是通过 OverlayFS 的 mount 方式挂载进来的。每次容器启动时,Docker 会根据 --hostname 参数重新生成。

正确设置容器 hostname

# 启动容器时指定 hostname
docker run --hostname myapp -td alpine

# 或使用 --add-host 添加自定义 hosts 解析
docker run --add-host myservice:192.168.1.100 -td alpine

验证

docker exec -it 容器名 cat /etc/hostname
docker exec -it 容器名 cat /etc/hosts

/etc/hosts 中会看到 172.17.0.x myapp 这样的映射。

注意:不要试图在容器内手动修改 /etc/hosts 来持久化主机名,重启容器后会丢失。生产环境如果需要容器间通过自定义域名通信,建议使用自定义桥接网络(容器名自动解析)或服务发现(Consul、Eureka)来解决。

9. docker rmi -f 强制删除镜像后,依赖该镜像的容器依然存在

现象
执行 docker rmi -f nginx:latest 成功删除了镜像,但 docker ps -a 看到之前基于该镜像创建的容器依然还在(状态为 ExitedRunning)。

原因
docker rmi -f 强制删除镜像,只是在镜像列表中移除了该镜像的“标签引用”,但实际上容器依然持有对该镜像层数据的引用。如果容器正在运行,该镜像的文件系统层依然存在于磁盘上,不会真正被清理。等容器被删除后,这些层才会被垃圾回收。

后果

  • 磁盘空间没有真正释放(容器还在引用这些层)。
  • 如果该镜像是某个容器的唯一依赖,你无法用 docker run 再从这个已删除的镜像创建新容器,但已有容器依然能正常运行。

正确做法

# 先删除依赖该镜像的所有容器(包括停止的)
docker rm -f $(docker ps -aq --filter ancestor=nginx:latest)

# 再删除镜像
docker rmi nginx:latest

# 或者一次性清理所有未被使用的镜像(更安全)
docker image prune -a

教训-f 不等于“真正彻底删除”,它只是“强制移除引用”。想要真正释放空间,必须确保没有容器在使用该镜像。

更多推荐