Docker基础知识

引言

打造便携(portable)的,轻量级的开发环境,是Docker吸引应用研发同学的主要原因。本文涉及的内容是我认为要实现这一目标,研发同学应当掌握的基本Docker知识。

本文内容源自Docker官方文档的翻译,简化和重组。下文中会有一些到官方文档的连接,方便希望进一步学习Docker知识的同学。

安装Docker Desktop for Mac

在mac中安装docker可以通过homebrew进行安装

1
2
3
brew cask install docker
## 验证安装是否成功
docker --help

也可以到参照文档进行手动下载安装

应用操作系统

我们开发一个应用通常需要如下依赖

  • 指定版本的操作系统,JDK,数据库
  • 经过一些调整的配置文件
  • 绑定指定的端口并占用一定数量的内存
  • 其他类似的依赖,比如环境变量

应用运行需要的这些组件和配置组织在一起,我们称之为”应用操作系统

你可以提供一个安装脚本来下载安装这些依赖组件和配置。Docker简化了这个过程,使用Docker可以创建一个包含你的应用和它的基础设施的镜像,然后可以在容器虚拟化平台上通过容器来运行这个镜像。

Docker引擎(Docker Engine)

Docker引擎是一个客户端-服务器应用程序,包含以下组件:

  • 一个服务端,它长期运行在后台,称为daemon进程,通过dockerd启动。
  • 一组用于跟daemon进程通信的REST API,用于给daemon发送指令。
  • 一个命令行接口客户端(CLI),使用docker命令启动。

Docker Engine Components Flow

命令行通过Docker REST API与daemon进程进行通信交互,向其发送脚本或命令。许多七大的Docker应用程序会使用底层API和命令行接口。

daemon进程会创建和管理Docker组件例如镜像(Images),容器(containers),网络(networks),卷(volume)

主要组件

Docker具有三个主要组件:

  1. 镜像是Docker的构建组件,它是定义了应用操作系统只读模板。
  2. 容器是从镜像创建的Docker运行组件。容器可以运行有,启动,停止,迁移和删除。
  3. 镜像是Docker的分发组件,被存储在注册服务中并通过该服务进行共享和管理。Docker-Hub是个公开可用的公共镜像注册服务。

为了令三种组件在一起工作,需要在一个主机上运行Docker Daemon(或Docker Engine),它会处理构建,运行,和分发容器的工作。另外,用户可以使用Docker客户端(Client)来与Docker Daemon进行命令交互。

Docker Architecture Diagram

客户端可以与本地主机或其他主机通信,使用pull命令来请求Docker Daemon从注册服务拉取镜像,然后Docker Daemon会从Docker Hub或配置的其他注册服务下载镜像并安装,最后客户端使用run来运行容器。

Docker镜像(Docker Image)

上文我们说过Docker容器是从只读的Docker镜像启动的。每个镜像由一系列的层组成,Docker利用联合文件系统来将这些层合并到一个单独镜像中。

Docker镜像的层结构,令Docker变得轻量。当你是用虚拟机的时候,如要更新应用到新版本,你需要替换应用程序包然后重新构建整个虚拟机镜像,使用Docker仅仅醒要添加或修改一个层。你不需要分发整个镜像,仅仅需要更新,这领分发Docker镜像变得更快,更简单。

每个镜像起始于一个基本镜像,例如ubuntufedora分别表示基本Ubuntu和Fedora操作系统的基本镜像。你也可以使用你自己的镜像作为基本镜像,例如如果你有一个基本Apache镜像,你可以用基于它构建新的应用镜像。

默认情况下,Docker从Docker Hub获取这些基本镜像。

Docker镜像使用简单易懂的指令集来从构建新镜像,其中RUN,COPY,ADD每个指令会在镜像中创建一个新的层,其他的指令会只会创建一个临时中间镜像,不会增加最终镜像的体积,例如:

  1. RUN 运行一个命令
  2. ADD,COPY 添加一个文件或文件夹
  3. ENV 创建一个环境变量
  4. ENTRY_POINT,CMD 在启动容器时运行一个进程

这些指令存储在Dockerfile文件中。构建镜像时Docker Daemon会读取Dockerfile文件,执行其中的指令,生成最终镜像。

假设有一个目录内容如下:

1
2
3
4
.
├── Dockerfile
├── app.py
└── requirements.txt

这个目录称为Docker Context,其中Dockerfile内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 使用官方的Python运行时镜像作为基本镜像
FROM python:2.7-slim

# 设置工作目录 /app
WORKDIR /app

# 拷贝当前目录内容到容器的/app目录中
COPY . /app

# 安装requirements.txt文件中指定的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 开放容器的80端口
EXPOSE 80

