Docker原理和基本使用

159 阅读19分钟

Docker是一个开源的应用容器引擎,基于Go语言并遵循Apatch2.0协议。主要的作用是将应用程序和基础设施进行分离,方便快速进行交付软件。

其中docker包含了三个基本的概念:

镜像(Image) : 镜像是一个只读的模板,相当于root文件系统。官方提供了许多镜像,如Ubuntu16.04 镜像包含了Ubuntu16.04 最小系统的 root 文件系统。

容器(Container) :容器是镜像运行的实体,可以被创建、启动、停止、暂停以及删除。它和镜像的关系类似于类和实例的关系。容器是实例,镜像是静态的定义。

仓库(Repository) :仓库用于代码控制,保存镜像。

此外,Docker使用客户端-服务端架构(C/S)Docker 客户端是Docker用户与Docker交互的主要方式。客户端将我们的命令,类似于:docker run 发送到Docker守护进程。守护进程侦听Docker API请求并管理Docker对象。同时,守护进程还可以与其他守护进行进行通信来管理Docker服务。Docker 客户端和守护进程可以在同一系统上运行,也可以将 Docker 客户端连接到远程 Docker 守护进程。

总的来说,Docker 技术使用 Linux 内核和内核功能来分隔进程,以便各进程相互独立运行。它可以独立运行多种进程、多个应用,更加充分地发挥基础设施的作用,同时保持各个独立系统的安全性。此外提供基于镜像的部署模式,这使得它能够轻松跨多种环境,与其依赖程序共享应用或服务组。一个docker 容器,是一个运行时环境,可以简单理解为进程运行的集装箱。

Docker的出现一定意义上是为了解决开发环境和生产环境一致性的问题,通过Docker可以将环境也纳入到版本控制中,排除环境不同导致的差异。因此,如何的虚拟化技术解决这个问题是实现Docker的一个核心技术。

docker实现原理

docker和kvm都是虚拟化技术,但是docker具有更少的抽象层,简单来说,就是VM是在宿主机器、宿主机器的操作系统之上创建虚拟层、虚拟化操作系统、虚拟化仓库,再安装应用。而docker是在宿主机器和宿主操作系统之上创建Docker引擎,在引擎的基础上安装应用。docker是利用宿主机的操作系统,省略了重新加载操作系统的过程,启动速度是妙级的,同时单机上支持上千容器。而虚拟机加载的Guest OS是分钟级的。

Namespace

当我们在服务器上启动多个服务的时候,服务会相互影响,没有办法做到完全隔离。而Docker可以通过namespace对不同容器进行隔离。Linux的命名空间提供了分离进程树、网络接口、挂载点以及进程通信等资源的方法。Linux Namespace是一种内核级别的资源隔离机制,它可以使得一组进程可以看到一组资源,而无法看到其他组的资源。其原理就是让一组资源使用相同的namespace,而不同的namespace引用的是不同的资源。目前的namespace类型如下:

Namespace系统调用参数隔离内容
UTSCLONE_NEWUTS主机名与域名
IPCCLONE_NEWIPC信号量、消息队列和共享内存
PIDCLONE_NEWPID进程编号
NetworkCLONE_NEWNET网络设备、网络栈、端口等等
MountCLONE_NEWNS挂载点(文件系统)
UserCLONE_NEWUSER用户和用户组
CgroupCLONE_NEWCGROUPCgroup的根目录
TimeCLONE_NEWTIME时钟

进程隔离

每个进程都有一个关于namespace的属性nsproxy表示所属的namespace,其中nsproxy就是指向各种namespace的代理。当新进程被创建之后会继承父进程的namespace,这就是为啥一个容器里所有的进程可以共享namespace的原因。

在所有的进程中有两个特殊的进程,一个是 pid 为 1 的 /user/lib/system 进程,另一个是 pid 为 2 的 kthreadd 进程,这两个进程都是被 Linux 中的上帝进程 idle创建出来的,其中前者负责执行内核的一部分初始化工作和系统配置,也会创建一些类似 getty 的注册进程,而后者负责管理和调度其他的内核进程。 注意:在PID=1的进程是Linux 系统中的 init 进程,它是系统的第一个进程,在现代 Linux 发行版中,init 进程通常由 systemd 代替。

当我们创建了一个新的Docker,并通过 exec 进入其内部的 bash 并打印其中的全部进程,会发现容器内部进程列表不包含宿主机器的其他进程。

使用 Linux 的命名空间实现进程的隔离,Docker 容器内部的任意进程都对宿主机器的进程一无所知。通过进程可以构成如下进程树。

