Docker从入门到实践

简要概述

Docker只是容器的一种实现(基于Go语言),是一个容器化的解决方案和平台

而容器是一种虚拟化技术,和虚拟机类似,也是一个独立的环境。但容器不需要运行一个完整的操作系统,而是直接复用本地主机的操作系统(传统方式是在硬件层面实现),所以启动更快

所以容器对比虚拟机的优势为:启动快、硬盘占用相对低、性能更好、运行时的系统的支持量多


镜像

只读模板,可用于创建Docker容器

获取镜像:docker pull ...

  • 下载过程中,会输出获取镜像的每一层信息。

列出本地镜像:docker images

  • 会显示来自于哪个仓库、镜像的标记、它的 ID 号、创建时间、镜像大小

修改已有镜像:docker commit ...

  • -m 来指定提交的说明信息:-m "Added json gem"

  • -a 可以指定更新的用户信息:-a "Docker Newbee"

  • 用来创建镜像的容器的 ID:0b2616b0e5a8

  • 最后指定目标镜像的仓库名和 tag 信息:ouruser/sinatra:v24f177bd27a9ff0f6dc2a830403925b5360bfe0b93d476f7fc3231110e7f71b1c

    创建成功后会返回这个镜像的 ID信息。

上传镜像:docker push ...

镜像导出为本地文件:docker save -o ...

镜像导出为本地文件:docker load --input ...tardocker load < ...tar

删除本地镜像:docker rmi ...

实现原理

每个镜像都由很多层次构成

Docker将这些不同的层结合到一个镜像中去,允许在镜像不变的基础上允许用户在其上进行一些写操作


容器

容器是镜像创建的运行实例,可被启动、开始、停止、删除。

容器间相互隔离,可以把其看做是一个简易版的 Linux 环境(包括root用户权限、进程空间、用户空间和网络空间等)和运行在其中的应用程序。

注:镜像是只读的,容器在启动的时候创建一层可写层作为最上层。

基于镜像新建并运行容器:docker run ...

启动终止运行的容器:docker start ...

终止正在运行的容器:docker stop ...

重启一个存在的容器:docker restart ...

查看启动的容器信息:docker ps

  • -a查看所有启动和未启动的容器

删除一个存在的容器:docker rm ...

查看容器输出信息:docker logs ...

进入容器:docker attach ...

  • 使用该命令,多窗口同时进入同一个容器时会同步显示

容器导出到本地为快照:docker export ... > ...tar

容器快照导入到镜像:docker import


数据管理

如何在 Docker 内部以及容器之间管理数据,在容器中管理数据主要有两种方式:数据卷、数据卷容器

数据卷

它是一个可供一个或多个容器使用的特殊目录

  • 数据共享:可以在容器之间共享和重用;

    ​ 多个容器可以挂载同一个数据卷来共享数据

  • 数据持久化:数据卷的更新不会影响镜像;

    1
    即使容器被删除,数据卷中的数据也不会丢失
  • 性能优化:数据卷的读写速度通常比容器的镜像层更快

数据卷的使用类似于 Linux 下对目录或文件进行 mount

创建数据卷:

使用docker run的时候使用-v标记创建一个数据卷并挂载到容器中

  • 一次run可以多次使用,挂载多个数据卷

  • 创建一个 web 容器,并加载一个数据卷到容器的/webapp目录。

    1
    sudo docker run -d -P --name web -v /webapp training/webapp python app.py

只挂载一个数据卷而不进行任何操作,通常不会对容器的存储空间产生显著影响,但可能会带来一些管理和性能方面的考虑。如果你不需要数据卷,可以考虑不使用它,或者在使用后删除数据卷以释放资源。

也可以在 Dockerfile 中使用 VOLUME 来添加一个或者多个新的卷到由该镜像创建的任意容器。

挂载一个主机目录作为数据卷:

使用-v标记也可以指定挂载一个本地主机的目录到容器中去。

  • 容器目录的路径必须是绝对路径,如果目录不存在 Docker 会自动为你创建它。

  • 加载主机的 /src/webapp 目录到容器的 /opt/webapp 目录

    1
    sudo docker run -d -P --name web -v /src/webapp:/opt/webapp training/webapp python app.py
  • Docker挂载数据卷的默认权限是读写,用户也可以通过:ro指定为只读。

    1
    -v /src/webapp:/opt/webapp:ro

数据卷容器

作用:容器之间共享、持续更新数据

数据卷容器:正常容器,专门用来提供数据卷供其它容器挂载

  1. 使用-v挂载数据卷容器

    1
    sudo docker run -d -v /dbdata --name dbdata training/postgres echo Data-only container
  2. 在其他容器中使用--volumes-from来挂载 dbdata 容器中的数据卷

    1
    sudo docker run -d --volumes-from dbdata --name db1 training/postgres