# 定义一个环境变量
ENV NAME World

# 容器启动时执行“python app.py”
CMD ["python", "app.py"]

使用这个Docker Context即可构建一个开放80端口,容器启动后执行python app.py的镜像。

完整的Dockerfile手册可以查看官方文档

构建命令如下:

1
docker build -t friendlyhello . # 注意最后的“.”表示使用当前目录作为Docker Context

Docker容器(Docker Container)

容器的基本结构

容器由一个操作系统,用户添加的文件和元数据组成。每个容器都是从一个镜像构建的。镜像告诉Docker引擎容器的内容、容器启动的时候要运行什么进程以及各种各样其他的配置数据。Docker镜像是只读的,当Docker从一个镜像运行一个容器时,会在镜像顶层添加一个读写层,以便于你的应用可以正常运行读写数据。这个读写层与容器的生命周期相同,如果应用要持久化保存数据需要通过mount机制来与主机存储通信。

容器和镜像的关系如下图所示:

Layers of a container based on the Ubuntu image

从镜像运行容器的命令如下:

1
docker run -it friendlyhello

容器与虚拟机对比

开发人员可以简单的任务应用程序在容器中运行跟在虚拟机中运行的情况大体一致。但容器和虚拟机还是有本质区别的。

容器原生运行在Linux操作系统中,与主机上的其他容器共享内核。运行运行一个独立的进程,内存占用与系统中执行的其他进程无异,这令容器非常轻量。

与之对比的是虚拟机,它运行一个完整的客户端操作系统通过虚拟化技术(hypervisor)访问主机资源。通常虚拟机比大多数其他类型的应用程序要占用更多的资源来提供一个隔离的运行环境。

image-20190522103006562

Docker 虚拟机
启动速度 几秒 几分钟
资源占用 普通进程资源 GustOS需要占用更多额外资源
性能 直接跟系统内核交互几乎无损耗 通过Hypervisor访问资源带来额外消耗
隔离 进程级别 系统级别
安全性 工宿主机共享用户权限,安全性差 GustOS具有独立用户权限,安全性更高
可管理性 云原生等概念促使相关工具迅速发展(例如k8s) 发展多年已经比较成熟
创建/删除 秒级别创建,适合快速迭代,节省大量时间 分钟级别
分发/部署 简单的Dockerfile,以及Docker Registry
提供了快速分发能力
通过镜像保证分发环境一致
通过虚拟机镜像分发,保证环境一致
没有Registry,无法体系化
资源限制 Linux的Control Group(cgroup)
部分应用无法正确识别到,比如Java7之前
Hypervisor层的资源限制
镜像 小,几MB~几百MB,不可变 大,数GB,需要配置管理

Docker后台常驻进程(Docker Daemon)

在你的电脑上安装Docker的同时会安装Docker主机(docker host)。一旦创建了Docker主机就可以通过它管理镜像和容器。例如下载镜像,启停、重启容器。

Docker客户端(Docker Client)

客户端与Docker主机通信,令用户可以操作镜像和容器。

可以通过如下命令检查当前主机上安装的Docker是否可以正常工作:

1
2
3
4
docker -v

# 正常会输出类似下面的文本:
Docker version 17.09.0-ce-rc3, build 2357fb2

输出的版本号会随安装的Docker程序包变化。

准确的客户端和服务端版本可以使用docker version命令查看,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Client:
Version: 17.09.0-ce-rc3
API version: 1.32
Go version: go1.8.3
Git commit: 2357fb2
Built: Thu Sep 21 02:31:18 2017
OS/Arch: darwin/amd64

Server:
Version: 17.09.0-ce-rc3
API version: 1.32 (minimum version 1.12)
Go version: go1.8.3
Git commit: 2357fb2
Built: Thu Sep 21 02:36:52 2017
OS/Arch: linux/amd64
Experimental: true

完整的指令列表可以使用docker --help命令查看,下面是常用命令表

功能 命令
镜像相关
当前目录作为Context构建镜像 docker image build --rm=true .
拉取镜像安装到本地 docker image pull ${IMAGE}
列出本地安装的镜像 docker image ls
列出本地安装的镜像(详细信息) docker image ls --no-trunc
删除本地镜像 docker image rm ${IMAGE_ID}
删除所有无用的镜像(没有创建容器) docker image prune
删除所有本地镜像 docker image rm $(docker image ls -aq)
容器相关
运行容器 docker container run
列出所有运行中的容器 docker container ls
List of all containers docker container ls -a
停止容器 docker container stop ${CID}
停止所有运行的容器 docker container stop $(docker container ls -q)
列出所有退出码为1的容器 docker container ls -a --filter "exited=1"
移除容器 docker container rm ${CID}
移除所有容器 docker container rm $(docker container ls -aq)
查找运行容器的ip地址 docker container inspect --format '' ${CID}
关联到容器 docker container attach ${CID}
打开一个容器的命令行 docker container exec -it ${CID} bash

