第2章 基础设施即服务(IaaS)-2-Docker

什么是Docker

Docker最初是dotCloud公司创始人Solomon Hykes在法国期间发起的一个公司内部项目,它是基于dotCloud公司多年云服务技术的一次革新,并于2013年3月以Apache 2.0授权协议开源,主要项目代码在GitHub上进行维护。Docker 项目后来还加入了Linux基金会,并成立推动开放容器联盟( OCI )。

Docker自开源后受到广泛的关注和讨论,至今其GitHub项目已经超过4万6千个星标和一万多个fork。甚至由于Docker项目的火爆,在2013年底,dotCloud公司决定改名为Docker。Docker 最初是在Ubuntu 12.04上开发实现的; Red Hat则从RHEL 6.5开始对Docker进行支持; Google也在其PaaS产

品中广泛应用Docker。

Docker使用Google公司推出的Go语言进行开发实现,基于Linux 内核的cgroup,namespace,以及AUFS类的Union FS等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术。由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。最初实现是基于LXC,从0.7版本以后开始去除LXC,转而使用自行开发的libcontainer,从1.11开始,则进一步演进为使用runC和containerd。

Docker在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护。使得Docker技术比虚拟机技术更为轻便、快捷。

下面的图片比较了Docker和传统虚拟化方式的不同之处。传统虚拟机技术是虚拟出一套硬件后,在其 上运行一个完整操作系统 ,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。

为什么要使用Docker

作为一种新兴的虚拟化方式,Docker跟传统的虚拟化方式相比具有众多的优势。

更高效的利用系统资源

由于容器不需要进行硬件虚拟以及运行完整操作系统等额外开销,Docker对系统资源的利用率更高。无论是应用执行速度、内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的主机,往往可以运行更多数量的应用。

更快速的启动时间

传统的虚拟机技术启动应用服务往往需要数分钟,而Docker容器应用,由于直接运行于宿主内核,无需启动完整的操作系统,因此可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。

一致的运行环境

开发过程中一个常见的问题是环境一致性问题。 由于开发环境、测试环境、生产环境不一致,导致有些bug并未在开发过程中被发现。而Docker的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现「这段代码在我机器上没问题啊」这类问题。

持续交付和部署

对开发和运维( DevOps)人员来说,最希望的就是一次创建或配置,可以在任意地方正常运行。

使用Docker可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员可以通过Dockerfile 来进行镜像构建,并结合持续集成(Continuous Integration)系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合持续部署(Continuous Delivery/Deployment)系统进行自动部

署。

而且使用Dockerfile 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。

更轻松的迁移

由于Docker确保了执行环境的一致性,使得应用的迁移更加容易。Docker 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云,甚至是笔记本,其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。

更轻松的维护和扩展

Docker使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩 展镜像也变得非常简单。此外,Docker团队同各个开源项目团队一起维护了一大批高质量的宜方镜像,既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务 的镜像制作成本。

对比传统虚拟机总结

特性

容器

虚拟机

启动

秒级

分钟级

硬盘使用

一般为MB

一般为GB

性能

接近原生

弱于

系统支持量

单机支持上千个容器

一般几十个

Docker引擎

Docker引擎是一个包含以下主要组件的客户端服务器应用程序。

  • 一种服务器,他是一个称为成为守护进程并且长时间运行的程序。

  • REST API用于指定程序可以用来与守护进程通信的接口,并指示它做什么。

  • 一个有命令行界面(CLI)工具的客户端。

Docker引擎组件的流程如下图所示:

Docker架构

Docker使用客户端-服务器(C/S)架构模式,使用远程API来管理和创建Docker容器。

Docker容器通过Docker镜像来创建。

容器与镜像的关系类似于面对对象编程中的对象与类。

Docker

面向对象

容器

对象

镜像

标题

说明

镜像(Images)

Docker镜像是用于创建Docker容器的模板。

容器(Container)

容器是独立运行的一个或一组应用。

客户端(Client)