访问:在 db1 容器内部,可以直接访问 /dbdata 目录来读取或写入数据。这个目录的内容将在所有挂载了相同数据卷的容器之间共享。

删除:如果要删除一个数据卷,必须在删除最后一个还挂载着它的容器时使用 docker rm -v 命令来指定同时删除关联的容器。


网络

可通过外部访问容器或容器互联的方式来提供网络服务。

Docker 提供了几种网络模式:

  1. 桥接模式(Bridge)
    • 默认的 Docker 网络模式。在这种模式下,Docker 为每个容器分配一个独特的 IP 地址,并创建一个虚拟网桥(通常名为 docker0),将容器连接到宿主机的网络。
    • 容器可以与其他容器在同一网桥上通信,也可以访问外部网络。
  2. 宿主机模式(Host)
    • 在这种模式下,容器共享宿主机的网络命名空间。这意味着容器使用宿主机的 IP 地址和端口,而不是自己的 IP 地址。
    • 容器可以直接与其他容器和外部网络通信,但这也意味着容器更容易受到网络攻击。
  3. 无网络模式(None)
    • 在这种模式下,容器没有分配网络接口或 IP 地址。这通常用于需要自定义网络配置的场景。
    • 容器在这种模式下无法进行网络通信,直到你手动配置网络。
  4. 自定义网络(User-Defined Networks)
    • 用户可以创建自己的网络,以满足特定的网络需求。自定义网络可以是覆盖网络(overlay networks),允许跨多个 Docker 守护进程的容器通信。
    • 自定义网络提供了更多的灵活性和控制,例如网络隔离、网络安全组等。
  5. Macvlan 网络模式
    • Macvlan 模式允许容器拥有自己独立的 MAC 地址,可以直接连接到物理网络。这种模式适用于需要容器直接在外部网络中可见的场景。
  6. 网络插件
    • Docker 网络也支持第三方网络插件,如 Weave, Calico, Flannel 等,提供了更高级的网络功能,如网络加密、网络策略等。

用户定义网络:

1
2
docker network create -d bridge my-net
docker run --network=my-net -itd --name=container3 busybox
  1. 创建my-net的bridge网络
  2. run时通过--network指定容器运行所在的网络类型

直接加入容器所在网络:

1
2
docker run -d --name redis example/redis --bind 127.0.0.1
docker run --rm -it --network container:redis example/redis-cli -h 127.0.0.1
  1. -d:以守护进程模式运行容器(后台运行)

    --bind 127.0.0.1:绑定容器的地址,通过该地址与其他IP通信

    *这意味着 Redis 服务将只接受来自本机的连接请求。这是一个安全措施,可以防止外部主机直接访问 Redis 服务。。

  2. --rm:容器退出时自动清理容器文件系统(删除容器)

    --network container:redis将此容器连接到名为 redis 的容器的网络

    -h 127.0.0.1:指定 Redis CLI 服务的主机名,这里是 127.0.0.1,因为 Redis 服务被绑定到了这个地址。

更多见:docker文档中文站

外部访问容器

要让外部也可以访问这些应用,可以通过-P -p参数来指定端口映射。

  • -P:当使用-P标记时,Docker 会随机映射一个49000~49900的端口到给内部容器的端口。

  • -p:可以指定要映射的端口,并且,在一个指定端口上只可以绑定一个容器

    格式为左边主机、右边容器,如下:

    • 映射指定IP的指定端口ip:hostPort:containerPort,如:192.168.1.1:5000:5000
    • 映射到指定IP的任意端口ip::containerPort,如:192.168.1.1::5000
    • 映射本地所有IP到该端口hostPort:containerPort,如:5000:5000

    还可以使用udp标记来指定 udp 端口

    1
    sudo docker run -d -p 127.0.0.1:5000:5000/udp training/webapp python app.py

查看映射端口

用法一:docker port 容器名称

1
2
3
4
80/tcp -> 0.0.0.0:80
80/tcp -> [::]:80
443/tcp -> 0.0.0.0:443
443/tcp -> [::]:443

解释:左边为容器内部端口,右边为映射的主机地址

  • [::] 表示宿主机的 IPv6 地址。[::] 是 IPv6 中的一个特殊地址,相当于 IPv4 中的 0.0.0.0,表示监听宿主机的所有 IPv6 地址

用法二:docker port 容器名称 端口

1
2
3
# 示例上述端口为80
0.0.0.0:80
[::]:80

解释:会显示绑定了容器80端口的,当前的主机地址端口

  • 如容器没有映射该端口,则会抛出Error

防火墙

在 Linux 上,Docker 操纵iptables规则来提供网络隔离。