Docker应用数据管理

Docker容器这个章节中有提到要持久化保存应用数据需要使用mount机制,本章详细说明Docker的应用数据管理。

默认情况下容器中创建的所有文件存储在容器的读写层上,这意味着:

  • 当容器退出后数据不能持久化,并且容器外部的其他进程是很难访问到容器内部的数据的。
  • 容器的读写层与容器运行的主机紧密耦合,其中的数据很难移动到其他什么地方。
  • 痛惜需要使用存储驱动来管理文件系统,存储驱动会利用Linux内核提供一个统一文件系统。这个附加的抽象层降低了性能。

Docker有两种方式来直接让容器存储文件到主机上,以便于文件即使在容器停止后也能吃就存储:volumes和bind mounts。如果是在Linux主机上运行Docker,那么还可以使用tmpfs。

三种方式和容器的关系如下图所示:

types of mounts and where they live on the Docker host

  • Volumes存储在主机文件系统上的(/var/lib/docker/volumes/ on Linux),由Docker管理.。非Docker进程不应修改。Volumes是持久化数据的最佳方式。

    1
    2
    docker volume create # 创建volume
    docker volume prune # 移除无用的volume
  • Bind mounts可以存储数据到主机系统的任意位置(内存或文件系统)。他们甚至有可能是重要的系统文件或目录。主机上的非Docker进程或Docker容器可以随时修改这些文件或目录。

  • tmpfs mounts只存储在主机系统的内存中,永远不会写的主机系统文件系统中。

Volumes使用场景

Volumes是Docker容器和服务持久化数据的推荐方式,一些用例如下:

  • 多个运行的容器共享数据。如果没有显式创建Volume,volume会在第一次挂载到同期上的时候被创建。当同期停止或被移除时,这个volume依然存在。多个容器可以同时以读写或只读方式挂载同一个volume。Volumes只会能人工显式使用命令移除。
  • 如果Docker主机不一定有只能结构的文件夹或文件,Volumes会帮你解耦Docker主机和容器运行时的配置。
  • 如果你想存储容器的数据到远程主机或云服务上
  • 如果你需要备份备份,恢复或迁移数据从一个Docker主机到另一个,Volumes是更好的选择。你可以停止使用volume的容器,然后备份volume的文件夹(例如:/var/lib/docker/volumes/\)

Bind mounts使用场景

一般来说应该尽可能使用Volumes,Bind mounts适用于一下用例:

  • 共享主机的配置文件给容器。默认情况下Docker通过挂载主机的/etc/resolv.conf文件给每个容器来提供DNS解析给容器。

  • 在主机上的开发环境和容器间共享源代码或构件。例如你可以挂载Maven项目的target/到一个容器,这样每次在主机上构建Maven项目,容器就可以访问到重新构建的构件。

    如果在开发环境中一这种方式使用Docker,需要确保生产环境使用的Dockerfile将生产环境的构件直接拷贝到镜像中,而不能依赖bind mount

  • 当Docker主机上的文件或目录结构能够确保与容器需要的一致时,可以使用bind mount

tmpfs mounts使用场景

如果你不想数据持久化存储在主机上或容器内,tmpfs mount是最佳方式。这可能是由于安全原因比如临时使用敏感数据,或当你的应用需要写大量的非持久化数据时提升容器的性能。

Docker网络

Docker容器和服务之所以这么强大的一个原因是可以将多个容器或服务连接在一起,甚至可以将容器和非容器进程连接在一起。Docker容器和服务不需要意识到他们是部署在Docker上的,也不关心与他们连接的是否是Docker容器。无论你的Docker主机运行在Linux,Window或者两者混合,你都可以使用Docker一平台无关的方式管理他们。

网络驱动

Docker的网络子系统是插件式的,默认的几个驱动提供了核心的网络功能:

  • bridge(桥接):默认的网络驱动。如果不明确指定,它就是你创建的网络的类型。当你的应用运行在一些相互通信的独立容器中时,通常使用桥接网络。
  • host(主机):对于独立的容器,移除容器和Docker主机的网络隔离,直接使用主机的网络。host只对Docker 17.06或以上的swarm服务可用。
  • overlay(叠加):叠加网络将多个Docker daemon连接在一起,令swarm服务可以相互通信。也可以使用叠加网络令swarm服务或独立容器,或两个不同Docker daemon上的独立容器可以通信。这个策略不需要在多个容器间进行操作系统级别的路由。
  • macvlan:Maclan网络允许给一个容器分配一个MAC地址,令其在你的网络表现的像一个物理设备一样。Docker daemon将靠MAC地址来在容器间路由数据。当你的应用希望直接与物理网络通信时,使用macvlan或许是最佳选择。
  • none:禁止网络通信。通常与自定义网络驱动结合使用。none不能使用在swarm服务上。
  • Network plugins:Docker可以安装和使用第三方网络插件。这些插件可以同Docker Hub获取,由第三方开发商提供。