Docker客户端通过命令行或者其他工具使用Docker API(https://docs.docker.com/reference/api/docker_remote_api)与Docker的守护进程通信。

主机(Host)

一个物理或者虚拟的机器用于执行Docker守护进程和容器。

仓库(Registry)

Docker仓库用来保存镜像,可以理解为代码控制中的代码仓库。Docker hub(https://hub.docker.com)提供了庞大的镜像集合供使用。

Docker Machine

Docker Machine 是一个简化Docker安装的命令行工具,通过一个简单的命令行即可在相应的平台上安装Docker,比如VirtualBox、Digtal Ocean、Microsofe Azure。

Docker镜像

我们都知道, 操作系统分为内核和用户空间。对于Linux而言,内核启动后,会挂载root文件系统为其提供用户空间支持。而Docker镜像( Image), 就相当于是一个root文件系统。比如官方镜像ubuntu:16.04就包含了完整的一套Ubuntu 16.04最小系统的root 文件系统。

Docker镜像是一个特殊的文件系统 ,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

分层存储

因为镜像包含操作系统完整的root 文件系统,其体积往往是庞大的,因此在Docker设计时, 就充分利用Union FS的技术,将其设计为分层存储的架构。所以严格来说,镜像并非是像一个ISO那样的打包文件,镜像只是一个虚拟的概念,其实际体现并非由一个文件组成,而是由一组文件系统组成,或者说,由多层文件系统联合组成。

镜像构建时,会一层层构建 ,前一层是后一层的基础。 每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。

分层存储的特征还使得镜像的复用、定制变的更为容易。甚至可以用之前构建好的镜像作为基础层, 然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。

关于镜像构建,将会在后续相关章节中做进一步的讲解。

Docker容器

镜像( Image ) 和容器( Container ) 的关系,就像是面向对象程序设计中的实例一样 ,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。

容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户ID空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性

使得容器封装的应用比直接在宿主运行更加安全。也因为这种隔离的特性,很多人初学Docker时常常会混淆容器和虚拟机。

前面讲过镜像使用的是分层存储,容器也是如此。每一个容器运行时 ,是以镜像为基础层,在其上创建一个当前容器的存储层 ,我们可以称这个为容器运行时读写而准备的存储层为容器存储层

容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。

按照Docker最佳实践的要求,容器不应该向其存储层内写入任何数据,容器存储层要保持无状态化。所有的文件写入操作,都应该使用数据卷(Volume)、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主 (或网络存储)发生读写,其性能和稳定性更高。

数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。

Docker仓库

镜像构建完成后,可以很容易的在当前宿主机上运行,但是,如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、 分发镜像的服务,Docker Registry就是这样的服务。

一个Docker Registry中可以包含多个仓库( Repository ) ;每个仓库可以包含多个标签( Tag ) ; 每个标签对应一个镜像。

通常,一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本。我们可以通过<仓库名>:<标签>的格式来指定具体是这个软件哪个版本的镜像。如果不给出标签,将以latest 作为默认标签。

以Ubuntu镜像为例,ubuntu 是仓库的名字,其内包含有不同的版本标签,如,14.04, 16.04。我们可以通过ubuntu:14.04, 或者ubuntu:16. 04来具体指定所需哪个版本的镜像。如果忽略了标签,比如ubuntu, 那将视为ubuntu:latest

仓库名经常以两段式路径形式出现,比如jwilder/nginx-proxy , 前者往往意味着Docker Registry多用户环境下的用户名,后者则往往是对应的软件名。但这并非绝对,取决于所使用的具体Docker Registry的软件或服务。

公有Docker Registry

Docker Registry公开服务是开放给用户使用、允许用户管理镜像的Registry服务。一般这类公开服务允许用户免费上传、下载公开的镜像,并可能提供收费服务供用户管理私有镜像。

最常使用的Registry公开服务是官方的Docker Hub,这也是默认的Registry,并拥有大量的高质量的官方镜像。除此以外,还有CoreOSQuay.io,CoreOS相关的镜像存储在这里; Google的Google Container Registry,Kubernetes的镜像使用的就是这个服务。

由于某些原因,在国内访问这些服务可能会比较慢。国内的一些云服务商提供了针对Docker Hub的镜像服务( Registry Mirror ) , 这些镜像服务被称为加速器。常见的有阿里云加速器DaoCloud加速器等。使用加速器会直接从国内的地址下载Docker Hub的镜像,比直接从Docker Hub下载速度会提高很多。

国内也有一些云服务商提供类似于Docker Hub的公开服务。比如时速云镜像仓库网易云镜像服务DaoCloud 镜像市场阿里云镜像库等。

私有Docker Registry

除了使用公开服务外,用户还可以在本地搭建私有Docker Registry。Docker 官方提供了Docker Registry镜像,可以直接使用做为私有Registry服务。

开源的Docker Registry镜像只提供了Docker Registry API的服务端实现,足以支持docker命令,不影响使用。但不包含图形界面,以及镜像维护、用户管理、访问控制等高级功能。在官方的商业化版本Docker Trusted Registry.中,提供了这些高级功能。

除了官方的Docker Registry外,还有第三方软件实现了Docker Registry API,甚至提供了用户界面以及一些高级功能。 比如,VMWare HarborSonatypeNexus

Ubuntu安装Docker

警告:切勿在没有配置Docker APT源的情况下直接使用apt命令安装Docker。

准备工作

系统要求

Docker CE支持以下版本的Ubuntu操作系统:

  • Artful 17.10(Docker CE 17.11 Edge +)

  • Xenial 16.04(LTS)

  • Trusty 14.04(LTS)

Docker CE 可以安装在64位的x86平台或ARM平台上。Ubuntu发行版中,LTS(Long-Term-Support)长期支持版本,会获得5年的升级维护支持,这样的版本会更稳定,因此在生产环境中推荐使用LTS版本,当前最新的LTS版本为Ubuntu 16.04。

卸载旧版本

旧版本的Docker 称为docker或者docker-engine,使用一下命令卸载旧版本:

$ sudo apt-get remove docker \
               docker-engine \
               docker.io

Ubuntu 14.04 可选内核模块

从Ubuntu 14.04开始,一部分内核模块移到了可选内核模块包( linux-image-extra-*), 以减少内核软件包的体积。正常安装的系统应该会包含可选内核模块包,而一些裁剪后的系统可能会将其精简掉。AUFS 内核驱动属于可选内核模块的一部分,作为推荐的Docker存储层驱动,一般建议安装可选内核模块包以使用AUFS

如果系统没有安装可选内核模块的话,可以执行下面的命令来安装可选内核模块包:

$ sudo apt-get update

$ sudo apt-get install \
    linux-image-extra-$(uname -r) \
    linux-image-extra-virtual

Ubuntu 16.04 +

ubuntu 16.04+上的Docker CE 默认使用overlay2存储层驱动,无需手动配置。

使用APT安装(不推荐)

由于apt源使用HTTPS以确保软件下载过程中不被篡改。因此,我们首先需要添加使用HTTPS传输的软件包以及CA证书。

$ sudo apt-get update

$ sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

鉴于国内网络问题,强烈建议使用国内源,官方源请在注释中查看。

为了确认所下载软件包的合法性,需要添加软件源的GPG密钥。

$ curl -fsSL https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg | sudo apt-key add -

# 官方源
# $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

然后,我们需要向source.list中添加Docker软件源。

$ sudo add-apt-repository \
    "deb [arch=amd64] https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu \
    $(lsp_release -cs) \
    stable"

以上命令会添加稳定版本的Docker CE APT 镜像源,如果需要最新或者测试版本的Docker CE 请将stable改为edge或者test。从Docker17.06开始,edge test版本的APT镜像源也会包含稳定版本的Docker。

安装Docker CE

更新apt软件包缓存,并安装docker-ce:

$ sudo apt-get update
$ sudo apt-get install docker-ce

使用脚本自动安装(推荐)

在测试或开发环境中Docker官方为了简化安装流程,提供了一套便捷的安装脚本,Ubuntu系统上可以使用这套脚本安装:

$ curl -fsSL get.docker.com -o get-docker.sh         #curl: http请求
$ sudo sh get-docker.sh --mirror Aliyun

执行这个命令后,脚本就会自动的将一切准备工作做好,并且把Docker CE 的Edge版本安装在系统中。

启动Docker CE

$ sudo systemctl enable docker
$ sudo systemctl start docker

Ubuntu 14.04 请使用一下命令启动:

$ sudo service docker start

建立docker用户组

默认请跨国下,docker命令会使用Unix socket与Docker引擎通讯。而只有root用户和docker组的用户才可以访问Docker引擎的Unix socket。出于安全考虑,一般Linux系统上不会直接使用root用户。因此,更好地做法是将需要使用docker的用户加入docker用户组。

建立docker组:

$ sudo groupadd docker

将当前用户加入docker组:

sudo usermod -aG docker $USER

Docker镜像加速器

国内从Docker Hub拉取镜像有时会遇到困难,此时可以配置镜像加速器。Docker 官方和国内很多云服务商都提供了国内加速器服务,例如:

  • Docker官方提供的中国registry mirror

阿里云加速器

DaoCloud加速器

我们以Docker官方加速器为例进行介绍。

Ubuntu 14.04、Debian 7 Wheezy

对于使用upstart的系统而言,编辑 /etc/default/docker 文件,在其中的DOCKER_OPTS中配置加速器地址:

DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com"

重新启动服务。

$ sudo service docker restart

Ubuntu 16.04+、Debian 8+、CentOS7

对于使用systemd的系统,请在 /etc/docker/daemon.json中写入如下内容(如果文件不存在请新建该文件)

{
 "registry-mirrors":[
   "https://registry.docker-cn.com"
 ]    
}

注意:一定要保证该文件符合json规范,否则Docker将不能启动。

之后重新启动服务。

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker

注意:如果您之前查看旧教程,修改了docker.service文件内容,请去掉您添加的内容(--registry-mirror=https://registry.docker-cn.com),这里不再赘述。

Window 10

对于使用Window 10 的系统,在系统右下角托盘 Docker 图标内右键菜单选择Settings,打开配置窗口后左侧导航菜单选择Daemon。在Registry mirrors一栏中填写加速器地址 https://registry.docker-cn.com,之后点击Apply保存后Docker就会重启并应用配置的镜像地址了。

使用Docker镜像

在之前的介绍中,我们知道镜像是Docker的三大组件之一。

Docker运行客器前需要本地存在对应的镜像,如果本地不存在该镜像,Docker会从镜像仓库下载该镜像。

本章将介绍更多关于镜像的内容,包括:

  • 从仓库获取镜像;

  • 管理本地主机上的镜像;

  • 介绍镜像实现的基本原理。

获取镜像

之前提到过,Docker Hub上有大量的高质量的镜像可以用,这里说下怎么获取这些镜像。

从Docker镜像仓库获取镜像的命令是docker pull。其命令格式为:

docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]

具体的选项可以通过docker pull --help命令看到,这里我们说一下镜像名称的格式。

  • Docker镜像仓库地址:地址的格式一般是<域名/IP>/[:端口号]。默认地址是Docker Hub。

  • 仓库名:如之前所说,这里的仓库名是两段式名称,即<用户名>/[软件名]。对于Docker Hub,如果不给出用户名,默认为library,也就是官方镜像。

比如:

$ docker pull ubuntu:16.04
16.04: Pulling from library/ubuntu
bf5d46315322: Pull complete
9f13e0ac480c: Pu1l complete
e8988b5b3097: Pull complete
40af181810e7: Pull complete
e6f7c7e5c03e: Pu1l complete
Digest: sha256: 147913621d9cdea08853f6ba9116c2e27a3ceffecf3b492983ae97c3d643fbbe
Status: Downloaded newer image for ubuntu:16.04

上面的命令中没有给出Docker镜像仓库地址,因此将会从Docker Hub获取镜像。而镜像名称是ubuntu:16. 04 , 因此将会获取官方镜像library/ubuntu 仓库中标签为16. 04的镜像。

从下载过程中可以看到我们之前提及的分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件。下载过程中给出了每一层的ID的前12位。并且下载结束后,给出该镜像完整的sha256的摘要,以确保下载一致性。

在使用上面命令的时候,你可能会发现,你所看到的层ID以及sha256的摘要和这里的不一样。这是因为官方镜像是一直在维护的 ,有任何新的bug,或者版本更新,都会进行修复再以原来的标签发布,这样可以确保任何使用这个标签的用户可以获得更安全、更稳定的镜像。

如果从Docker Hub下载镜像非常缓慢,可以参照镜像加速器一节配置加速器。

运行

有了镜像后,我们就能够以这个镜像为基础启动并运行一个容器。以上面的ubuntu:16.04为例,如果我们打算启动里面的bash并且进行交互式操作的话,可以执行下面的命令。

$ docker run -it --rm \
    ubuntu:16.04 \
    bash

root@e7009C6ce357:/# cat /etc/os-release
NAME="Ubuntu”
VERSION="16.04.4 LTS, Trusty Tahr"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 16.04.4 LTS"
VERSION_ID="16.04”
HOME_URL="http://www.ubuntu.com/”
SUPPORT_URL= "http://help.ubuntu.com/"
BUG_REPORT_URL="http://bugs.launchpad.net/ubuntu/"

docker run 就是运行容器的命令,我们这里简要的说明一下上面用到的参数。

  • -it : 这是两个参数,一个是-i : 交互式操作,一个是-t终端。我们这里打算进入bash 执行一些命令并查看返回结果 ,因此我们需要交互式终端。

  • --rm :这个参数是说容器退出后随之将其删除。默认情况下,为了排障需求,退出的容器并不会立即删除,除非手动docker rm。我们这里只是随便执行个命令,看看结果,不需要排障和保留结果,因此使用--rm 可以避免浪费空间。

  • ubuntu:16.04 : 这是指用ubuntu:16. 04镜像为基础来启动容器。

  • bash :放在镜像名后的是命令,这里我们希望有个交互式Shell,因此用的是bash

进入容器后,我们可以在Shell下操作,执行任何所需的命令。这里,我们执行了cat /etc/os-release, 这是Linux常用的查看当前系统版本的命令,从返回的结果可以看到容器内是Ubuntu 16.04.4 LTS系统。

最后我们通过exit 退出了这个容器。

列出镜像

要想列出已经下载下来的镜像,可以使用docker image ls 命令。

$ docker image 1s
REPOSITORY    TAG      IMAGE ID        CREATED        SIZE
redis        latest    5f515359c7f8    5 days ago     183 MB
nginx        latest    05a60462f8ba    5 days ago     181 MB
mongo        3.2       fe9198c04d62    5 days ago     342 MB
<none>      <none>     00285df0df87    5 days ago     342 MB
ubuntu       16.04     f753707788c5    4 weeks ago    127 MB      
ubuntu       latest    f753707788c5    4 weeks ago    127 MB
ubuntu       14.04     1e0c3dd64ccd    4 weeks ago    188 MB

列表包含了仓库名、标签、镜像ID、创建时间以及所占用的空间

其中仓库名、标签在之前的基础概念章节已经介绍过了。镜像ID则是镜像的唯一标识,一个镜像可以对应多个标签。因此,在上面的例子中,我们可以看到 ubuntu:16.04ubuntu:latest拥有相同的ID,因为它们对应的是同一个镜像。

镜像体积

如果仔细观察,会注意到,这里标识的所占用空间和在Docker Hub上看到的镜像大小不同。比如,ubuntu:16.04 镜像大小,在这里是127 MB, 但是在Docker Hub显示的却是50 MB。这是因为Docker Hub中显示的体积是压缩后的体积。在镜像下载和上传过程中镜像是保持着压缩状态的,因此Docker Hub所显示的大小是网络传输中更关心的流量大小。而docker image 1s显示的是镜像下载到本地后,展开的大小,准确说,是展开后的各层所占空间的总和,因为镜像到本地后,查看空间的时候,更关心的是本地磁盘空间占用的大小。

另外一个需要注意的问题是,docker image ls 列表中的镜像体积总和并非是所有镜像实际硬盘消耗。由于Docker镜像是多层存储结构,并且可以继承、复用,因此不同镜像可能会因为使用相同的基础镜像,从而拥有共同的层。由于Docker使用Union FS,相同的层只需要保存一份即可,因此实际镜像硬盘占用空间很可能要比这个列表镜像大小的总和要小的多。

你可以通过以下命令来便捷的查看镜像、容器、数据卷所占用的空间。

$ docker system df 

TYPE            TOTAL        ACTIVE        SIZE        RECLAIMABLE
Images          24           0             1.992GB     1.992GB(100%)
Containers      1            0             62.82MB     62.82M1B (10%)
Local Volumes   9            0             652.2MB     652.2NB (108) 
Build Cache                                0B          0B

虚悬镜像

上面镜像列表中,还可以看到一个特殊的镜像,这个镜像既没有仓库名,也没有标签,均为<none>

<none>    <none>    00285df0df87    5 days ago    342MB

这个镜像原本是有镜像名和标签的,原来为mongo:3.2 ,随着官方镜像维护,发布了新版本后,重新docker pull mongo:3.2 时,mongo:3.2 这个镜像名被转移到了新下载的镜像身上,而旧的镜像上的这个名称则被取消,从而成为了<none>。 除了docker pull可能导致这种情况,docker build也同样可以导致这种现象。由于新旧镜像同名,旧镜像名称被取消,从而出现仓库名、标签均为<none> 的镜像。这类无标签镜像也被称为虚悬镜像(dangling image),可以用下面的命令专门显示这类镜像:

$ docker image ls -f dangling=true
REPOSITORY        TAG        IMAGE ID        CREATED        SIZE
<none>            <none>     00285df0df87    5 days ago     342MB

一般来说,虚悬镜像已经失去了存在的价值,是可以随意删除的,可以用下面的命令删除。

$ docker image prune

中间层镜像

为了加速镜像构建、重复利用资源,Docker会利用中间层镜像。所以在使用一段时间后,可能会看到一一些依赖的中间层镜像。默认的docker image ls列表中只会显示顶层镜像,如果希望显示包括中间层镜像在内的所有镜像的话,需要加-a参数。

$ docker image ls -a

这样会看到很多无标签的镜像,与之前的虚悬镜像不同,这些无标签的镜像很多都是中间层镜像,是其它镜像所依赖的镜像。这些无标签镜像不应该删除,否则会导致上层镜像因为依赖丢失而出错。实际上,这些镜像也没必要删除,因为之前说过,相同的层只会存一遍,而这些镜像是别的镜像的依赖,因此并不会因为它们被列出来而多存了一份,无论如何你也会需要它们。只要删除那些依赖它们的镜像后,这些依赖的中间层镜像也会被连带删除。

列出部分镜像

不加任何参数的情况下,docker image ls 会列出所有顶级镜像,但是有时候我们只希望列出部分镜像。docker image ls有好几个参数可以帮助做到这个事情。

根据仓库名列出镜像

$ docker image ls ubuntu
REPOSITORY        TAG        IMAGE ID        CREATED        SIZE
ubuntu            16.04      f753707788c5    4 weeks ago    127 MB
ubuntu            latest     f753707788c5    4 weeks ago    127 MB
ubuntu            14.04      1e0c3dd64ccd    4 weeks ago    188 MB

列出特定的某个镜像,也就是说指定仓库名和标签

$ docker image ls ubuntu:16.04
REPOSITORY        TAG        IMAGE ID        CREATED        SIZE
ubuntu            16.04      f753707788c5    4 weeks ago    127 MB

除此以外,docker image ls 还支持强大的过滤器参数 --filter,或者简写 -f 。之前我们已经看到了使用过滤器来列出虚悬镜像的用法,它还有更多的用法。比如,我们希望看到在 mongo:3.2之后建立的镜像,可以用下面的命令:

$ docker image ls -f since=mongo:3.2
REPOSITORY        TAG        IMAGE ID        CREATED        SIZE
redis             latest     5f515359c7f8    5 days ago     183 MB
nginx             latest     05a60462f8ba    5 days ago     181 MB

想查看某个位置之前的镜像也可以,只需要把since换成before即可。

此外,如果镜像构建时,定义了LABEL,还可以通过LABEL来过滤。

$ docker image ls -f label=com.example.version=0.1
...

以特定格式显示

默认情况下,docker image ls 会输出一个完整的表格,但是我们并非所有时候都会需要这些内容。比如刚才删除虚悬镜像的时候,我们需要利用docker image ls 把所有的虚悬镜像的ID列出来,然后才可以交给docker image rm命令作为参数来删除指定的这些镜像,这个时候就用到了-q参数。

$ docker image 1s -q
5f515359c7f8
05a60462f8ba
fe9198c04d62
00285df0df87
f753707788c5
f 753707788c5
1e0c3dd64ccd

--filter配合-q产生出指定范围的ID列表,然后送给另一个docker 命令作为参数,从而针对这组实体成批的进行某种操作的做法在Docker命令行使用过程中非常常见,不仅仅是镜像,将来我们会在各个命令中看到这类搭配以完成很强大的功能。因此每次在文档看到过滤器后,可以多注意一下它们的用法。

另外一些时候,我们可能只是对表格的结构不满意, 希望自己组织列;或者不希望有标题,这样方便其它程序解析结果等,这就用到了Go的模板语法

比如,下面的命令会直接列出镜像结果,并且只包含镜像ID和仓库名:

$ docker image 1s --format "{{ .ID}}: {{ .Repository}}"
5f515359c7f8: redis
05a60462f8ba: nginx
fe9198c04d62: mongo
00285df0df87: < none>
f753707788c5; ubuntu
f753707788c5: ubuntu
1e0c3dd64ccd: ubuntu

或者打算以表格等距显示,并且有标题行,和默认一样,不过自己定义列:

$ docker image 1s --format "table {{. ID}}\t{{.Repository}}t{{Tag}}"
IMAGE ID        REPOSITORY   TAG
5f515359c7f8    redis        latest
05a60462f8ba    nginx        latest
fe9198c04d62    mongo        3.2
00285df0df87    <none>       <none>
f753707788c5    ubuntu       16.04
f753707788c5    ubuntu       latest
1e0c3dd64ccd    ubuntu       14.04

删除本地镜像

如果要删除本地镜像,可以使用docker image rm命令,其格式为:

$ docker image rm[选项]<镜像1>[<镜像2>...]

用ID、镜像名、摘要删除镜像

其中,<镜像>可以是镜像短ID镜像长ID镜像名或者镜像摘要

比如我们有这么一些镜像:

$ docker image ls
REPOSITORY        TAG        IMAGE ID        CREATED        SIZE
centos            latest     0584b3d2cf6d    3 weeks ago    196.5 MB
redis             alpine     501ad78535f0    3 weeks ago    21.03 MB
docker            latest     cf693ec9b5c7    3 weeks ago    105.1 MB
nginx             latest     e43d811ce2f4    5 weeks ago    181.5 MB

我们用镜像的完整ID,也称为长ID,来删除镜像。使用脚本的时候可能会用长ID,但是人工输入就太累了,所以更多的时候是用短ID来删除镜像。docker image ls默认列出的就已经是短ID了,一般取前3个字符以上,只要足够区分于别的镜像就可以了。

比如这里,如果我们要删除redis:alipine镜像,可以执行:

$ docker image rm 501
Untagged: redis:alpine
Untagged: redis@sha256:f1ed3708f538b537eb9c2a7dd50dc90a706f7debd7e1196c9264edeea521a86d
Deleted: sha256:501ad78535f015d88872e13fa87a828425117e3d28075d0c117932b05bf189b7
Deleted: sha256:96167737e29ca8e9d74982ef2a0dda76ed7b430da55e321c071f0dbff8c2899b
Deleted: sha256:32770d1dcf835f192cafd6b9263b7b597a1778a403a109e2cc2ee866f74adf23
Deleted: sha256:127227698ad74a5846ff5153475e03439d96d4b1c7f2a449c7a826ef74a2d2fa
Deleted: sha256:1333ecc582459bac54e1437335c0816bc17634e131ea0cc48daa27d32c75eab3
Deleted: sha256:4fc455b921edf9c4aea207c51ab39b10b06540c8b4825ba57b3feed1668fa7c7

我们也可以用镜像名,也就是<仓库名>:<标签>,来删除镜像。

$ docker image rm centos
Untagged: centos:latest
Untagged: centos@sha256:b2f9d1c0ff5f87a4743104d099a3d561002ac500db1b9bfa02a783a46e0d366c
Deleted: sha256:0584b3d2cf6d235ee310cf14b54667d889887b838d3f3d3033acd70fc3c48b8a
Deleted: sha256:97ca462ad9eeae25941546209454496e1d66749d53dfa2ee32bf1faabd239d38

当然,更精确的是使用镜像摘要删除镜像。

$ docker image ls --digests
REPOSITORY    TAG    IMAGE ID                                                                                 CREATED        SIZE
DIGEST        slim   sha256 :b4f0e0bdeb578043c1ea6862f0d40cc4afe32a4a582f3be235a3b164422be228 6e0c4c8e3913    3 weeks ago    214 MB

$ docker image rm node@sha256:b4f0e0bdeb578043c1ea6862f0d40cc4ate32a4a582f3be235a3b164422be228
Untagged: node@sha256: b4f0e0bdeb578043c1ea6862f0d40cc4afe32a4a582f3be235a3b164422be228

Untagged和Deleted

如果观察上面这几个命令的运行输出信息的话,你会注意到删除行为分为两类,一类是 Untagged, 另一类是Deleted。 我们之前介绍过,镜像的唯一标识是其ID和摘要,而一个镜像可以有多个标签。

因此当我们使用上面命令删除镜像的时候,实际上是在要求删除某个标签的镜像。所以首先需要做的是将满足我们要求的所有镜像标签都取消,这就是我们看到的Untagged的信息。因为一个镜像可以对应多个标签,因此当我们删除了所指定的标签后,可能还有别的标签指向了这个镜像,如果是这种情况,那么Delete行为就不会发生。所以并非所有的docker image rm都会产生删除镜像的行为,有可能仅仅是取消了某个标签而已。

当该镜像所有的标签都被取消了,该镜像很可能会失去了存在的意义,因此会触发删除行为。镜像是多层存储结构,因此在删除的时候也是从上层向基础层方向依次进行判断删除。镜像的多层结构让镜像复用变动非常容易,因此很有可能某个其它镜像正依赖于当前镜像的某一层。这种情况,依旧不会触发删除该层的行为。直到没有任何层依赖当前层时,才会真实的删除当前层。这就是为什么,有时候会奇怪,为什么明明没有别的标签指向这个镜像,但是它还是存在的原因,也是为什么有时候会发现所删除的层数和自己docker pull 看到的层数不一样的源。

除了镜像依赖以外,还需要注意的是容器对镜像的依赖。如果有用这个镜像启动的容器存在(即使容器没有运行), 那么同样不可以删除这个镜像。之前讲过,容器是以镜像为基础,再加一层容器存储层,组成这样的多层存储结构去运行的。因此该镜像如果被这个容器所依赖的,那么删除必然会导致故障。如果这些容器是不需要的,应该先将它们删除,然后再来删除镜像。

用docker image ls命令来配合

像其它可以承接多个实体的命令一样,可以使用docker image ls -q 来配合使用docker image rm,这样可以成批的删除希望删除的镜像。我们在镜像列表”章节介绍过很多过滤镜像列表的方式都可以拿过来使用。

比如,我们需要删除所有仓库名为redis 的镜像:

$ docker image rm $(docker image 1s -q redis)

或者删除所有在mongo:3.2 之前的镜像:

$ docker image rm $(docker image 1s -q -f before=mongo:3.2)

充分利用你的想象力和Linux命令行的强大,你可以完成很多非常赞的功能。

CentOS/RHEL的用户需要注意的事项

在Ubuntu/Debian上有UnionFS 可以使用,如aufs 或者overlay2 ,而CentOS和RHEL的内核中没有相关驱动。因此对于这类系统,一般使用devicemapper驱动利用LVM的一些机制来模拟分层存储。 这样的做法除了性能比较差外,稳定性一般也不好,而且配置相对复杂。Docker 安装在CentOS/RHEL上后,会默认选择devicemapper , 但是为了简化配置,其devicemapper 是跑在一个稀疏文件模拟的块设备上,也被称为loop-lvm。这样的选择是因为不需要额外配置就可以运行Docker,这是自动配置唯一能做到的事情。 但是loop-lvm的做法非常不好,其稳定性、性能更差,无论是日志还是docker info中都会看到警告信息。官方文档有明确的文章讲解了如何配置块设备给devicemapper 驱动做存储层的做法,这类做法也被称为配置direct-lvm

除了前面说到的问题外,devicemapper + loop-lvm 还有一个缺陷,因为它是稀疏文件,所以它会不断增长。用户在使用过程中会注意到/var/lib/docker/devicemapper/devicemapper/data不断增长,而且无法控制。很多人会希望删除镜像或者可以解决这个问题,结果发现效果并不明显。原因就是这个稀疏文件的空间释放后基本不进行垃圾回收的问题。因此往往会出现即使删除了文件内容,空间却无法回收,随着使用这个稀疏文件一直在不断增长。

所以对于CentOS/RHEL的用户来说,在没有办法使用UnionFS 的情况下,一定要配置 direct-lvmdevicemapper ,无论是为了性能、稳定性还是空间利用率。

或许有人注意到了CentOS 7中存在被backports回来的overlay驱动,不过CentOS里的这个驱动达不到生产环境使用的稳定程度,所以不推荐使用

使用Dockerfile定制镜像

从刚才的docker commit的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、 文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是Dockerfile。

Dockerfile是一个文本文件,其内包含了一条条的指令(Instruction), 每一条指令构建一层,因此每一条指令的内容 ,就是描述该层应当如何构建。

还以之前定制nginx镜像为例,这次我们使用Dockerfile来定制。

在一个空白目录中,建立一个文本文件,并命名为Dockerfile :

$ mkdir mynginx
$ cd mynginx
$ touch Dockerfile

其内容为:

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

这个Dockerfile很简单,一共就两行。涉及到了两条指令,FROMRUN

FROM指定基础镜像

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

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

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

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

FROM scratch
...

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

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

RUN执行命令

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

  • shell格式: RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的Dockerfile中的RUN指令就是这种格式。

RUN echo ' <h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
  • exec格式: RUN ["可执行文件","参数1","参数2"],这更像是函数调用中的格式。

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

FROM debian:jessie

RUN apt-get update
RUN apt-get install -y gcc libc6-dev make
RUN wget -0 redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.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正确的写法应该是这样:

FROM debian:jessie

RUN buildDeps='gcc libc6-dev make' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -0 redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.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/* \
8& 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制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。

构建镜像

好了,让我们再回到之前定制的nginx镜像的Dockerfile 来。现在我们明白了这个Dockerfile的内容,那么让我们来构建这个镜像吧。

Dockerfile文件所在目录执行:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/htm1/index.html
---> Running in 9cdc27646c7b
---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44a4490ce2c

从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在Step 2中,如同我们之前所说的那样,RUN 指令启动了一个容器9cdc27646c7b , 执行了所要求的命令,并最后提交了这一层44a4490ce2c, 随后删除了所用到的这个容器9cdc27646c7b

这里我们使用了docker build命令进行镜像构建。其格式为:

docker build [选项] <上下文路径/URL/->

在这里我们指定了最终镜像的名称-t nginx:v3 ,构建成功后,我们可以像之前运行nginx:v2那样来运行这个镜像,其结果会和nginx:v2 一样。

镜像构建上下文(Context)

如果注意,会看到docker build 命令最后有一个.. 表示当前目录,而Dockerfile 就在当前目录,因此不少初学者以为这个路径是在指定Dockerfile 所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定上下文路径。那么什么是上下文呢?

首先我们要理解docker build 的工作原理。Docker 在运行时分为Docker引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组REST API,被称为Docker Remote API,而如docker命令这样的客户端工具,则是通过这组API与Docker引擎交互,从而完成各种功能。因此,虽然表面上我们好

像是在本机执行各种docker 功能,但实际上,一切都是使用的远程调用形式在服务端( Docker引擎)完成。也因为这种C/S设计,让我们操作远程服务器的Docker引擎变得轻而易举。

当我们进行镜像构建的时候,并非所有定制都会通过RUN 指令完成,经常会需要将一些本地文件复制进镜像 ,比如通过COPY 指令、ADD 指令等。而docker build命令构建镜像,其实并非在本地构建,而是在服务端,也就是Docker引擎中构建的。那么在这种客户端服务端的架构中,如何才能让服务端获得本地文件呢?

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

如果在Dockerfile 中这么写:

COPY ./package.json /app/

这并不是要复制执行docker build 命令所在的目录下的package.json ,也不是复制Dockerfile 所在目录下的package.json , 而是复制上下文( context )目录下的package.json

因此,COPY 这类指令中的源文件的路径都是相对路径。这也是初学者经常会问的为什么COPY ../package. json /app或者COPY /opt/xxx /app 无法工作的原因,因为这些路径已经超出了上下文的范围, Docker弓|擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

现在就可以理解刚才的命令docker build -t nginx:v3 . 中的这个. , 实际上是在指定上下文的目录,docker build命令会将该目录下的内容打包交给Docker引擎以帮助构建镜像。

如果观察docker build 输出,我们其实已经看到了这个发送上下文的过程:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...

理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些初学者在发现COPY /opt/xxxx /app 不工作后,于是干脆将Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行后,在发送一个几十GB的东西极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误。

其它docker build的用法

直接用Git repo进行构建

或许你已经注意到了,docker build 还支持从URL构建,比如可以直接从Git repo中构建:

$ docker build https://gi thub.com/twang2218/gitlab-ce-zh.git#:8.14
docker build https: //github.com/twang2218/gitlab-ce-zh.git\#:8.14
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM gitlab/gitlab-ce:8.14.0-ce.0
8.14.0-ce.0: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists
...

这行命令指定了构建所需的Git repo,并且指定默认的master 分支,构建目录为/8.14/ ,然后Docker就会自己去git clone 这个项目、切换到指定分支、并进入到指定目录后开始构建。

用给定的tar压缩包构建
$ docker build http://server/context.tar.gz

如果所给出的URL不是个Git repo,而是个tar压缩包,那么Docker引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。

从标准输入中读取Dockerfile进行构建
docker build - < Dockerfile

cat Dockerfile | docker build

从标准输入中读取上下文压缩包进行构建

$ docker build - < context.tar.gz

如果发现标准输入的文件格式是gzip、bzip2以及xz的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。

Dockerfile指令详解

我们已经介绍了FROM、RUN,还提及了COPY、ADD,其实Dockerfile功能很强大,它提供了十多个指令。下面我们继续讲解其他的指令。

COPY复制文件

格式:

  • COPY <源路径>. . . <目标路径>

  • COPY ["<源路径>",. . . "<目标路径>"]

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

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

COPY package.json /usr/src/app/

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

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

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

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

ADD更高级的复制文件

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

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

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

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

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

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

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

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

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

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的参数的形式进行执行。比如:

CMD echo $HOME

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

CMD [ "sh", "-C", "echo $HOME” ]

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

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

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

一些初学者将CMD写为:

CMD service nginx start

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

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

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

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

CID ["nginx", “-g”, "daemon off;"]

ENTRYPOINT入口点

ENV设置环境变量

格式有两种:

  • ENV <key> <value>

  • ENV <key1>=<value1> <key2>-<value2>...

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

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

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

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

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" \
  8& gpg --batch --decrypt --output SHASUNS256.txt SHASUMS256.txt.asc \
  && grep ”node-V$NODE VERSION-1inux-x64.tar.xz\$" SHASUMS256.txtI sha256sum -C - \
  && tar -xJf "node-v$NODE_ VERSION-1inux -x64.tar.xz" -C /usr/local --strip-components=1 \
  && rm "node-v$NODE_ VERSION-1inux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
  && 1n -s /usr/local/bin/node /usr/1ocal/bin/nodejs

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

下列指令可以支持环境变量展开: ADD、 COPY、 ENV、 EXPOSE、 LABEL、 USER、 WORKDIR、 VOLUME 、STOPSIGNAL 、ONBUILD。

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

VOLUME定义匿名卷

EXPOSE暴露端口

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

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

此外,在早期Docker版本中还有一个特殊的用处。 以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个Docker引擎参数--icc=false ,当指定该参数后,容器间将默认无法互访,除非互相间使用了--links 参数的容器才可以互通,并且只有镜

像中EXPOSE 所声明的端口才可以被访问。这个--icc=false的用法,在引入了docker network 后已经基本不用了, 通过自定义网络可以很轻松的实现容器间的互联与隔离。

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

WORKDIR指定工作目录

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

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

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

RUN cd /app
RUN echo "hello” > world.txt

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

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

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

参考文档

操作Docker容器

启动容器

启动容器有两种方式,一种是基于镜像新建一个容器并启动,另外一个是将在终止状态( stopped ) 的容器重新启动。

因为Docker的容器实在太轻量级了,很多时候用户都是随时删除和新创建容器。

新建并启动

所需要的命令主要为docker run

例如,下面的命令输出一个"Hello World",之后终止容器。

$ docker run ubuntu:14.04 /bin/echo 'Hello world'
Hello world

这跟在本地直接执行/bin/echo 'hello world'几乎感觉不出任何区别。

下面的命令则启动一个bash终端,允许用户进行交互。

$ docker run -t -i ubuntu:14.04 /bin/bash 
root@af8bae53bdd3:/#

其中,-t选项让Docker分配个伪终端 ( pseudo-tty )并绑定到容器的标准输入上,-i 则让容器的标准输入保持打开。

在交互模式下,用户可以通过所创建的终端来输入命令,例如

root@af8bae53bdd3:/# pwd
/
root@af8bae53bdd3:/# ls
bin boot dev etc home lib 1lib64 media mnt opt proc root run sbin srv sys tmp usr var

当利用docker run 来创建容器时,Docker 在后台运行的标准操作包括:

  • 检查本地是否存在指定的镜像,不存在就从公有仓库下载

  • 利用镜像创建并启动一个容器

  • 分配一个文件系统,并在只读的镜像层外面挂载一层可读写层

  • 从宿主主机配置的网桥接口中桥接个虚拟接口到容器中去

  • 从地址池配置一 个ip地址给容器

  • 执行用户指定的应用程序

  • 执行完毕后容器被终止

启动已终止容器

可以利用docker container start 命令,直接将一个已经终止的容器启动运行。

容器的核心为所执行的应用程序,所需要的资源都是应用程序运行所必需的。除此之外,并没有其它的资源。可以在伪终端中利用pstop 来查看进程信息。

root@ba267838cc1b:/# ps 
PID    TTY    TIME        CMD
1      ?      00:00:00    bash
11     ?      00:00:00    ps

可见,容器中仅运行了指定的bash应用。这种特点使得Docker对资源的利用率极高,是货真价实的轻量级虚拟化。

守护态运行

更多的时候,需要让Docker在后台运行而不是直接把执行命令的结果输出在当前宿主机下。此时,可以通过添加-d参数来实现。

下面举两个例子来说明一下。

如果不使用-d参数运行容器。

$ docker run ubuntu:17.10 /bin/sh -C "while true; do echo hello world; sleep 1; done"
hello world
hello world
hello world
hello world

容器会把输出的结果(STDOUT)打印到宿主机上面

如果使用了-d参数运行容器。

$ docker run -d ubuntu:17.10 /bin/sh -C "while true; do echo hello world; sleep 1; done"
77b2dc01fe0f3f1265df143181e7b9af5e05279a884f4776ee75350ea9d8017a

此时容器会在后台运行并不会把输出的结果(STDOUT)打印到宿主机上面(输出结果可以用docker logs查看)。

注:容器是否会长久运行,是和docker run指定的命令有关,和-d参数无关。

使用-d参数启动后会返回一个唯一的 id,也可以通过docker container ls命令来查看容器信息。

终止容器

可以使用docker container stop 来终止一个运行中的容器。

此外,当Docker容器中指定的应用终结时,容器也自动终止。

例如对于上一章节中只启动了一个终端的容器,用户通过exit 命令或Ctrl+d 来退出终端时,所创建的容器立刻终止。

终止状态的容器可以用docker container ls -a命令看到。例如

docker container ls -a
CONTAINER ID    IMAGE                    COMMAND                CREATED        STATUS                            PORTS    NAMES
ba267838cc1b    ubuntu:14. 04            "/bin/bash"            30 minutes ag  Exited (0) About a minute ago    trusting_newton
98e5efa7d997    training/webapp:latest   "python app.py"        About an hour ago Exited (0) 34 minutes ago     backstabbing_pike

处于终止状态的容器,可以通过docker container start 命令来重新启动。

此外,docker container restart 命令会将一个运行态的容器终止 ,然后再重新启动它。

进入容器

在使用-d参数时,容器启动后会进入后台。

某些时候需要进入容器进行操作,包括使用docker attach 命令或docker exec 命令,推荐大家使用docker exec 命令,原因会在下面说明。

attach命令

docker attach 是Docker自带的命令。下面示例如何使用该命令。

$ docker run -dit ubuntu
243c32535da7d142fb0e6df616a3c3ada0b8ab417937c853a9e1c251f499f550

$ docker container ls
CONTAINER ID    IMAGE            COMMAND        CREATED           STATUS        PORTS    NAMES
243c32535da7    ubuntu: latest   "/bin/bash"    18 seconds ago    Up 17 seconds          nostalgic_hypatia

$ docker attach 243c
root@243c32535da7:/#

注意:如果从这个stdin中exit,会导致容器的停止。

exec命令

-i-t参数

docker exec后边可以跟多个参数,这里主要说明-i -t 参数。

只用-i参数时,由于没有分配伪终端,界面没有我们熟悉的Linux命令提示符,但命令执行结果仍然可以返回。

-i-t参数一起使用时,则可以看到我们熟悉的Linux命令提示符。

$ docker run -dit ubuntu
69d137adef7a8a689cbcb059e94da5489d3cddd240ff675c640c8d96e84fe1f6

$ docker container ls
CONTAINER ID    IMAGE            COMMAND        CREATED           STATUS        PORTS         NAMES
69d137adef7a    ubuntu: latest   "/bin/bash"    18 seconds ago    Up 17 seconds               zealous_swirles

$ docker exec -i 69d1 bash
ls
bin
boot
dev
...

$ docker exec -it 69d1 bash
root@69d137adef7a:/#

如果从这个stdin中exit,不会导致容器的停止。这就是为什么推荐大家使用docker exec的原因。

更多参数说明请使用docker exec --help 查看。

删除容器

可以使用docker container rm来删除一个处于终止状态的容器。例如

$ docker container rm trusting_newton
trusting_newton

如果要删除一个运行中的容器 ,可以添加-f参数。Docker 会发送SIGKILL 信号给容器。

清理所有处于终止状态的容器

docker container ls -a命令可以查看所有已经创建的包括终止状态的容器,如果数量太多要一个个删除可能会很麻烦,用下面的命令可以清理掉所有处于终止状态的容器。

$ docker container prune

访问Docker仓库

仓库( Repository )是集中存放镜像的地方。

一个容易混淆的概念是注册服务器( Registry ) 。实际上注册服务器是管理仓库的具体服务器,每个服务器上可以有多个仓库,而每个仓库下面有多个镜像。从这方面来说,仓库可以被认为是一个 具体的项目或目录。例如对于仓库地址dl . dockerpool. com/ubuntu来说,dl . dockerpool. com是注册服务器地址,ubuntu是仓库名。

大部分时候,并不需要严格区分这两者的概念。

Docker Hub

目前Docker官方维护了一个公共仓库Docker Hub,其中已经包括了数量超过15,000的镜像。大部分需求都可以通过在Docker Hub中直接下载镜像来实现。

注册

你可以在https: //cloud.docker.com免费注册个Docker账号。

登录

可以通过执行docker login 命令交互式的输入用户名及密码来完成在命令行界面登录Docker Hub。

你可以通过docker logout退出登录。

拉取镜像

你可以通过docker search 命令来查找官方仓库中的镜像,并利用docker pull 命令来将它下载到本地。

例如以centos 为关键词进行搜索:

$ docker search centos
NAME                        DESCRIPTION                            STARS    OFFICIAL     AUTOMATED
centos            The official build of Centos.                    46        [OK]
tianon/centos    Centos 5 and 6,created using rinse instea...     28                     
blalor/centos    Bare-bones base CentoS 6.5 image                  6                      [OK]
saltstack/ centos-6-minimal                                        6                      [OK]                                                                                                                                                                          
tutum/centos-6.4 DEPRECATED. Use tutum/centos :6.4 instead...      5                      [OK]

可以看到返回了很多包含关键字的镜像,其中包括镜像名字、描述、收藏数(表示该镜像的受关注程度)、是否官方创建、是否自动创建。

官方的镜像说明是官方项目组创建和维护的,automated资源允许用户验证镜像的来源和内容。

根据是否是官方提供,可将镜像资源分为两类。

一种是类似 centos 这样的镜像,被称为基础镜像或根镜像。 这些基础镜像由Docker公司创建、验证、支持、提供。这样的镜像往往使用单个单词作为名字。

还有一种类型,比如tianon/centos 镜像,它是由Docker的用户创建并维护的,往往带有用户名称前缀。可以通过前缀username/ 来指定使用某个用户提供的

镜像,比如tianon用户。

另外,在查找的时候通过--filter=stars=N 参数可以指定仅显示收藏数量为N以上的镜像。

下载官方centos 镜像到本地。

$ docker pull centos
Pulling repository centos
0b443ba03958: Download complete
539c0211cd76: Download complete
511136ea3c5a: Download complete
7064731afe90: Download complete

推送镜像

用户也可以在登录后通过docker push 命令来将自己的镜像推送到Docker Hub。

以下命令中的username请替换为你的Docker账号用户名。

$ docker tag ubuntu:17.10 username/ubuntu:17.10

$ docker image ls

REPOSITORY                TAG        IMAGE ID        CREATED        SIZE
ubuntu                    17.10      275d79972a86    6 days ago     94. 6MB
username/ubuntu           17.10      275d79972a86    6 days ago     94. 6MB

$ docker push username/ubuntu:17.10
$ docker search username

NAME    DESCRIPTION    STARS    OFFICIAL    AUTOMATED
username/ ubuntu

自动创建

自动创建( Automated Builds )功能对于需要经常升级镜像内程序来说,十分方便。

有时候,用户创建了镜像,安装了某个软件,如果软件发布新版本则需要手动更新镜像。

而自动创建允许用户通过Docker Hub指定跟踪一个目标网站 (目前支持GitHub或BitBucket)上的项目,一旦项目发生新的提交或者创建新的标签( tag ),Docker Hub会自动构建镜像并推送到Docker Hub中。

要配置自动创建,包括如下的步骤:

  • 创建并登录Docker Hub,以及目标网站;

  • 在目标网站中连接帐户到Docker Hub ;

  • 在Docker Hub中配置一个自动创建;

  • 选取一个目标网站中的项目(需要含Dockerfile )和分支;

  • 指定Dockerfile 的位置,并提交创建。

之后,可以在Docker Hub的自动创建页面中跟踪每次创建的状态。

Docker 私有仓库

有时候使用Docker Hub这样的公共仓库可能不方便,用户可以创建一个本地仓库供私人使用。

本节介绍如何使用本地仓库。

docker-registry.是官方提供的工具,可以用于构建私有的镜像仓库。本文内容基于docker registry. v2.x 版本。

安装运行docker-registry

容器运行

你可以通过获取官方registry 镜像来运行。

$ docker run -d -P 5000:5000 --restart=always --name registry registry

这将使用官方的registry 镜像来启动私有仓库。默认情况下,仓库会被创建在容器的/var/lib/registry 目录下。你可以通过-v参数来将镜像文件存放在本地的指定路径。例如下面的例子将上传的镜像放到本地的/opt/data/registry 目录。

$ docker run -d \
    -p 5000:5000 \
    -v /opt/data/registry:/var/1ib/registry \ 
    registry

在私有仓库上传、搜索、下载镜像

创建好私有仓库之后,就可以使用docker tag 来标记一个镜像,然后推送它到仓库。例如私有仓库地址为127.0.0.1:5000

先在本机查看已有的镜像。

$ docker image ls
REPOSITORY        TAG        IMAGE ID        CREATED        VIRTUAL SIZE
ubuntu            latest     ba5877dc9bec    6 weeks ago    192.7 MB

使用docker tagubuntu:latest 这个镜像标记为127 .0.0.1:5000/ubuntu:latest

格式为docker tag IMAGE[:TAG] [REGISTRY_ HOST[:REGISTRY_ PORT]/ ]REPOSITORY[:TAG]

Docker 数据卷

数据卷是一个可供一个或多个容器使用的特殊目录,它绕过UFS,可以提供很多有用的特性:

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

  • 数据卷的修改会立马生效

  • 数据卷的更新,不会影响镜像

  • 数据卷默认会一直存在,即使容器被删除

注意:数据卷的使用,类似于Linux下对目录或文件进行mount ,镜像中的被指定为挂载点的目录中的文件会隐藏掉,能显示看的是挂载的数据卷

选择-v还是--mount参数

Docker新用户应该选择--mount参数,经验丰富的Docker使用者对-v或者--volume已经很熟悉了,但是推荐使用--mount参数。

创建一个数据卷

$ docker volume create my-vol

查看所有的数据卷。

$ docker volume ls
local                my-vol

在主机里使用以下命令可以查看指定数据卷的信息

$ docker volume inspect my-vol
[
    {
        “Driver”:"local",
        "Labels":{},
        "MountDoint":"/var/lib/docker/volumes/my-vol/_date",
        "Name":"my-vol",
        "Options":{},
        "Scope":"local"
    }
]

Docker 构建 Tomcat

查找Docker Hub上的Tomcat镜像

这里我们拉取官方的镜像

docker pull tomcat

等待下载完成后,我们就可以在本地镜像列表里查到REPOSITORY为tomcat的镜像。

运行容器:

docker run --name tomcat -p 8080:8080 -V $PWD/test:/usr/local/tomcat/webapps/test -d tomcat

命令说明:

  • -p 8080:8080 :将容器的8080端口映射到主机的8080端口

  • -v$PWD/test:/usr/local/tomcat/webapps/test: 将主机中当前目录下的test挂载到容器的/test

查看容器启动情况

root@UbuntuBase:/usr/local/docker/tomcat/webapps# docker ps
CONTAINER ID        IMAGE        COMMAND          CREATED        STATUS        PORTS                     NAMES
38498e53128c        tomcat    "catalina.sh run"   2 mins ago     Up 2 mins    0.0.0.0:8080->8080/tcp     tomcat      

通过浏览器访问

Docker 构建MySQL

查看Docker Hub上的MySQL镜像

这里我们拉取官方镜像

docker pull mysql

等待下载完成后,我们就可以在本地镜像列表里查到REPOSITORY为mysql的镜像

运行容器:

docker run -p 3306:3306 --name mysql \
-V /usr/local/docker/mysq1/conf:/etc/mysql \
-v /usr/1oca1/docker/mysq1/logs:/var/1og/mysq1 \
-V /usr/1ocal/docker/mysq1/data:/var/lib/mysq1 \
-e MYSQL_ROOT_PASSWORD=123456 \
-d mysql

命令参数:

  • -p 3306:3306 : 将容器的3306端口映射到主机的3306端口

  • -v /usr/local/docker/mysql/conf:/etc/mysql:将主机当前目录下的conf挂载到容器的 /etc/mysql

  • -v /usr/local/docker/mysql/logs:/var/log/mysql:将主机当前目录下的logs目录挂载到容器的 /var/log/mysql

  • -v /usr/local/docker/mysql/data:/var/lib/mysql:将主机当前目录下的data目录挂载到容器的 /var/lib/mysql

  • -e MYSQL\_ROOT\_PASSWORD=123456:初始化root用户的密码

Docker 常用命令

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值