其中,containerd 进程是容器的管理器,负责整个容器的生命周期和资源管理,而 containerd-shim 进程是容器运行时的一部分,负责在容器运行时环境中管理容器的各个方面,包括启动、监控、事件传递等。这两个组件协同工作,实现了容器的高效运行和管理。

当使用Linux的clone创建一个新进程的时候,会传入CLONE_NEWPID,通过命名空间进行隔离,使得Docker 容器内部的任意进程都对宿主机器的进程一无所知。

网络隔离

当Docker通过namespace完成了与宿主机的进程的网络隔离之后,导致无法通过宿主机的网络连接整个互联网,这里Docker为我们提供了四种不同的网络模式:Host、Container、None、Bridge。

其中,**Bridge模式除了分配网络命名空间之外,会为所有的容器设置IP地址。**其具体流程:

  • 当 Docker 服务器在主机上启动之后会创建新的虚拟网桥 docker0,随后在该主机上启动的全部服务在默认情况下都与该网桥相连。
  • 每一个容器创建时,会创建一对虚拟网卡,一个放在创建的容器中,一个放在docker0网桥中。
  • docker0会为每一个容器分配一个新的IP地址,而并将docker0的ip地址设置成默认网关。
  • 网桥通过iptables的配置与宿主机器的网卡连接,所有符合条件的请求都会通过 iptables 转发到 docker0 并由网桥分发给对应的机器。

Docker 是如何将容器的内部的端口暴露出来并对数据包进行转发的呢?

当Docker的容器需要将服务暴露给宿主机器的时候,为容器分配一个IP地址,向iptable中追加新的规则,通过 iptables 进行数据包转发,让 Docker 容器能够优雅地为宿主机器或者其他容器提供服务。

Cgroups实现资源限制

虽然namespace为我们提供了进程、文件系统等之间的相互隔离,但没办法实现物理层面的隔离。如果这些容器占用了相同的物理资源,而一个容器在执行CPU密集型的任务,会对其他容器中任务的性能和执行效率造成影响。而 Control Groups(简称 CGroups)就是能够隔离宿主机器上的物理资源,例如 CPU、内存、磁盘 I/O 和网络带宽。

在Linux中,CGroup以文件和目录的形式存在操作系统的/sys/fs/cgroup路径下(mount -t cgroup),具体的子系统如下:

这些就是当前机器可以被cgroup进行限制的资源种类,Cgroup都是被相同标准和参数限制的进程,不同的Cgroup之间有层级关系,他们可以从父类继承一些用于资源使用和标准的参数。每个进程都可以加入一个Cgroup也可以随时退出一个Cgroup。

以cpu限制举例,我们可以查看CPU的限制情况:

两个参数cfs_quota_uscfs_period_us,它表示的意思是:限制进程在长度为**cfs_period_us的一段时间内,只能被分配到总量为cfs_quota_us**的CPU时间。-1代表没有限制,100000代表100ms。也就是说,默认情况下,任何一个线程在 100ms 的时间周期内,「最多」能被分配到的CPU时间就是 100ms,不会被限制。

Linux 使用文件系统来实现 CGroup,如果我们想要创建一个新的 group 只需要在想要分配或者限制资源的子系统下面创建一个新的文件夹,然后这个文件夹下就会自动出现很多的内容。 (新建文件为test_cpu_cgroup)

如果linux上已经安装了Docker,会发现所有的子系统目录下都存在一个docker文件夹。这里就有关于CPU资源的各种限制。我们创建一个进程死循环的进程 while : ; do : ; done &,这是他对CPU的使用是没有限制的。

通过修改cfs_quota_us的限制,一**个进程「最多」只能使用到 的CPU限制在20% **,并将被限制的PID写入tasks文件。即可控制对资源的使用情况。

echo 20000 > ./cpu.cfs_quota_us 
echo 20101 > ./tasks 

总的来说,启动容器的时候,docker会为这个容器创建一个和容器标识符一样的cgroup而每个cgroup下都有一个tasks文件,这个文件中存储着属于当前Cgroup的所有进程的 pid。当我们关闭掉正在运行的容器时,Docker 的子控制组对应的文件夹也会被 Docker 进程移除,Docker 在使用 CGroup 时其实也只是做了一些创建文件夹改变文件内容的文件操作。

UnionFS

联合文件系统是一种分层、轻量级并高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。Docker是怎么使用联合文件系统的呢?

首先,我们理解一下Docker的存储驱动。

Docker中每一个镜像都是一系列的只读的层,Dockerfile中每一个命令都会在已有的只读的层上创建一个新的层。如果这个时候我们执行了**docker run**** ,会在最上面的层上添加一个可写的层,也就是容器层。所有对于运行时容器的修改其实都是对这个容器读写层的修改。**