Docker 安装了两个自定义的 iptables 链:DOCKERDOCKER-USER

  • DOCKER 链用于 Docker 创建的所有规则
  • DOCKER-USER 链用于用户定义的规则,这些规则在 Docker 规则之前应用。

安全

Docker是在Linux操作系统层面上的虚拟化实现,运行在容器内的进程,跟运行在本地系统中的进程,本质上并无区别。所以,Docker容器的安全性,很大程度上其实依赖于Linux系统自身。

在评估 Docker 的安全性时,主要考虑:

  • Linux 内核的命名空间机制提供的容器隔离安全

    隔离系统资源

  • Linux 控制组机制对容器资源的控制能力安全

    限制、记录和隔离进程组使用的物理资源

  • Linux 内核的能力机制所带来的操作权限安全

    限制和隔离进程的权限

  • Docker程序 (特别是服务端) 本身的抗攻击性。

  • 其他安全增强机制 (包括AppArmor、SELinux等) 对容器安全性的影响。

命名空间安全

命名空间(Namespace)是一种用于**隔离系统资源**的技术,它允许多个进程或容器使用相同的资源名称而不发生冲突。

而 Docker 使用 Linux 命名空间来提供容器隔离。当创建一个 Docker 容器时,它在宿主机上创建了一个隔离的环境,拥有自己的网络栈、文件系统、用户空间等。

通过该机制,每个容器都有自己的网络栈。从网络架构的角度来看,所有的容器实际上是通过本地主机的网桥接口 (Docker0) 进行相互通信,就像物理机器通过物理交换机通信一样。

涉及安全部分:

  • 运行在容器中的应用可以直接访问系统内核和部分系统文件。因此,用户必须保证容器中应用是安全可信的,跟保证运行在系统中的软件是可信的一个道理 ,否则本地系统将可能受到威胁。
  • 实际上,Docker自1.30版本起对镜像管理引入了签名系统,用户可以通过签名来验证镜像的完整性和正确性。

控制组安全

Linux 控制组(Control Groups,简称 cgroups)是一种 Linux 内核功能,**用于限制、记录和隔离进程组使用的物理资源**(如 CPU、内存、磁盘 I/O 等)。控制组为系统管理员提供了一种机制,可以对进程和线程进行精细的资源分配和管理。

当启动一个容器时,Docker 将在后台为容器创建一个独立的控制组策略集合,确保了发生在容器内的资源压力不会影响到本地主机系统和其他容器。

1
2
3
4
# /sys/fs/cgroup
cat /sys/fs/cgroup/cpu/docker/<Container_ID>/cpuacct.usage
# 也可以使用docker stats查看容器资源使用情况
docker stats <container_id_or_name>

内核能力机制安全

Linux 内核的能力机制(Capabilities)是一种更细粒度的权限管理系统,用于限制和隔离进程的权限。它为管理员提供了更多的控制权,可以更精确地管理系统资源和权限。通过合理使用能力机制,可以提高系统的安全性和稳定性。

容器的设计原则之一是最小权限原则,即容器内的应用程序只拥有执行所需的最小权限集合。由于容器共享宿主机的内核,它们不需要在每个容器中都运行完整的操作系统和系统服务。这可以减少资源消耗,提高效率,并简化部署和管理。

默认情况下,Docker 容器默认被限制使用内核的一部分能力,以确保容器的隔离性和安全性。

  • 例如包括chown、dac_override、fowner、kill、setgid、setuid、setpcap、net_bind_service、net_raw、sys_chroot、mknod、setfcap、audit_write 等。

特权进程的管理方式有所不同,特权进程和系统级工具通常由宿主机管理,而不是在容器内运行。这样可以更好地保护宿主机和整个系统的安全。

  • 例如有包括有ssh、cron、syslogd、硬件管理工具模块(例如负载模块)、网络配置工具等等
  • *容器内的进程通常不需要特权权限,因为它们依赖于宿主机来提供所需的基础设施和资源管理。宿主机上运行的服务和工具负责管理容器,包括网络、存储和其他系统级功能。

为了加强安全,容器可以禁用一些没必要的权限。

  • 完全禁止任何文件挂载操作
  • 禁止直接访问本地主机的套接字
  • 禁止访问一些文件系统的操作,比如创建新的设备、修改文件属性等
  • 禁止模块加载

这样,就算攻击者在容器中取得了root权限,也不能获得本地主机的较高权限,能进行的破坏也有限。

不恰当地分配了内核能力,会导致容器内应用获取破坏本地系统的权限。例如,早期的Docker版本曾经不恰当的继承CAP_DAC_READ_SEARCH能力,导致容器内进程可以通过系统调用访问到本地系统的任意文件目录。
默认情况下,Docker采用“白名单”机制,禁用“必需功能”之外的其他权限。当然,用户也可以根据自身需求来为 Docker 容器启用额外的权限。

Docker 服务端的防护