由于网络通常与容器编排有关,而容器编排是另外一个比较复杂的主题,所以在此仅对比较简单的bridge网络做进一步阐述。该类型网络的开发环境特别用有。

使用bridge网络

桥接网络只对运行在同一个Docker deamon上的容器有效。对于不同Docker daemon上的容器通信,你可以管理操作系统级别的路由或使用overlay网络。

往Docker启动,会自动创建一个默认桥接网络(Default Bridge Network,下文简称DBN),新启动的容器如果没有特别指定会自动连接到这个默认桥接网络上。你也可以创建用户自定义的桥接网络(User-Defined Bridge Network,下文简称UDBN)。应当优先使用用户定义的桥接网络。

img

用户定义的与默认桥接网络区别

  • UDBN提供更好的隔离性和容器间的交互性。

    连接到同一个UDBN的容器相互暴露所有的端口,但对外部世界不暴露任何端口。这让容器化的应用很容易与彼此通信,又不会意外对外部世界开放访问权限。

    想象一个场景,一个应用有一个web前端和一个数据库后端。外部世界需要通过80端口访问web前端,但是只有后端自己需要访问数据的主机和端口。利用UDBN只有web端口需要被开放,数据库应用不需要开放任何端口,因为web前端可以通过UDBN访问它。

    如果在DBN上运行同样的应用,你需要同时使用-p--publish开放web端口和数据库端口,这意味着Docker主机需要通过其它方式组织对数据库端口的访问。

  • UDBN在容器间提供自动DNS解析

    在DBN上的容器只能通过IP地址进行彼此的访问,除非你使用--link来连接容器,这个参数已经过时了。在UDBN上容器可以通过名称和别名来解析彼此。

    再看前面提到的场景,如果将前端和后端容器分别命名为webdbweb容器可以通过db这个名称来访问数据库。

  • 容器可以在运行中和UDBN连接或分离

    在整个容器的声明周期内,你可以随时与UDBN连接或断开。要从DBN中移除一个容器,需要停止通知并使用不同的网络选项重建它。

  • 每个UDBN创建一个可配置的桥接

    如果你的容器使用DBN,你可以配置它,但是所有的容器只能使用相同的设置,例如MTUiptables规则。另外,只能在Docker外部进行设置,需要重启Docker才能生效。

    UDBN使用docker network create创建和设置。如果不同组的应用有不同的网络需求,你可以为它们独立配置UDBN。

  • 在DBN上关联的容器会共享环境变量

    起初在两个容器间共享环境变量的唯一方式是使用--link来连接他们。UDBN是不能以这种方式共享变量的。然而有更好的方式来共享环境变量。例如:

    • 多个容器使用一个Docker volume挂载一个包含共享信息的文件或目录。
    • 多个容器可以使用docker-compose一起启动,在compse文件中可以定义共享变量。

连接到同一个UDBN的容器跑彼此暴露所有的端口。对于需要被外部世界访问的端口必须使用-p--publish进行公开发布。

管理UDBN

使用docker network create命令创建UDBN

1
docker network create my-net

你可以指定子网,IP地址范围,网关和其他选项。可以通过docker network create —help查看具体细节

使用docker network rm命令移除UDBN。如果有容器正连接到要移除的网络,需要先断开连接。

1
docker network rm my-net

连接容器到UDBN

创建新容器时可以通过一个或多个--network选项来指定要连接到的网络。下面这个例子连接一个Nginx容器到my-net网络。同时公开了容器的80端口到Docker主机的8080端口映射,这样外部的客户端可以通过主机的8080端口访问到容器的80端口。连接到my-net网络的其他容器可以通过my-nginx这个名称访问所有的端口。

1
2
3
4
docker create --name my-nginx \
--network my-net \
--publish 8080:80 \
nginx:latest

要连接一个正在运行的容器到已存在的UDBN,使用docker network connect命令。下面的命令连接已经运行的my-nginx容器到已存在的my-net网络。

1
docker network connect my-net my-nginx

断开容器和UDBN的连接

要断开一个正在运行的容器到UDBN的链接,使用docker network disconnect命令。下面的将my-nginx容器从my-net网络中断开。

1
docker network disconnect my-net my-nginx