镜像其实本质上是一个文件,联合文件系统是docker镜像的基础**。因为镜像是通过分层来实现继承,基于父镜像可以制作各种新的镜像**。另外,不同 Docker 容器就可以共享一些基础的文件系统层,同时再加上自己独有的改动层,大大提高了存储的效率。

~~AUFS,AUFS支持为每一个成员目录设置只读、读写和写出权限,同时AUFS中有一个类似分层的概念,对只读权限的分支可以逻辑上进行增量地修改(不影响只读部分的)。每一个镜像层或者容器层都是 ~~~~/var/lib/docker/~~~~ 目录下的一个子文件夹。在 Docker 中,所有镜像层和容器层的内容都存储在 ~~~~/var/lib/docker/aufs/diff/~~~~ 目录中 ~~

docker的基本使用

使用镜像

  • 获取: (镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件)
docker pull ubuntu:18.04
  • 查看:
docker image ls  
docker system df  // 查看镜像、容器、数据卷所占用的空间

由于新旧镜像同名,旧镜像的名字被取消,从而出现的名字为的镜像称为虚悬镜像,可以通过docker image prune删除

  • 删除
 docker image rm <镜像ID、镜像名、镜像摘要>
  • commit:适用于入侵之后保存现场

    • 我们可以启动一个nignx容器并进入该容器,对内容进行修改。随后使用commit该容器保存为新的镜像。

      docker run --name web-commit -d -p 80:80 nginx
      docker exec -it web-commit bash
      echo '<h1>bakabaka</h1>' > /usr/share/nginx/html/index.html
      docker diff web-commit
      docker commit --author "fox" --message "change" web-commit nginx:v2
      

  • 我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像

  • 不推荐使用 docker commit 制作镜像,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。

  • 使用Dockerfile构建镜像

    首先我们需要在项目根目录下创建Dockerfile文件

# syntax=docker/dockerfile:1

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
EXPOSE 3000
  • 构建镜像
docker build -t <image_name> .
  • 启动容器,-p标志采用HOST:CONTAINER 格式的字符串值
docker run -dp 127.0.0.1:3000:3000 <image_name>

使用容器

  • 新建启动 docker run ubuntu:18.04
  • 终止 docker container stop
  • 进入容器 docker attach <container_id>docker exec -it <container_id> bash
  • 导入导出 docker export container_id > ubuntu.tar & cat ubuntu.tar | docker import - test/ubuntu:v1.0
  • 删除 docker rm <the-container-id>

dockerfile的基本使用

一个基础的Dockerfile(FROM& RUN)

docker镜像由只读层组成,每一层是一个Dockefile指令,这些层是堆叠在一起的,每一层都是与前一层的变化的增量。

# syntax=docker/dockerfile:1

FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

每条指令创建一层:

  • FROM从 Docker 镜像创建一个层。FROM指定了一个基础的镜像,且必须是第一条指令。在Docker Hub中又许多的官方镜像可以供我们选择,如node、nginx、redis等。特殊的,Docker 存在一个名为 scratch的空白镜像。

  • RUN使用 make构建您的应用程序。RUN指令是用来执行命令行命令的,其格式有两种:

    • shell格式,即RUN <命令>,如:RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
    • exec 格式,即RUN ["可执行文件", "参数1", "参数2"],类似于函数调用。如:RUN yum install gcc

    值得注意的是:

    Dockerfile 中每一个指令都会建立一层,每一个 RUN 的行为,也会新建立一层,在这个层上执行命令,执行结束后,commit 这一层的修改,构成新的镜像。如果存在多个RUN的操作(如下),结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。同时UnionFS还有最大层数限制,比如AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。

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"

// after
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" \
    && rm redis.tar.gz 

这个时候我们基于上面的Dockerfile构建一个镜像,

启动容器docker run -dp 0.0.0.1:82:80 <image_name>

镜像构建上下文

Docker 引擎(也就是服务端守护进程)提供了一组 API, docker 客户端通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在**服务端(Docker 引擎)**完成。当我们进行镜像构建的时候,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。docker build 命令构建镜像,其实并非在本地构建,而是在 Docker 引擎中构建的

这里就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