当提供容器创建服务时,要更加注意进行参数的安全检查,防止恶意的用户用特定参数来创建一些破坏性的容器。

例如将宿主机的根目录/映射到容器的/host目录中,这样容器理论上可以对主机的文件系统进行任意修改了。事实上,几乎所有虚拟化系统都允许类似的资源共享,而没法阻止恶意用户共享主机根文件系统到虚拟机系统。

而Linux命名空间机制将可以实现使用非root用户来运行全功能的容器。这将从根本上解决了容器和主机之间共享文件系统而引起的安全问题。

Linux 用户命名空间(user namespaces)是一种相对较新的内核特性,它允许映射 UID 和 GID,从而实现更细粒度的权限控制。

这项特性在 Linux 内核版本 3.8 及以后引入,发布于 2013 年左右。

默认情况下,Docker守护进程在Linux上以root权限运行,并限制对/var/run/docker.sock Unix套接字的访问。为了允许非root用户与Docker守护进程交互,用户需要加入docker用户组。将用户添加到docker用户组后,他们可以使用Docker CLI而无需sudo

1
sudo usermod -aG docker username

但是即使加入了docker用户组,用户仍然需要有权限执行Docker CLI命令。在某些安全策略严格的环境下,可能不允许将普通用户添加到docker用户组。此外,用户命名空间的配置和使用也需要谨慎,以避免潜在的安全风险。


底层/核心

Docker 归根到底是一种容器虚拟化技术。从操作系统功能上看,Docker底层依赖的核心技术 主要包括Linux操作系统的:

  • 命名空间 (Namespaces)
  • 控制组 (Control Groups)
  • 联合文件系统 (Union File Systems)
  • Linux 虚拟网络支持

基本架构

Docker采用了C/S架构,由客户端和服务端组成。

客户端和服务端可以运行在同一个机器上,也可以分离,主要通过socket或RESTful API进行通信。

服务端(Docker Daemon守护进程)

  • Docker 守护进程一般在宿主主机后台运行,作为服务端接受来自客户的请求,并处理这些请求(创建、运行、分发容器)。

客户端(Docker CLI)

  • Docker 客户端为用户提供一系列可执行命令,用户用这些命令实现与Docker 守护进程的交互(docker run/pull/push)。

核心组件

  1. Docker 守护进程(Docker Daemon)

    • 后台服务,负责处理 Docker 客户端的请求,管理容器的生命周期。
  2. Docker 客户端(Docker Client)

    • 命令行界面(CLI),允许用户与 Docker 守护进程交互,执行容器管理任务。
  3. Docker 镜像(Docker Images)

    • 只读模板,包含运行容器所需的所有内容,如代码、运行时环境、库和配置。
  4. Docker 容器(Docker Containers)

    • 镜像的运行实例,提供隔离的运行环境,用于运行应用程序。
  5. Dockerfile

    • 一个文本文件,包含一系列指令,用于定义如何构建 Docker 镜像。
  6. Docker Hub / Registry

    • 集中存储和分发 Docker 镜像的仓库。Docker Hub 是公共注册中心,也可以使用私有注册中心。
  7. Docker 网络(Docker Networking)

    • 负责容器间的网络通信,包括默认的网桥、用户定义的网络和容器间的连接。
  8. Docker 卷(Docker Volumes)

    • 持久化存储机制,用于在容器之间共享数据或将数据持久化到宿主机。
  9. Docker Engine API

    • RESTful API,允许客户端与 Docker 守护进程交互。
    • 它是 Docker 守护进程的一部分,不需要另外安装。当你安装 Docker 时,API 服务就会启动,并且可以通过默认的 Unix 套接字 /var/run/docker.sock 或配置的 HTTP/HTTPS 端点进行访问。

    注:Ubuntu系统中,Docker服务端的默认启动配置文在/etc/default/docker

  10. Docker Compose:

    • Docker Compose 是一个独立的工具,用于定义和运行多容器 Docker 应用程序,通过 YAML 文件配置

      它通常作为单独的二进制文件安装。虽然它不包含在 Docker 守护进程中,但你可以使用包管理器或直接从 GitHub 仓库下载并安装它。

  11. Docker Swarm:

    • 容器编排系统,提供服务发现、负载均衡、服务更新和扩展等功能。

      Docker Swarm 模式在 Docker 1.12 及以后的版本中已经内置在 Docker Engine 中,不需要单独安装。你可以直接使用 docker swarm 命令来初始化和管理 Swarm 集群。

命名空间

命名空间(Namespace)是 Linux 内核的一个特性,主要用于实现操作系统级别的虚拟化,提供一种隔离机制。

每个容器都可以拥有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统中运行一样。命名空间保证了容器之间彼此互不影响。

