初识容器
# 1. 初识容器:万事开头难
# Docker 的形态
目前使用Docker基本上有两个选择: Docker Desktop 和 Docker Engine。
- Docker Desktop是专门针对个人使用而设计的,支持Mac和Windows快速安装,具有直观的图形界面,还集成了许多周边工具,方便易用。只是对个人学习免费,受条款限制不能商用,我们在日常工作中难免会“踩到雷区”。
- Docker Engine 只能在Linux上运行,只能使用命令行操作,缺乏辅助工具,需要我们自己动手DIY运行环境。不过要是较起真来,它才是Docker当初的真正形态,“血脉”最纯正,也是现在各个公司在生产环境中实际使用的Docker产品,毕竟机房里99%的服务器跑的都是Linux。
# Docker 命令
Docker Engine需要使用命令行操作,主命令是 docker
,后面再接各种子命令。
查看Docker的基本信息的命令是 docker version
和 docker info
,其他常用的命令有 docker ps
、 docker pull
、 docker images
、 docker run
。
# Docker 的架构
我们敲的命令行 docker
实际上是一个客户端client ,它会与Docker Engine里的后台服务Docker daemon通信,而镜像则存储在远端的仓库Registry里,客户端并不能直接访问镜像仓库。
Docker client可以通过 build
、 pull
、 run
等命令向Docker daemon发送请求,而Docker daemon则是容器和镜像的“大管家”,负责从远端拉取镜像、在本地存储镜像,还有从镜像生成容器、管理容器等所有功能。
所以,在Docker Engine里,真正干活的其实是默默运行在后台的Docker daemon,而我们实际操作的命令行工具“docker”只是个“传声筒”的角色。
# 与虚拟机的区别
# 2. 被隔离的进程:一起来看看容器的本质
广义上来说,容器技术是动态的容器、静态的镜像和远端的仓库这三者的组合。今天就来看看究竟什么是容器(即狭义的、动态的容器)。
容器封装了运行中的进程,并把进程与外界环境隔离开,让进程与外部系统互不影响。它就是一个特殊的隔离环境,它能够让进程只看到这个环境里的有限信息,不能对外界环境施加影响。
使用容器技术,我们就可以让应用程序运行在一个有严密防护的**“沙盒”(Sandbox)**环境之内,就好像是把进程请进了“隔离酒店”,它可以在这个环境里自由活动,但绝不允许“越界”,从而保证了容器外系统的安全。
容器技术的另一个本领就是为应用程序加上资源隔离,在系统里切分出一部分资源,让它只能使用指定的配额。这可以避免容器内进程的过渡系统消耗,并充分利用计算机硬件,让有限的资源能够提供稳定可靠的服务。
容器本质上就是一种特殊的进程。
# 隔离是怎么实现的
奥秘在于 Linux 内核所提供的三个技术:namespace、cgroup 和 chroot,虽然这三个技术的初衷都不是为了实现容器,但结合在一起就会发生奇妙的”化学反应“。
- namespace:它可以创建出独立的文件系统、主机名、进程号、网络等资源空间,相当于给进程盖了一间小板房,这样就实现了系统全局资源和进程局部资源的隔离。
- cgroup:全称是 Linux Control Group,用来实现对进程的 CPU、内存等资源的优先级和配额限制,相当于给进程的小板房加了一个天花板。
- chroot:它可以更改进程的根目录,也就是限制访问文件系统,相当于给进程的小板房铺上了地砖。 综合运用以上三个技术,一个四四方方、具有完善的隔离特性的容器就此出现了,进程也就可以搬进去快乐生活啦。
目前容器技术基本不再使用古老的 chroot 了,而是改用 pivot_root。
# 3. 容器化的应用
# 什么是容器化的应用
镜像是容器的静态形式,它打包了应用程序的所有运行依赖项,方便保存和传输。使用容器技术运行镜像,就形成了动态的容器,由于镜像只读不可修改,所以应用程序的运行环境总是一致的。
任何应用都能够打包再分发后运行,这也是无数开发者梦寐以求的“一次编写,到处运行(Build once, Run anywhere)”的至高境界。所以, 所谓的“容器化的应用”,或者“应用的容器化”,就是指应用程序不再直接和操作系统打交道,而是封装成镜像,再交给容器环境去运行。
现在你就应该知道了,镜像就是静态的应用容器,容器就是动态的应用镜像,两者互相依存,互相转化,密不可分。
Docker只不过是众多容器运行时(Container Runtime)中最出名的一款而已。
# 操纵容器化应用(常用命令)
# 4. 创建容器镜像(编写Dockerfile)
本节讲解镜像的内部机制,还有高效、正确地编写Dockerfile制作容器镜像的方法。
# 镜像的内部机制:分层
镜像就是一个打包文件,里面包含了应用程序还有它运行所依赖的环境,例如文件系统、环境变量、配置参数等等。
- 环境变量、配置参数:用一个manifest清单就可以管理
- 文件系统:较麻烦。为了保证容器运行环境的一致性,镜像必须把应用程序所在操作系统的根目录(rootfs),都包含进来。
为避免多个镜像都对Ubuntu根目录文件打包,造成对磁盘存储、网络传输的浪费,针对容器镜像有了“分层”(layer)的概念。
容器镜像是由多个只读的Layer构成的,同一个Layer可以被不同的镜像共享,减少了存储和传输的成本。
容器镜像内部并不是一个平坦的结构,而是由许多的镜像层组成的,每层都是只读不可修改的一组文件,相同的层可以在镜像之间共享,然后多个层像搭积木一样堆叠起来,再使用一种叫“ Union FS联合文件系统”的技术把它们合并在一起,就形成了容器最终看到的文件系统( 图片来源 (opens new window))。
可以用命令 docker inspect
来查看镜像的分层信息,它的分层信息在“RootFS”部分。
相信你现在也就明白,之前在使用 docker pull
、 docker rmi
等命令操作镜像的时候,那些“奇怪”的输出信息是什么了,其实就是镜像里的各个Layer。Docker会检查是否有重复的层,如果本地已经存在就不会重复下载,如果层被其他镜像共享就不会删除,这样就可以节约磁盘和网络成本。
# Dockerfile 是什么
# 怎样编写正确、高效的Dockerfile
FROM
: 基础镜像COPY
: 一些需要打包进镜像里的源码、配置等文件ADD
: 与COPY
的区别是COPY的SRC只能是本地文件,其他用法一致RUN
: 执行任意的Shell命令,比如更新系统、安装应用、下载文件、创建目录、编译程序等等,实现任意的镜像构建步骤,非常灵活ARG
: 创建的变量只在镜像构建过程中可见,容器运行时不可见ENV
: 创建的变量不仅能够在构建镜像的过程中使用,在容器运行时也能够以环境变量的形式被应用程序使用EXPOSE
: 声明容器对外服务的端口号ENTRYPOINT
:
EXPOSE 443 # 默认是tcp协议
EXPOSE 53/udp # 可以指定udp协议
2
3
关于 RUN 指令的一些细节
RUN
通常会是Dockerfile里最复杂的指令,会包含很多的Shell命令,但Dockerfile里一条指令只能是一行,所以有的 RUN
指令会在每行的末尾使用续行符 \
,命令之间也会用 &&
来连接,这样保证在逻辑上是一行,就像下面这样:
RUN apt-get update \
&& apt-get install -y \
build-essential \
curl \
make \
unzip \
&& cd /tmp \
&& curl -fSL xxx.tar.gz -o xxx.tar.gz\
&& tar xzf xxx.tar.gz \
&& cd xxx \
&& ./config \
&& make \
&& make clean
2
3
4
5
6
7
8
9
10
11
12
13
14
有的时候在Dockerfile里写这种超长的 RUN
指令很不美观,而且一旦写错了,每次调试都要重新构建也很麻烦,所以你可以采用一种变通的技巧: 把这些Shell命令集中到一个脚本文件里,用 COPY
命令拷贝进去再用 RUN
来执行:
COPY setup.sh /tmp/ # 拷贝脚本到/tmp目录
RUN cd /tmp && chmod +x setup.sh \ # 添加执行权限
&& ./setup.sh && rm setup.sh # 运行脚本然后再删除
2
3
4
5
RUN
指令实际上就是Shell编程,如果你对它有所了解,就应该知道它有变量的概念,可以实现参数化运行,这在Dockerfile里也可以做到,需要使用两个指令 ARG
和 ENV
。
`ARG` 和 `ENV` 的区别
下面是一个简单的例子,使用 ARG
定义了基础镜像的名字(可以用在“FROM”指令里),使用 ENV
定义了两个环境变量:
ARG IMAGE_BASE="node"
ARG IMAGE_TAG="alpine"
ENV PATH=$PATH:/tmp
ENV DEBUG=OFF
2
3
4
5
讲了这些Dockerfile指令之后,我还要特别强调一下,因为因为 RUN, COPY, ADD 会生成新的镜像层,其它指令只会产生临时层,不影响构建大小,所以Dockerfile里最好不要滥用指令,尽量精简合并,否则太多的层会导致镜像臃肿不堪。
# docker build是怎么工作的
Dockerfile必须要经过 docker build
才能生效,所以我们再来看看 docker build
的详细用法。
参数:
-f
: 指定Dockerfile,如果不指定就使用当前目录下名字是“Dockerfile”的文件-t
: 为镜像起一个有意义的名字,方便管理
docker build
需要指定“构建上下文”,其中的文件会打包上传到Docker daemon,所以尽量不要在“构建上下文”中存放多余的文件。
待补充
# 5. 镜像仓库:该怎样用好Docker Hub这个宝藏
待补充
# 6. 打破次元壁:容器该如何与外界互联互通
在前面的几节课里,我们已经学习了容器、镜像、镜像仓库的概念和用法,也知道了应该如何创建镜像,再以容器的形式启动应用。
不过,用容器来运行“busybox”“hello world”这样比较简单的应用还好,如果是Nginx、Redis、MySQL这样的后台服务应用,因为它们运行在容器的“沙盒”里,完全与外界隔离,无法对外提供服务,也就失去了价值。这个时候,容器的隔离环境反而成为了一种负面特性。
所以,容器的这个“小板房”不应该是一个完全密闭的铁屋子,而是应该给它开几扇门窗,让应用在“足不出户”的情况下,也能够与外界交换数据、互通有无,这样“有限的隔离”才是我们真正所需要的运行环境。
那么今天,我就以Docker为例,来讲讲有哪些手段能够在容器与外部系统之间沟通交流。
# 如何拷贝容器内的数据
Docker提供的 docker cp
命令,很类似Linux的“cp”“scp”,它可以在宿主机和容器之间拷贝文件,是最基本的一种数据交换功能。
用法:
- 把文件拷贝进容器:
docker cp [src path] [container id/name]:[dest path]
- 把文件拷贝出容器: 更换源路径(src path)和目标路径(dest path)就可以了
假设当前目录下有一个“a.txt”的文件,现在我们要把它拷贝进Redis容器的“/tmp”目录,如果使用容器ID,命令就会是这样:
docker cp a.txt 062:/tmp
docker cp
的用法模仿了操作系统的拷贝命令,偶尔一两次的文件共享还可以应付,如果容器运行时经常有文件来往互通,这样反复地拷来拷去就显得很麻烦,也很容易出错。
# 如何共享主机上的文件:挂载
容器提供了类似虚拟机共享宿主机目录的功能,效果也几乎一样,用起来很方便,只需要在 docker run
命令启动容器的时候使用 -v
参数就行,具体的格式是“ 宿主机路径:容器内路径”。--> 把宿主机目录挂载进容器内部.
-v
挂载目录时若发现源路径不存在,会自动创建,这有时候会是一个“坑”,当主机目录被意外删除时会导致容器里出现空目录,让应用无法按预想的流程工作。-v
挂载目录默认是可读可写的,但也可以加上ro变成只读,可以防止容器意外修改文件,例如-Vtmp:/tmp:ro
。
一个文件挂载的例子
我还是以Redis为例,启动容器,使用 -v
参数把本机的“/tmp”目录挂载到容器里的“/tmp”目录,也就是说让容器共享宿主机的“/tmp”目录:
docker run -d --rm -v /tmp:/tmp redis
然后我们再用 docker exec
进入容器,查看一下容器内的“/tmp”目录,应该就可以看到文件与宿主机是完全一致的。
docker exec -it b5a sh # b5a是容器ID
-v
参数挂载宿主机目录的这个功能,对于我们日常开发测试工作来说非常有用,我们可以在不变动本机环境的前提下,使用镜像安装任意的应用,然后直接以容器来运行我们本地的源码、脚本,非常方便。
这里我举一个简单的例子。比如我本机上只有Python 2.7,但我想用Python 3开发,如果同时安装Python 2和Python 3很容易就会把系统搞乱,所以我就可以这么做:
- 先使用
docker pull
拉取一个Python 3的镜像,因为它打包了完整的运行环境,运行时有隔离,所以不会对现有系统的Python 2.7产生任何影响。 - 在本地的某个目录编写Python代码,然后用
-v
参数让容器共享这个目录。 - 现在就可以在容器里以Python 3来安装各种包,再运行脚本做开发了。
docker pull python:alpine
docker run -it --rm -v `pwd`:/tmp python:alpine sh
2
3
显然,这种方式比把文件打包到镜像或者 docker cp
会更加灵活,非常适合有频繁修改的开发测试工作。
# 如何实现网络互通
使用 docker cp
和 docker run -v
可以解决容器与外界的文件互通问题,但对于Nginx、Redis这些服务器来说,网络互通才是更要紧的问题。网络互通的关键在于“打通”容器内外的网络。
Docker提供了三种网络模式,分别是 null、 host 和 bridge。
- null 是最简单的模式,也就是没有网络,但允许其他的网络插件来自定义网络连接
- host 的意思是直接使用宿主机网络,相当于去掉了容器的网络隔离(其他隔离依然保留),所有的容器会共享宿主机的IP地址和网卡。这种模式没有中间层,自然通信效率高,但缺少了隔离,运行太多的容器也容易导致端口冲突。
- host模式需要在
docker run
时使用--net=host
参数
- host模式需要在
使用 host 网络模式的例子
下面用--net=host
这个参数启动Nginx:
docker run -d --rm --net=host nginx:alpine
为了验证效果,我们可以在本机和容器里分别执行 ip addr
命令,查看网卡信息:
ip addr # 本机查看网卡
docker exec xxx ip addr # 容器查看网卡
2
可以发现这两个命令的输出信息是完全一样的,如网卡 ensxxx 和 IP 地址。
- bridge,也就是桥接模式,它有点类似现实世界里的交换机、路由器,只不过是由软件虚拟出来的,容器和宿主机再通过虚拟网卡接入这个网桥(图中的docker0),那么它们之间也就可以正常的收发网络数据包了。不过和host模式相比,bridge模式多了虚拟网桥和网卡,通信效率会低一些。
- 可以用
--net=bridge
来启用桥接模式,但其实不必要,因为Docker默认的网络模式就是bridge,所以一般不需要显式指定。
- 可以用
使用 bridge 网络模式的例子
下面我们启动两个容器Nginx和Redis,就像刚才说的,没有特殊指定就会使用bridge模式:
docker run -d --rm nginx:alpine # 默认使用桥接模式
docker run -d --rm redis # 默认使用桥接模式
2
然后我们还是在本机和容器里执行 ip addr
命令(Redis容器里没有ip命令,所以只能在Nginx容器里执行):
对比一下刚才host模式的输出,就可以发现容器里的网卡设置与宿主机完全不同,eth0是一个虚拟网卡,IP地址是B类私有地址“172.17.0.4”。
我们还可以用 docker inspect
直接查看容器的ip地址:
docker inspect xxx |grep IPAddress
这显示出两个容器的IP地址分别是“172.17.0.3”和“172.17.0.4”,而宿主机的IP地址则是“172.17.0.1”,所以它们都在“172.17.0.0/16”这个Docker的默认网段,彼此之间就能够使用IP地址来实现网络通信了。
# 如何分配服务端口号
服务器应用都必须要有端口号才能对外提供服务。前面第4节我们在学习编写Dockerfile的时候也看到过,可以用 EXPOSE
指令声明容器对外的端口号。
一台主机上的端口号数量是有限的,而且多个服务之间还不能够冲突,但我们打包镜像应用的时候通常都使用的是默认端口,容器实际运行起来就很容易因为端口号被占用而无法启动。
解决这个问题的方法就是加入一个“中间层”,由容器环境例如Docker来统一管理分配端口号,在本机端口和容器端口之间做一个“映射”操作,容器内部还是用自己的端口号,但外界看到的却是另外一个端口号,这样就很好地避免了冲突。
端口号映射需要使用bridge模式,并且在 docker run
启动容器时使用 -p
参数,形式和共享目录的 -v
参数很类似,用 :
分隔本机端口和容器端口。
分配服务端口号的例子
如果要启动两个Nginx容器,分别跑在80和8080端口上:
docker run -d -p 80:80 --rm nginx:alpine
docker run -d -p 8080:80 --rm nginx:alpine
2
这样就把本机的80和8080端口分别“映射”到了两个容器里的80端口,不会发生冲突,我们可以用curl再验证一下。
使用 docker ps
命令能够在“PORTS”栏里更直观地看到端口的映射情况。