Dockerfile指令基本使用

  • COPY :

    • 格式: COPY <源路径>... <目标路径>

    • 原路径可以是多个甚至是通配符,目标路径可以是容器内的绝对路径也可以是相对于WORKDIR置顶目录的相对路径。使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。

    • 在dockerfile中添加COPY:COPY index.html /usr/share/nginx/html/index.html ,构建镜像并运行容器效果如下

  • ADD

    • 格式: ADD <源路径>... <目标路径>
    • ADD可以看成一个更高级的复制文件,在COPY的基础上添加了一些功能,例如当源路径是一个连接,这是Docker引擎会试图去下载这个连接的文件放到目标路径。如果原路径是一个压缩包,ADD会自动解压文件。但是ADD会导致镜像构建的缓存失效,肯呢个会导致镜像构建变得缓慢。
    • 一般情况下可以遵循这样的原则,所有的文件复制均使用 COPY 指令,仅在需要自动解压缩的场合使用 ADD
  • CMD

    • CMD指令和RUN很类似,也是两种格式shell和 exec。在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的。
    • 例如:CMD ["nginx", "-g", "daemon off;"]
  • ENTRYPOINT

    • ENTRYPOINT 的格式和 RUN 指令格式一样,分为 exec 格式和 shell 格式。ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。指定了 ENTRYPOINT 后,CMD 的含义就发生了改变,将变为:<ENTRYPOINT> "<CMD>"
  • ENV

    • 设置环境变量,格式:ENV <key> <value>ENV <key1>=<value1> <key2>=<value2>...
  • VOLUME

    • 定义匿名卷:VOLUME <路径>
    • 容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,数据库文件应该保存于卷(volume)中。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,也不会向容器存储层写入大量数据。
  • EXPOSE

    • 暴露端口: EXPOSE <端口1> [<端口2>...]
    • 容器运行时,并不会因为这个声明就开启这个端口的服务。这里只是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。
    • 要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。
  • WORKDIR

    • 指定工作目录,如该目录不存在,WORKDIR 会帮你建立目录。

docker-compose的基本使用

Compose的定位是定义和运行多个Docker容器的应用。它允许用户通过一个单独的docker-compose.yml文件来定义一组相关联的应用容器。Compose实现上调用了 Docker 服务提供的 API 来对容器进行管理。Compose 适用于生产、登台、开发、测试以及 CI 工作流程。它还具有用于管理应用程序整个生命周期的命令。

基本的docker-compose的使用需要编写Dockefile文件和docker-compose.yml,并通过docker-compose up来运行。

compose常用命令

  • build: 构建项目中的服务容器,
docker-compose build

选项包括:

  • --pull 始终尝试通过 pull 来获取更新版本的镜像。
  • --no-cache 构建镜像过程中不使用 cache(这将加长构建过程)。
  • --force-rm 删除构建过程中的临时容器。

  • 启动服务:
docker-compose up -d  // 后台运行
  • 停止服务
docker-compose down
  • 停止已运行的服务
docker-compose stop
  • 启动服务
docker-compose start
  • 查看容器
docker-compose ps
  • 查看日志
docker-compose logs

compose模版文件

默认的模版文件名称docker-compose.yml,每个服务通过image执行镜像。Docker Compose 的 YAML 文件包含 4 个一级 key:version、services、networks、volumes

  • version 是必须指定的,而且总是位于文件的第一行。它定义了 Compose 文件格式(主要是 API)的版本。注意,version 并非定义 Docker Compose 或 Docker 引擎的版本号。
  • services 用于定义不同的应用服务。上边的例子定义了两个服务:一个名为 lagou-mysql数据库服 务以及一个名为lagou-eureka的微服。Docker Compose 会将每个服务部署在各自的容器中。
  • networks 用于指引 Docker 创建新的网络。默认情况下,Docker Compose 会创建 bridge 网络。 这是一种单主机网络,只能够实现同一主机上容器的连接。当然,也可以使用 driver 属性来指定不 同的网络类型。
  • volumes 用于指引 Docker 来创建新的卷。

这里,我们使用一个常见的docker-compose文件举例部分配置

version: '3'
services:
  app:
    image: node:latest
    container_name: app_main
    restart: always
    command: sh -c "yarn install && yarn start"
    ports:
      - 8000:8000
    working_dir: /app
    volumes:
      - ./:/app
    environment:
      MYSQL_HOST: localhost
      MYSQL_USER: root
      MYSQL_PASSWORD: 
      MYSQL_DB: test
  mongo:
    image: mongo
    container_name: app_mongo
    restart: always
    ports:
      - 27017:27017
    volumes:
      - ~/mongo:/data/db
volumes:
  mongodb:

其中:

  • version 指的是 docker-compose 的版本(最新的是 3)
  • services 定义了我们需要运行的服务
  • app 是你的一个容器的自定义名称
  • image 指的是我们要拉取的镜像,这里我们使用的是 node:latestmongo
  • container_name 是每个容器的名称
  • restart 启动/重启一个服务容器
  • port 定义了运行该容器的自定义端口
  • working_dir 是服务容器的当前工作目录
  • environment 定义了环境变量,如 DB 凭证等
  • command 是运行该服务的命令