在操作系统中,包括内核、文件系统、网络、PID、UID、IPC、内存、硬盘、CPU等资源,所有的资源都是应用进程直接共享的。

要想实现虚拟化,除了要实现对内存CPU、网络IO、硬盘IO、存储空间等的限制外,还要实现文件系统、网络、PID、UIDIPC等等的相互隔离。

限制的实现相对容易实现一些,隔离则需要宿主主机系统的深入支持。

  1. 进程隔离: PID 命名空间允许在不同命名空间内运行相同 PID 的进程,而不会相互冲突。
    • 在不同的命名空间中,看到的进程号不相同,每个进程命名空间有一套自己的进程号管理方法。
    • 进程命名空间是一个父子关系的结构,子空间中的进程对于父空间是可见的。
  2. 网络隔离: 网络命名空间提供了独立的网络设备和网络栈,使得在不同命名空间内的网络配置和网络访问是隔离的。
  3. 文件系统隔离: 文件系统命名空间允许在不同命名空间内挂载和访问不同的文件系统。类似 chroot,将一个进程放到一个特定的目录执行。
    • 挂载命名空间允许不同命名空间的进程看到的文件结构不同,这样每个命名空间中的进程所看到的文件目录彼此被隔离。
  4. IPC 隔离: IPC 命名空间提供了独立的进程间通信资源,如信号量、消息队列等。
    • PID命名空间和IPC命名空间可以组合起来一起使用,同一个IPC名字空间内的进程可以彼此可见,允许进行交互;不同空间的进程则无法交互。
  5. UTS(UNIX Time-Sharing System)隔离: UTS 命名空间允许每个容器拥有独立的主机名和域名,从而可以虚拟出一个有独立主机名和网络空间的环境,就跟网络上一台独立的主机一样。
  6. 用户和组隔离: 每个容器可以有不同的用户和组id,也就是说可以在容器内使用特定的内部用户执行程序,而非本地系统上存在的用户。每个容器内部都可以有root帐号,跟宿主主机不在一个命名空间。
  7. cgroups(Control Groups): 虽然不是命名空间,但 cgroups 与命名空间一起使用,提供了对资源使用的限制和管理。

控制组

控制组 (CGroups) 是Linux内核的一个特性,主要用来对共享资源进行隔离、限制、审计等。

控制组可以提供对容器的内存、CPU、磁盘IO等资源进行限制和计费管理。只有能控制分配到的容器的资源,Docker才能**避免多个容器同时运行时的系统资源竞争**。

控制组提供如下功能:

  • 资源限制(Resource Limiting)组可以设置为不超过设定的内存限制

    比如:内存子系统可以为进程组设定一个内存使用上限,一旦进程组使用的内存达到限额再申请内存,就会出发 OutofMemory警告。

  • 优先级(Prioritization)通过优先级让一些组优先得到更多的CPU等资源

  • 资源审计(Accounting)用来统计系统实际上把多少资源用到适合的目的上,可以使用cpuacct子系统记录某个进程组使用的CPU时间。

  • 隔离(Isolation)为组隔离命名空间,这样一个组不会看到另一个组的进程、网络连接和文件系统。

  • 控制(Control)挂起、恢复和重启动等操作。

可以在/sys/fs/cgroup/memory/docker/目录下看到对Docker组应用的各种限制项,用户可以通过修改这些文件值来控制组限制Docker应用资源。例如,通过下面的命令可限制 Docker组中的所有进程使用的物理内存总量不超过100MB:

1
$sudo echo 104857600 > /sys/fs/cgroup/memory/docker/memory.limit_in_bytes

联合文件系统

联合文件系统(UnionFS)**是实现 Docker镜像的技术基础**,它是一种轻量级的高性能分层文件系统,支持将文件系统中的修改信息作为一次提交,并层层叠加,使得镜像可以通过分层来进行继承,同时可以将不同目录挂载到同一个虚拟文件系统下。

基于基础镜像制作时:

  • 用户基于基础镜像来制作各种不同的应用镜像。这些镜像共享同一个基础镜像层,提高了存储效率。
  • 此外,当用户改变了一个Docker 镜像时(比如升级程序到新的版本),则一个新的层(layer)会被创建。因此,用户不用替换整个原镜像或者重新建立,只需要添加新层即可。用户分发镜像的时候,也只需要分发被改动的新层内容(增量部分)。这让 Docker 的镜像管理变得十分轻量级和快速。

作为容器运行时:

  • 当 Docker利用镜像启动一个容器时,将利用镜像分配文件系统并且挂载一个新的可读写的层给容器,容器会在这个文件系统中创建,并且这个可读写的层被添加到镜像中。

Docker网络

Docker的网络实现其实就是利用了Linux上的网络命名空间和虚拟网络设备(特别是veth pair)。

基本原理:

Docker中的网络接口默认都是虚拟的接口。虚拟接口的最大优势就是转发效率极高对于本地系统和容器内系统来看,虚拟接口跟一个正常的以太网卡相比并无区别,只是它速度要快得多。

当进行数据包转发时,发送接口的发送缓存中的数据包 将被直接复制到接收接口的接收缓存中,而无需通过外部物理网络设备进行交换。

所以Docker容器网络就很好地利用了Linux虚拟网络技术。它在本地主机和容器内分别创建一个虚拟接口,并让它们彼此连通(这样的一对接口叫做veth pair)

网络的创建过程

Docker 创建一个容器的时候,会具体执行如下操作:

  • 创建一对虚拟接口,分别放到本地主机和新容器的命名空间中。

  • 本地主机一端的虚拟接口veth1连接到默认的docker0网桥或指定网桥上

  • 容器一端的虚拟接口veth2将放到新创建的容器中,并修改名字作为eth0这个接口只在容器的命名空间可见。

  • 宿主机从网桥的可用地址段中 获取一个空闲地址分配给容器的eth0(例如172.17.0.2/16)

    并为eth0配置默认路由网关docker0网卡的内部接口docker0的IP地址(例如 172.17.42.1/16)。

  1. 创建虚拟接口对: Docker 为新容器创建一对虚拟网络接口(通常称为 veth )。

    这对接口在宿主机和容器之间建立了一个虚拟的网络连接,分别放到本地主机和新容器的命名空间中

  2. 宿主机端的虚拟接口:一个接口如 veth1留在宿主机的命名空间中,并连接到 Docker 默认的网桥docker0或其他指定的网桥上。

  3. 容器端的虚拟接口: 另一个接口如 veth2移动到新创建的容器的命名空间中,并重命名为 eth0。这个接口只在容器的命名空间内可见,为容器提供了一个网络接口。

  4. 分配 IP 地址: 宿主机的网桥从其可用的地址段中为容器的 eth0 分配一个空闲的 IP 地址。这个地址通常是一个私有地址,例如 172.17.0.2/16

  5. 配置默认路由和网关: 容器的 eth0 接口配置默认路由,将网桥的内部接口(例如 docker0 的 IP 地址,如 172.17.42.1/16)作为网关。这样,容器可以通过这个网关与外部网络通信。

容器内部的网络配置通常包括配置 /etc/resolv.conf(DNS 解析)和 /etc/hosts 文件,以确保容器可以正确解析域名和识别宿主机。

在这种模式下,Docker 会自动创建一个虚拟网桥并为每个容器分配一个唯一的 IP 地址,在完成这些之后,容器就可以使用它所能看到的eth0虚拟网卡来连接其他容器和访问外部网络,由于容器被分配到了同一个子网,它们之间可以互相访问。

除了默认的桥接配置(bridge mode),Docker 还支持用户定义的网络,允许更复杂的网络拓扑和配置。

  • 另外,可以在 docker运行的时候通过--net参数来指定容器的网络配置,有4个可选值 bridge、host、container 和none。
  • 如需要划分子网,可以使用docker network create --subnet=192.168.1.0/24 --driver bridge my_network1来指定网络的子网
  • 如果需要,可以将一个容器连接到多个网络,如docker network connect my_network2 container1,将现有容器连接到另一个网络

详细介绍见上面的网络章节


Dockerfile

Dockerfile 是一种文本文件,包含了构建 Docker 镜像的所有指令。

通过 Dockerfile,开发者可以定义一套自动化的流程,从而基于某个基础镜像,构建出一个包含所需软件包、环境配置、依赖和应用程序的 Docker 镜像。

下面将列出 Dockerfile 中常见的指令及其功能。

FROM 指定基础镜像

所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个 nginx 镜像的容器,再进行修改一样,基础镜像是必须指定的。而 FROM 就是指定 基础镜像,因此一个 DockerfileFROM 是必备的指令,并且必须是第一条指令。

Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginxredismongomysqlhttpdphptomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 nodeopenjdkpythonrubygolang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。

如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntudebiancentosfedoraalpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。

除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。

1
2
FROM scratch
...

如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

RUN 执行命令

RUN 指令是用来执行命令行命令的。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:

  • shell 格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。
1
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
  • exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。

既然 RUN 就像 Shell 脚本一样可以执行命令,那么我们是否就可以像 Shell 脚本一样把每个命令对应一个 RUN 呢?比如这样:

1
2
3
4
5
6
7
8
9
FROM debian:stretch

RUN apt-get update
RUN apt-get install -y gcc libc6-dev make wget
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

之前说过,Dockerfile 中每一个指令都会建立一层,RUN 也不例外。每一个 RUN 的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit 这一层的修改,构成新的镜像。

而上面的这种写法,创建了 7 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。 这是很多初学 Docker 的人常犯的一个错误。

Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。

