让博客Docker化,轻松上手Docker
Docker是一个有趣的技术,在过去的两年已经从一个想法变成了全世界的机构都在采用来部署应用的技术。在今天的文章中我将会讨论如何通过将一个现有应用Docker化来上手Docker。那这里选取的现有应用就是我的博客。
什么是Docker
在我们开始学习Docker的基础知识之前让我们首先理解什么是Docker,并且为什么它那么流行。Docker是一个操作系统容器管理工具,通过将应用打包到操作系统容器里面,从而让你能轻松管理和部署应用。
容器 vs. 虚拟机
容器可能不如虚拟机一样为人所熟知,但是它们是另外的一种提供操作系统虚拟化的方法。然而,他们与标准的虚拟机有很大的差异。
标准的虚拟机通常包含一个完整的操作系统,OS软件包,最后包含一两个应用。它是通过一个向虚拟机提供了硬件虚拟化的Hypervisor来实现的,允许单个服务器运行很多独立的被当做虚拟游客(virtual guest)的操作系统。
而容器与虚拟机的类似之处在于它们允许单个服务器运行多个操作环境(operating environment),然而这些环境不却是完整的操作系统。容器通常只包含必要的OS软件包和应用。他们通常不包含一个完整的操作系统或者硬件虚拟化。这也意味着比之虚拟机,容器的额外开销(overhead)更小。
容器和虚拟机通常被视为不能共生的技术,然而这通常是一个误解。虚拟机面向物理服务器,提供可以能与其他虚拟机一起共享这些物理资源的,功能完善的操作环境。容器通常是用来通过对单一主机的一个进程进行隔离,来保证被隔离的进程无法与处于同一个系统的其他进程进行互动。实际上,比起完全的虚拟机,容器与BSD的Jail,
chroot的进程更加类似。
在容器的基础上Docker提供了什么
Docker自身并不是一个容器的运行时环境;实际上Docker实际上是对容器技术不可知的(container technology agnostic),并且为了支持Solaris Zones和BSD Jails花了不少功夫。Docker提供的是一种容器管理,打包和部署的方法。尽管这种类型的功能已经某一种程度地存在于虚拟机中,但在传统上,它们并不是为了绝大多数的容器方案而生的,而那些已经存在的,却又不如Docker一样容易使用且功能完善。
现在我们知道了Docker是什么,让我们开始通过安装Docker并且部署一个公共的预先构建好的容器来学习Docker是如何工作的。
从安装开始
因为Docker不会默认安装好,第一步就是安装Docker软件包;因为我们实例中使用的操作系统是Ubuntu 14.04,我们将会使用Apt包管理工具:
apt-getinstalldocker.ioReadingpackagelists...Done
Buildingdependencytree
Readingstateinformation...Done
Thefollowingextrapackageswillbeinstalled:
aufs-toolscgroup-litegitgit-manliberror-perl
Suggestedpackages:
btrfs-toolsdebootstraplxcrinsegit-daemon-rungit-daemon-sysvinitgit-doc
git-elgit-emailgit-guigitkgitwebgit-archgit-bzrgit-cvsgit-mediawiki
git-svn
ThefollowingNEWpackageswillbeinstalled:
aufs-toolscgroup-litedocker.iogitgit-manliberror-perl
0upgraded,6newlyinstalled,0toremoveand0notupgraded.
Needtoget7,553kBofarchives.
Afterthisoperation,46.6MBofadditionaldiskspacewillbeused.
Doyouwanttocontinue?[Y/n]y
要检查是否有容器运行我们可以执行docker命令,然后使用ps命令选项:
dockerpsCONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES
docker命令的ps功能类似于Linux的ps命令。它能显示可用的Dokcer容器和他们的当前状态。因为我们还没有启动任何的Docker容器,命令显示没有任何的正在运行的容器。
部署一个预先构建好的nginx Docker容器
Docker的一个我最喜欢的特性是其可有让你用类似yum或者apt-get部署一个软件包一样的方式来部署一个预先构建好容器的能力。为了更好的说明这一点,让我们来部署一个预先构建好的运行nginx服务器的容器。我们可以通过执行docker命令,但是这一次,我们使用的是run命令选项。
#dockerrun-dnginx
Unabletofindimage'nginx'locally
Pullingrepositorynginx
5c82215b03d1:Downloadcomplete
e2a4fb18da48:Downloadcomplete
58016a5acc80:Downloadcomplete
657abfa43d82:Downloadcomplete
dcb2fe003d16:Downloadcomplete
c79a417d7c6f:Downloadcomplete
abb90243122c:Downloadcomplete
d6137c9e2964:Downloadcomplete
85e566ddc7ef:Downloadcomplete
69f100eb42b5:Downloadcomplete
cd720b803060:Downloadcomplete
7cc81e9a118a:Downloadcomplete
docker命令的run功能告诉Docker来找到一个指定的Docker镜像,并且启动一个运行该镜像的容器。默认情况下,Docker容器会在前台运行,意味这当你执行docker run你的shell会绑定到这个容器的console和在容器里面运行的进程。为了在将这个Docker容器在后台启动,我包含了一个-d(detach,脱离)的标志。
现在再次运行
docker ps,我们可以看到正在运行的nginx容器:
dockerpsCONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES
f6d31ab01fc9nginx:latestnginx-g'daemonoff4secondsagoUp3seconds443/tcp,80/tcpdesperate_lalande
在上面的输出中,我们可以看到运行中的容器叫desperate_lalande,并且该容器是从镜像nginx:latest构建而来。
Docker镜像
镜像是Docker的核心特性之一,并且与虚拟机的镜像很类似。类似之处在于,一个Docker镜像是一个保存好并且打包好的容器。然而Docker,并不止步于镜像创建。Docker也包含了通过Docker仓库分发这些镜像的能力,这个概念与软件包仓库类似。正是这个能力让Docker可以如同用yum来部署一个软件包一样来部署容器。为了更好的理解这如何工作的,让我们再看看docker run的输出:
#dockerrun-dnginx
Unabletofindimage'nginx'locally
第一条消息我们可以看到docker不能在本地找到一个名为nginx的镜像。我们之所以看到这个消息是因为当我们执行docker run的时候我们告诉Docker启动一个容器,一个基于名为nginx镜像的容器。因为Docker正在启动一个基于特定镜像的容器,它需要首先找到这个镜像。在检查远端的仓库之前,Docker首先检查是否本地已经存在有一个该特定名称的镜像。
因为我们的系统是全新的,没有一个名为nginx的Docker镜像,这意味着Docker需要在Docker仓库里面下载:
Pullingrepositorynginx
5c82215b03d1:Downloadcomplete
e2a4fb18da48:Downloadcomplete
58016a5acc80:Downloadcomplete
657abfa43d82:Downloadcomplete
dcb2fe003d16:Downloadcomplete
c79a417d7c6f:Downloadcomplete
abb90243122c:Downloadcomplete
d6137c9e2964:Downloadcomplete
85e566ddc7ef:Downloadcomplete
69f100eb42b5:Downloadcomplete
cd720b803060:Downloadcomplete
7cc81e9a118a:Downloadcomplete
这正如输出的第二部分所显示的一样。默认情况下,Docker使用Docker Hub仓库,这是由Docker公司运行的仓库服务。
如Github一样,Docker Hub对于公有的仓库免费,但是对于私有仓库需要付费。然而,你也可以部署你自己的Docker仓库,实际上这只是运行一下
docker run registry这么简单。在这篇文章中,我们不会部署一个自己的注册表服务(registry service)。
停止和移除镜像
在我们开始构建一个自己的Docker容器之前,让我们首先清理我们的Docker环境。我们需要停止之前启动的容器并且移除它。
要启动一个Docker容器我们执行
docker命令并且使用run命令选项,要停止这个已启动的镜像我们只需要执行docker命令并使用kill选项并指定该容器的名称。
#dockerkilldesperate_lalande
desperate_lalande
如果我们再次执行docker ps我们看到容器没有运行了。
#dockerps
CONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES
然而在这一刻,我们仅仅停止了这个容器,尽管它没有运行了,但是它还是存在的。默认情况下docker ps只会显示运行中的容器,如果我们添加了-a(all,所有)标志,它就会显示所有不论运行与否的容器。
#dockerps-a
CONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES
f6d31ab01fc95c82215b03d1nginx-g'daemonoff4weeksagoExited(-1)Aboutaminuteagodesperate_lalande
要完全的移除该容器,我们可以使用docker命令并且使用rm命令选项。
#dockerrmdesperate_lalande
desperate_lalande
尽管这个容器已经被移除了;我们仍然可以随时使用nginx镜像。我们想要重新运行docker run -d nginx,容器将会立即=启动而不需要再次拉取名叫nginx的镜像。这是因为Docker已经在本地保存了一个备份。
要查看所有本地的镜像我们可以是运行docker命令并且使用
image选项。
#dockerimages
REPOSITORYTAGIMAGEIDCREATEDVIRTUALSIZE
nginxlatest9fab4090484a5daysago132.8MB
构建我们自己的自定义镜像
到目前为止我们已经使用了一些基础的Docker命令来启动,停止和移除一个普通的预选构建好的镜像。而为了Docker化这个博客,我们将要构建我们自己的Docker镜像,这意味要创建一个Dockerfile。
在绝大多数的虚拟机环境中,假如你想要创建一个机器的镜像,你需要首先创建一个虚拟机,然后安装好操作系统,然后安装好应用程序,最后将其转化成一个模板或者镜像。然而,对于Docker来说,这些步骤都可以通过Dockerfile进行自动化。一个Dockerfile是一个可以向Docker提供构建指令的方式。在这一节中,我们将要创建一个可以用来部署本博客的自定义Dockerfile。
理解应用
在我们开始创建Dockerfile之前,我们需要首先理解要部署这个博客我们必需什么。
这个博客自身实际上是通过一个自己编写的名为
hamerkop(注:锤头鹳的意思)的静态网站生成器生成的一个静态的HTML网页。这个生成器非常的简单,是我专门为这个博客编写的,刚好够用。这个博客的所有的代码和源文件都能在公有的Github仓库中访问到。为了部署这个博客我们只需要从Github拿到该仓库的内容,然后安装Python和一些Python的模块,并且执行hamerkop应用。要对外服务这些生成的内容我们需要使用nginx;这意味着我们也需要安装好nginx。
到目前为止,我们的Dockerfile应该足够简单,但是就这些也足够让我们学到不少
Dockerfile语法。首先让我们克隆Github仓库代码,然后用最喜爱的编辑器来创建一个Dockerfile;我这里使用vi。
#gitclonehttps://github.com/madflojo/blog.git
Cloninginto'blog'...
remote:Countingobjects:622,done.
remote:Total622(delta0),reused0(delta0),pack-reused622
Receivingobjects:100%(622/622),14.80MiB|1.06MiB/s,done.
Resolvingdeltas:100%(242/242),done.
Checkingconnectivity...done.
>cdblog/
>viDockerfile
用FROM继承一个Docker镜像
Dockerfile的第一条指令是FROM指令。这用来将一个已经存在的Docker镜像指定为基础镜像。这基本上为我们提供了继承另一个Docker镜像的方法。在我们这个场景中,我们将会从我们之前用到的nginx镜像开始,如果我们想要从一个最原始的空白状态(* blank slate*)开始,我们可以通过指定ubuntu:latest使用Ubuntu镜像。
FROMnginx:latest
MAINTAINERBenjaminCane
除了FROM指令之外,我还包含了一个MAINTAINER指令,其是用来显示Dockerfile的作者的。
因为Docker支持使用
#来作为评论的标示,我将会使用这种语法来解释Dockerfile的各个部分。
运行一个测试构建
因为我们继承了nginx Docker镜像,我们当前的Dockerfil也继承了所有用来构建该nginx镜像的所有指令。这意味着即使在这一刻,我们已经能够从这个Dockerfile中构建出一个Docker镜像并且用这个镜像运行出一个容器。生成的镜像基本上跟nginx镜像一样,让我们现在就开始来对这个Dockerfile进行构建,之后还有几次构建过,通过这些实践来帮助解释Docker的构建过程。
为了开始从一个Dockerfile中开始一个构建,我们可以简单地执行
docker命令并且使用build命令选项。
#dockerbuild-tblog/root/blog
SendingbuildcontexttoDockerdaemon23.6MB
SendingbuildcontexttoDockerdaemon
Step0:FROMnginx:latest
--->9fab4090484a
Step1:MAINTAINERBenjaminCane
--->Runninginc97f36450343
--->60a44f78d194
Removingintermediatecontainerc97f36450343
Successfullybuilt60a44f78d194
在上面的例子中,我们使用-t(tag,标签)标志来将这个镜像贴上名为"blog"的标签。这基本上让我们可以对镜像进行命名。假如不为镜像指定一个标签,这个镜像就只能通过一个由Dokcer指定的镜像ID(Image ID)来调用。在这个场景下,这镜像ID是60a44f78d194,正如我们在docker命令的成功构建消息中看到的一样。
除了
-t标志,我也指定了/root/blog目录。这个目录就是“构建目录(build directory)”,这个目录包含了Dockerfile和其他必要的构建这个容器的文件。
现在我们已经完成了一个成功的构建,让我们开始对这个镜像进行定制化。
使用RUN来执行apt-get
这个用来生成HTML页面的静态网站生成器是使用Python来编写的,因此在Dockerfile中第一个自定义的任务是安装Python。要安装Python包,我们需要用到Apt包管理器,这意味着我们需要在Dockerfile中说明需要执行apt-get update和apt-get install python-dev;我们可以通过RUN指令来完成这一点。
#Dockerfilethatgeneratesaninstanceofhttp://bencane.com
FROMnginx:latest
MAINTAINERBenjaminCaneInstallpythonandpipRUNapt-getupdate
RUNapt-getinstall-ypython-devpython-pip
在上面我们仅仅是运行RUN指令来告诉Docker当其构建这个镜像的时候,它需要执行指定的apt-get命令。然而有意思的部分是,这些命令只会在这个容器的情景(context)中才会执行。这意味着python-dev和python-pip只被安装到了容器中,并没有安装到主机中。或者用更简单的话来说,在容器中,pip是可以执行的,但是出了容器之外,pip命令是找不到的。
同样重要的一点是,Docker的构建过程中是不接受用户的输入的。这意味这所有由RUN指令来执行的命令必须不藉由用户输入而完成。这给构建过程增加了一点复杂度,因为很多应用是需要用户输入的。在我们这个场景中,
RUN所有执行的命令都不需要用户输入。
安装Python模块
现在Python安装好了我们需要安装一些Python模块。要在Docker之外做这件事情,我们通常是使用pip命令并且引用在博客的仓库中的一个名叫requirements.txt文件。在之前的一个步骤里,我们使用了git命令将博客的Github仓库克隆到/root/blog目录下;这个目录也同时是我们创建Dockerfile的地方。这很重要,因为这意味着Git仓库的内容能被Docker在构建过程中访问到。
当执行构建的时候,Docker会将构建的情景(context)设置为一个指定的构建目录。这意味该文件夹任何的文件以及子目录中的文件都能被构建过程所使用,而处于该目录之外(处于构建情景之外的),是不能被访问到的。
要安装必需的Python模块,我们需要将
requirements.txt文件从构建目录拷贝到容器之中去。我们可以在Dockerfile中使用COPY指令。
#Dockerfilethatgeneratesaninstanceofhttp://bencane.com
FROMnginx:latestMAINTAINERBenjaminCaneInstallpythonandpipRUNapt-getupdate
RUNapt-getinstall-ypython-devpython-pipCreateadirectoryforrequiredfilesRUNmkdir-p/build/AddrequirementsfileandrunpipCOPYrequirements.txt/build/
RUNpipinstall-r/build/requirements.txt
在Dockerfile中我们添加了3个指令。第一个指令使用RUN在容器中创建了一个/build目录。这个目录将用来拷贝用来生成静态HTML页面所需的任何文件。第二个指令是`COPY指令,用来将requirements.txt从构建目录拷贝到容器中的/build目录。第三个使用了RUN指令,用来执行pip命令;这会安装所有在requirements.txt文件中指定的模块。
COPY是一个在构建定制化的镜像时需要理解的很重要的指令。没有在Dockerfile文件中指定复制文件,Docke镜像就不会包含这个requirements.txt文件。在Docker容器一切都是被隔离的情况下,除非在Dockerfile中特别指定过,容器是不大可能包含所需的依赖的。
重新运行一个构建
现在我们有了一些可以让Docker执行的定制化的任务了,让我们来试着再一次对这个blog镜像进行构建。
#dockerbuild-tblog/root/blog
SendingbuildcontexttoDockerdaemon19.52MB
SendingbuildcontexttoDockerdaemon
Step0:FROMnginx:latest
--->9fab4090484a
Step1:MAINTAINERBenjaminCane
--->Usingcache
--->8e0f1899d1eb
Step2:RUNapt-getupdate
--->Usingcache
--->78b36ef1a1a2
Step3:RUNapt-getinstall-ypython-devpython-pip
--->Usingcache
--->ef4f9382658a
Step4:RUNmkdir-p/build/
--->Runninginbde05cf1e8fe
--->f4b66e09fa61
Removingintermediatecontainerbde05cf1e8fe
Step5:COPYrequirements.txt/build/
--->cef11c3fb97c
Removingintermediatecontainer9aa8ff43f4b0
Step6:RUNpipinstall-r/build/requirements.txt
--->Runninginc50b15ddd8b1
Downloading/unpackingjinja2(from-r/build/requirements.txt(line1))
Downloading/unpackingPyYaml(from-r/build/requirements.txt(line2))
Successfullyinstalledjinja2PyYamlmistunemarkdownMarkupSafe
Cleaningup...
--->abab55c20962
Removingintermediatecontainerc50b15ddd8b1
Successfullybuiltabab55c20962
从上面的构建输出我们可以看到构建成功了,但是我们也可以看到另外一个有意思的消息;---> Using cache(使用缓存)。这个消息告诉我们的是,Docker能够在构建过程中使用他的构建缓存。
Docker构建缓存
当Docker构建一个镜像的时候,它不会仅仅构建一个单一的镜像;它实际上在整个构建过程中会构建出多个镜像。实际上我们可以从以上的输出看到,在每一步之后,Docker都创建了一个新的镜像。
Step5:COPYrequirements.txt/build/
--->cef11c3fb97c
上面片段中的最后一行,实际上是Docker在告诉我们创建了一个新的镜像,它通过输出镜像ID来告诉我们这一点;cef11c3fb97c。这个策略的一个有用之处在于Docker能够使用这些镜像作为后续构建步骤的缓存。这很有用,因为它能让Docker加快相同容器的新构建的构建过程。如果仔细我们看上面的例子,我们可以发现Docker能够使用一个已经缓存了的镜像,而不是重新安装python-dev和python-pip包。然而,因为Docker无法找到一个执行过mkdir命令的构建,这之后每一个后续的步骤都执行了。
Docker的构建缓存是一个馈赠也是一个诅咒;这么说的原因是否要使用缓存或者重新执行指令这个决定是在一个很狭窄的范围做出的。比如,如果如果有对
requirements.txt文件的更改,Docker会在构建过程中检测到这个改动然后从那一点重新开始。然而执行apt-get命令却情况不同。如果提供Python包的Apt仓库包含了一个python-pip包更新的版本;Docker无法检测到这个变化,然后简单地使用缓存。这意味着可能我们安装了软件包的一个较老的版本。尽管这个对于python-pip软件包来说这不是什么问题,如果安装包缓存了一个包含已知漏洞的软件包,那么就是一个问题。
介于这个原因,周期性的重新构建镜像并且不使用Docker的缓存是有用的。你可以在执行一个Docker构建的时候指定
--no-cache=True来禁用缓存。
部署blog的其余部分
当Python软件包和模块都安装好后,现在我们应该拷贝必需的应用文件,然后运行hamerkop应用了。要完成这一步我们可以简单地使用更多的COPY和RUN指令。
FROMnginx:latest
MAINTAINERBenjaminCane安装python和pipRUNapt-getupdate
RUNapt-getinstall-ypython-devpython-pip创建一个文件夹放置必需文件RUNmkdir-p/build/添加依赖文件然后运行pipCOPYrequirements.txt/build/
RUNpipinstall-r/build/requirements.txt添加博客代码和必需文件COPYstatic/build/static
COPYtemplates/build/templates
COPYhamerkop/build/
COPYconfig.yml/build/
COPYarticles/build/articles运行生成器RUN/build/hamerkop-c/build/config.yml
现在我们补上了其余的构建指令,让我们来再来一次构建并且验证是否镜像能够构建成功。
#dockerbuild-tblog/root/blog/
SendingbuildcontexttoDockerdaemon19.52MB
SendingbuildcontexttoDockerdaemon
Step0:FROMnginx:latest
--->9fab4090484a
Step1:MAINTAINERBenjaminCane
--->Usingcache
--->8e0f1899d1eb
Step2:RUNapt-getupdate
--->Usingcache
--->78b36ef1a1a2
Step3:RUNapt-getinstall-ypython-devpython-pip
--->Usingcache
--->ef4f9382658a
Step4:RUNmkdir-p/build/
--->Usingcache
--->f4b66e09fa61
Step5:COPYrequirements.txt/build/
--->Usingcache
--->cef11c3fb97c
Step6:RUNpipinstall-r/build/requirements.txt
--->Usingcache
--->abab55c20962
Step7:COPYstatic/build/static
--->15cb91531038
Removingintermediatecontainerd478b42b7906
Step8:COPYtemplates/build/templates
--->ecded5d1a52e
Removingintermediatecontainerac2390607e9f
Step9:COPYhamerkop/build/
--->59efd1ca1771
Removingintermediatecontainerb5fbf7e817b7
Step10:COPYconfig.yml/build/
--->bfa3db6c05b7
Removingintermediatecontainer1aebef300933
Step11:COPYarticles/build/articles
--->6b61cc9dde27
Removingintermediatecontainerbe78d0eb1213
Step12:RUN/build/hamerkop-c/build/config.yml
--->Runninginfbc0b5e574c5
Successfullycreatedfile/usr/share/nginx/html//2011/06/25/checking-the-number-of-lwp-threads-in-linux
Successfullycreatedfile/usr/share/nginx/html//2011/06/checking-the-number-of-lwp-threads-in-linux
Successfullycreatedfile/usr/share/nginx/html//archive.html
Successfullycreatedfile/usr/share/nginx/html//sitemap.xml
--->3b25263113e1
Removingintermediatecontainerfbc0b5e574c5
Successfullybuilt3b25263113e1
运行一个定制化的容器在成功构建后,我们现在可以通过运行docker命令并且使用run选项来启我们的定制化的容器,就如我们之前运行nginx容易类似。
#dockerrun-d-p80:80--name=blogblog
5f6c7a2217dcdc0da8af05225c4d1294e3e6bb28a41ea898a1c63fb821989ba1
与先前一样,-d(detach,脱离)标志是用来告诉Docker在后台运行容器。然后我们这里也使用两个新的标志。第一个标志是--name,这是用来给容器一个用户指定的名称。在之前的例子里,我们没有指定名称,因此Docker随机生成了一个名称。第二个新出现的标志是-p,这个标志能让用户来将一个端口从主机机器映射到容器中的一个端口。
我们使用的nginx基础镜像暴露了80端口来提供HTTP服务。默认情况下,与Docker容器内部绑定的端口并没有与主机系统绑定。为了让外部的系统访问容器内部暴露的端口,这些端口必须通过使用
-p标志从主机端口映射到容器端口。假如我们想要端口从主机的8080端口,映射到容器中的80端口,我们可以通过使用这种语法-p 8080:80。
从上面的命令中,看起来我们的容器已经启动成功了。我们可以通过运行执行
docker ps来验证。
#dockerps
CONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES
d264c7ef92bdblog:latestnginx-g'daemonoff3secondsagoUp3seconds443/tcp,0.0.0.0:80->80/tcpblog
总结
到这里,我们已经有了一个运行中的自定制的Docker容器。尽管这个文章中我们触及到了不少的Dockerfile指令,我们还未能讨论所有的指令。要获取一个完整的指令列表你可以查看Docker的手册页面,那里很好的解释了每个指令。
另外一个很好的资源是
Docker最佳实践页面,包含了不少的构建自定制Dockerfile最佳实践。有些指点十分有用,比如策略性地在Dockerfile中安排指令的顺序。在上面的例子中,我们的Dockerfile用到的COPY指令,被放在了最后。这么做的原因是,articles目录会频繁更改。最好将可能经常变动的指令放到最后,这样可以优化Docker的缓存步骤。
在这个文章中我覆盖了如何从一个预先构建好的容器开始,如何构建然后部署一个自定制的容器。尽管Docker要学习的内容还有很多,希望这篇文章会助你迈出第一步。
网友评论