上面的 Dockerfile 正确的写法应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM debian:stretch

RUN set -x; buildDeps='gcc libc6-dev make wget' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps

首先,之前所有的命令只有一个目的,就是编译、安装 redis 可执行文件。因此没有必要建立很多层,这只是一层的事情。因此,这里没有使用很多个 RUN 一一对应不同的命令,而是仅仅使用一个 RUN 指令,并使用 && 将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。

并且,这里为了格式化还进行了换行。Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。

此外,还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。

很多人初学 Docker 制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。

COPY 复制文件

格式:

  • COPY [--chown=<user>:<group>] <源路径>... <目标路径>
  • COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]

RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。

COPY 指令将从构建上下文目录中 <源路径> 的文件/目录复制到新的一层的镜像内的 <目标路径> 位置。比如:

1
COPY package.json /usr/src/app/

<源路径> 可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match 规则,如:

1
2
COPY hom* /mydir/
COPY hom?.txt /mydir/

<目标路径> 可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用 WORKDIR 指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。

此外,还需要注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。

在使用该指令的时候还可以加上 --chown=<user>:<group> 选项来改变文件的所属用户及所属组。

1
2
3
4
COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/

如果源路径为文件夹,复制的时候不是直接复制该文件夹,而是将文件夹中的内容复制到目标路径。

ADD 更高级的复制文件

ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能。

比如 <源路径> 可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <目标路径> 去。下载后的文件权限自动设置为 600,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN 指令进行解压缩。所以不如直接使用 RUN 指令,然后使用 wget 或者 curl 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

如果 <源路径> 为一个 tar 压缩文件的话,压缩格式为 gzip, bzip2 以及 xz 的情况下,ADD 指令将会自动解压缩这个压缩文件到 <目标路径> 去。

在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像 ubuntu 中:

1
2
3
FROM scratch
ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /
...

但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 ADD 命令了。

在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合。

另外需要注意的是,ADD 指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。

因此在 COPYADD 指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用 COPY 指令,仅在需要自动解压缩的场合使用 ADD

在使用该指令的时候还可以加上 --chown=<user>:<group> 选项来改变文件的所属用户及所属组。

1
2
3
4
ADD --chown=55:mygroup files* /mydir/
ADD --chown=bin files* /mydir/
ADD --chown=1 files* /mydir/
ADD --chown=10:11 files* /mydir/

CMD 容器启动命令

CMD 指令的格式和 RUN 相似,也是两种格式:

  • shell 格式:CMD <命令>
  • exec 格式:CMD ["可执行文件", "参数1", "参数2"...]
  • 参数列表格式:CMD ["参数1", "参数2"...]。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。

之前介绍容器的时候曾经说过,Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的。

在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如,ubuntu 镜像默认的 CMD/bin/bash,如果我们直接 docker run -it ubuntu 的话,会直接进入 bash。我们也可以在运行时指定运行别的命令,如 docker run -it ubuntu cat /etc/os-release。这就是用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令了,输出了系统版本信息。

在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 ",而不要使用单引号。

如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:

1
CMD echo $HOME

在实际执行中,会将其变更为:

1
CMD [ "sh", "-c", "echo $HOME" ]

这就是为什么我们可以使用环境变量的原因,因为这些环境变量会被 shell 进行解析处理。

提到 CMD 就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。

Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 systemd 去启动后台服务,容器内没有后台服务的概念。

一些初学者将 CMD 写为:

1
CMD service nginx start

然后发现容器执行后就立即退出了。甚至在容器内去使用 systemctl 命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。

而使用 service nginx start 命令,则是希望 upstart 来以后台守护进程形式启动 nginx 服务。而刚才说了 CMD service nginx start 会被理解为 CMD [ "sh", "-c", "service nginx start"],因此主进程实际上是 sh。那么当 service nginx start 命令结束后,sh 也就结束了,sh 作为主进程退出了,自然就会令容器退出。

正确的做法是直接执行 nginx 可执行文件,并且要求以前台形式运行。比如:

1
CMD ["nginx", "-g", "daemon off;"]

ENV 设置环境变量

格式有两种:

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>...

这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如 RUN,还是运行时的应用,都可以直接使用这里定义的环境变量。

1
2
ENV VERSION=1.0 DEBUG=on \
NAME="Happy Feet"

这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。

定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 node 镜像 Dockerfile 中,就有类似这样的代码:

1
2
3
4
5
6
7
8
9
ENV NODE_VERSION 7.2.0

RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
&& grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
&& rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
&& ln -s /usr/local/bin/node /usr/local/bin/nodejs

在这里先定义了环境变量 NODE_VERSION,其后的 RUN 这层里,多次使用 $NODE_VERSION 来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新 7.2.0 即可,Dockerfile 构建维护变得更轻松了。

下列指令可以支持环境变量展开: ADDCOPYENVEXPOSEFROMLABELUSERWORKDIRVOLUMESTOPSIGNALONBUILDRUN

可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份 Dockerfile 制作更多的镜像,只需使用不同的环境变量即可。

ARG 构建参数

格式:ARG <参数名>[=<默认值>]

构建参数和 ENV 的效果一样,都是设置环境变量。所不同的是,ARG 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG 保存密码之类的信息,因为 docker history 还是可以看到所有值的。

Dockerfile 中的 ARG 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker build 中用 --build-arg <参数名>=<值> 来覆盖。

灵活的使用 ARG 指令,能够在不修改 Dockerfile 的情况下,构建出不同的镜像。

ARG 指令有生效范围,如果在 FROM 指令之前指定,那么只能用于 FROM 指令中。

1
2
3
4
5
ARG DOCKER_USERNAME=library

FROM ${DOCKER_USERNAME}/alpine

RUN set -x ; echo ${DOCKER_USERNAME}

使用上述 Dockerfile 会发现无法输出 ${DOCKER_USERNAME} 变量的值,要想正常输出,你必须在 FROM 之后再次指定 ARG

1
2
3
4
5
6
7
8
9
# 只在 FROM 中生效
ARG DOCKER_USERNAME=library

FROM ${DOCKER_USERNAME}/alpine

# 要想在 FROM 之后使用,必须再次指定
ARG DOCKER_USERNAME=library

RUN set -x ; echo ${DOCKER_USERNAME}

对于多阶段构建,尤其要注意这个问题

1
2
3
4
5
6
7
8
9
10
# 这个变量在每个 FROM 中都生效
ARG DOCKER_USERNAME=library

FROM ${DOCKER_USERNAME}/alpine

RUN set -x ; echo 1

FROM ${DOCKER_USERNAME}/alpine

RUN set -x ; echo 2

对于上述 Dockerfile 两个 FROM 指令都可以使用 ${DOCKER_USERNAME},对于在各个阶段中使用的变量都必须在每个阶段分别指定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ARG DOCKER_USERNAME=library

FROM ${DOCKER_USERNAME}/alpine

# 在FROM 之后使用变量,必须在每个阶段分别指定
ARG DOCKER_USERNAME=library

RUN set -x ; echo ${DOCKER_USERNAME}

FROM ${DOCKER_USERNAME}/alpine

# 在FROM 之后使用变量,必须在每个阶段分别指定
ARG DOCKER_USERNAME=library

RUN set -x ; echo ${DOCKER_USERNAME}

EXPOSE 声明端口

格式为 EXPOSE <端口1> [<端口2>...]

EXPOSE 指令是声明容器运行时提供服务的端口,这只是一个声明,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。

要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。

VOLUME 定义匿名卷

格式为:

  • VOLUME ["<路径1>", "<路径2>"...]
  • VOLUME <路径>

之前我们说过,容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,后面的章节我们会进一步介绍 Docker 卷的概念。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。

1
VOLUME /data

这里的 /data 目录就会在容器运行时自动挂载为匿名卷,任何向 /data 中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行容器时可以覆盖这个挂载设置。比如:

1
$ docker run -d -v mydata:/data xxxx

在这行命令中,就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置。

WORKDIR 指定工作目录

格式为 WORKDIR <工作目录路径>

使用 WORKDIR 指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR 会帮你建立目录。

之前提到一些初学者常犯的错误是把 Dockerfile 等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:

1
2
RUN cd /app
RUN echo "hello" > world.txt

如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt 文件,或者其内容不是 hello。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。这就是对 Dockerfile 构建分层存储的概念不了解所导致的错误。

之前说过每一个 RUN 都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。

因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令。

1
2
3
WORKDIR /app

RUN echo "hello" > world.txt

如果你的 WORKDIR 指令使用的相对路径,那么所切换的路径与之前的 WORKDIR 有关:

1
2
3
4
5
WORKDIR /a
WORKDIR b
WORKDIR c

RUN pwd

RUN pwd 的工作目录为 /a/b/c

LABEL 指令

LABEL 指令用来给镜像以键值对的形式添加一些元数据(metadata)。

1
LABEL <key>=<value> <key>=<value> <key>=<value> ...

我们还可以用一些标签来申明镜像的作者、文档地址等:

1
2
3
LABEL org.opencontainers.image.authors="yeasy"

LABEL org.opencontainers.image.documentation="https://yeasy.gitbooks.io"

具体可以参考 https://github.com/opencontainers/image-spec/blob/master/annotations.md


Docker从入门到实践
https://www.fishingrodd.cn/2024/08/24/Docker从入门到实践/
作者
FishingRod
发布于
2024年8月24日
更新于
2024年11月12日
许可协议