关注【异步图书】微信公众号,每日赠送新书
本文摘自:人民邮电出版社异步社区《Docker实战》
本文主要内容
Docker是什么
Docker的使用以及它如何能节省时间和金钱
容器与镜像之间的区别
Docker的分层特性
使用Docker构建并运行一个to-do应用程序
Docker是一个允许用户“在任何地方构建、分发及运行任何应用”的平台。它在极短的时间内发展壮大,目前已经被视为解决软件中最昂贵的方面之一——部署的一个标准方法。
在Docker出现之前,开发流水线通常由用于管理软件活动的不同技术组合而成,如虚拟机、配置管理工具、不同的包管理系统以及各类依赖库复杂的网站。所有这些工具需要由专业的工程师管理和维护,并且多数工具都具有自己独特的配置方式。
Docker改变了这一切,允许不同的工程师参与到这个过程中,有效地使用同一门语言,这让协作变得轻而易举。所有东西通过一个共同的流水线转变成可以在任何目标平台上使用的单一的产出——无须继续维护一堆让人眼花缭乱的工具配置,如图1-1所示。
与此同时,只要现存的软件技术栈依然有效,用户就无须抛弃它——用户可以将其原样打包到一个Docker容器内供其他人使用。由此获得的额外好处是,用户能看到这些容器是如何构建的,因此如果需要深入其细节,完全没问题。
在本文中,读者将了解到Docker是什么、为什么它很重要,并开始使用它。
图1-1 Docker如何消除了工具维护的负担
在动手实践之前,我们将对Docker稍做讨论,以便读者了解它的背景、“Docker”名字的来历以及为什么使用它!
要理解Docker是什么,从一个比喻开始会比技术性解释来得简单,而且这个Docker的比喻非常有说服力。Docker原本是指在船只停靠港口之后将商品移进或移出的工人。箱子和物品的大小和形状各异,而有经验的码头工人能以合算的方式手工将商品装入船只,因而他们倍受青睐(见图1-2)。雇人搬东西并不便宜,但除此之外别无选择。
对在软件行业工作的人来说,这听起来应该很熟悉。大量时间和精力被花在将奇形怪状的软件放置到装满了其他奇形怪状软件、大小各异的船只上,以便将其卖给其他地方的用户或商业机构。
图1-2 标准化集装箱前后的航运对比
在Docker出现之前,部署软件到不同环境所需的工作量巨大。即使不是采用手工运行脚本的方式在不同机器上进行软件配备(还是有很多人这么做),用户也不得不全力应付那些配置管理工具,它们掌管着渴求资源且快速变化的环境的状态。即便将这些工作封装到虚拟机中,还是需要花费大量时间来部署这些虚拟机、等待它们启动并管理它们所产生的额外的资源开销。
使用Docker,配置工作从资源管理中分离了出来,而部署工作则是微不足道的:运行docker run
,环境的镜像会被拉取下来并准备运行,所消耗的资源更少并且是内含的,因此不会干扰其他环境。
读者无须担心容器是将被分发到Red Hat机器、Ubuntu机器还是CentOS虚拟机镜像中,只要上面有Docker,就没有问题。
图1-3展示了使用Docker概念时如何能节省时间和金钱。
图1-3 使用Docker前后软件交付的对比
几个重要的实际问题出现了:为什么要使用Docker,Docker用在什么地方?针对“为什么”的简要答案是:只需要一点点付出,Docker就能快速为企业节省大量金钱。部分方法(肯定不是所有的)将在随后的几节中讨论。我们已经在实际工作环境中切身体会到所有这些益处。
Docker可以在很多情况下替代虚拟机。如果用户只关心应用程序,而不是操作系统,可以用Docker替代虚拟机,并将操作系统交给其他人去考虑。Docker不仅启动速度比虚拟机快,迁移时也更为轻量,同时得益于它的分层文件系统,与其他人分享变更时更简单、更快捷。而且,它牢牢地扎根在命令行中,非常适合脚本化。
如果想快速体验软件,同时避免干扰目前的设置或配备一个虚拟机的麻烦,Docker可以在几毫秒内提供一个沙箱环境。在亲身体验之前,很难感受到这种解放的效果。
因为对Linux用户而言,Docker镜像实际上没有依赖,所以非常适合用于打包软件。用户可以构建镜像,并确保它可以运行在任何现代Linux机器上——就像Java一样,但不需要JVM。
Docker 有助于将一个复杂系统分解成一系列可组合的部分,这让用户可以用更离散的方式来思考其服务。用户可以在不影响全局的前提下重组软件使其各部分更易于管理和可插拔。
由于可以在一台机器上启动数百个(甚至数千个)隔离的容器,因此对网络进行建模轻而易举。这对于现实世界场景的测试非常有用,而且所费无几。
因为可以将系统的所有部分捆绑在Docker容器中,所以用户可以将其编排运行在笔记本电脑中移动办公,即便在离线时也没问题。
不同团队之间关于软件交付的复杂谈判在业内司空见惯。我们亲身经历过不计其数的这类讨论:失效的库、有问题的依赖、更新被错误实施或是执行顺序有误,甚至可能根本没执行以及无法重现的错误等。估计读者也遇到过。Docker让用户可以清晰地说明(即便是脚本的形式)在一个属性已知的系统上调试问题的步骤,错误和环境重现变得更简单,而且通常与所提供的宿主机环境是分离的。
通过使用结构化方式构建镜像,为迁移到不同环境做好准备,Docker 强制用户从一个基本出发点开始明确地记录软件依赖。即使用户不打算在所有地方都使用Docker,这种对文档记录的需要也有助于在其他地方安装软件。
持续交付(continuous delivery,CD)是一个基于流水线的软件交付范型,该流水线通过一个自动化(或半自动化)流程在每次变动时重新构建系统然后交付到生产环境中。
因为用户可以更准确地控制构建环境的状态,Docker 构建比传统软件构建方法更具有可重现性和可复制性。使持续交付的实现变得更容易。通过实现一个以Docker为中心的可重现的构建过程,标准的持续交付技术,如蓝/绿部署(blue/green deployment,在生产环境中维护“生产”和“最新”部署)和凤凰部署(phoenix deployment,每次发布时都重新构建整个系统)变得很简单。
现在,读者对Docker如何能够提供帮助有了一定了解。在进入一个真实示例之前,让我们来了解一下几个核心概念。
在本节中,我们将介绍一些关键的Docker概念,如图1-4所示。
图1-4 Docker的核心概念
在开始运行Docker命令之前,将镜像、容器及层的概念牢记于心是极其有用的。简而言之,容器
运行着由镜像
定义的系统。这些镜像由一个或多个层
(或差异集)加上一些Docker的元数据组成。
让我们来看一些核心的Docker命令。我们将把镜像转变成容器,修改它们,并添加层到我们即将提交的新镜像中。如果这一切听上去有点儿混乱,不用太担心。在本文结束时,一切都将更加清晰。
Docker的中心功能是构建、分发及在任何具有Docker的地方运行软件。
对终端用户而言,Docker是一个用于运行的命令行程序。就像git(或任何源代码控制工具)一样,这个程序具有用于执行不同操作的子命令。
表1-1中列出了将在宿主机上使用的主要的Docker子命令。
表1-1 Docker子命令
命 令 | 目 的 |
|
构建一个Docker镜像 |
|
以容器形式运行一个Docker镜像 |
|
将一个Docker容器作为一个镜像提交 |
|
给一个Docker镜像打标签 |
如果读者不熟悉Docker,这可能是第一次听说本文所说的“容器”和“镜像”这两个词。它们很可能是Docker中最重要的概念,因此有必要花点儿时间明确其差异。
在图1-5中,读者将看到这些概念的展示,里面有从一个基础镜像启动的3个容器。
图1-5 Docker镜像与容器
看待镜像和容器的一种方式是将它们类比成程序与进程。一个进程可以视为一个被执行的应用程序,同样,一个Docker容器可以视为一个运行中的Docker镜像。
如果读者熟悉面向对象原理,看待镜像和容器的另一种方法是将镜像看作类而将容器看作对象。对象是类的具体实例,同样,容器是镜像的实例。用户可以从单个镜像创建多个容器,就像对象一样,它们之间全都是相互隔离的。不论用户在对象内修改了什么,都不会影响类的定义——它们从根本上就是不同的东西。
现在,我们要动手使用Docker来构建一个简单的“to-do”应用程序(todoapp)镜像了。在这个过程中,读者会看到一些关键的Docker功能,如Dockerfile、镜像复用、端口暴露及构建自动化。这是接下来10分钟读者将学到的东西:
如何使用Dockerfile来创建Docker镜像;
如何为Docker镜像打标签以便引用;
如何运行新建的Docker镜像。
to-do应用是协助用户跟踪待完成事项的一个应用程序。我们所构建的应用将存储并显示可被标记为已完成的信息的简短字符串,它以一个简单的网页界面呈现。
图1-6展示了如此操作将得到的结果。
图1-6 构建一个Docker应用程序
应用程序的细节不是重点。我们将演示从我们所提供的一个简短的Dockerfile开始,读者可以放心地在自己的宿主机上使用与我们相同的方法构建、运行、停止和启动一个应用程序,而无须考虑应用程序的安装或依赖。这正是 Docker 赋予我们的关键部分——可靠地重现并简便地管理和分享开发环境。这意味着用户无须再遵循并迷失在那些复杂的或含糊的安装说明中。
to-do应用程序 这个to-do应用程序将贯穿本文,多次使用,它非常适合用于实践和演示,因此值得读者熟悉一下。
创建Docker镜像有4种标准的方式。表1-2逐一列出了这些方法。
表1-2 创建Docker镜像的方式
方 法 | 描 述 | 详见技巧 |
Docker命令/“手工” |
使用 |
详见技巧14 |
Dockerfile |
从一个已知基础镜像开始构建,并指定一组有限的简单命令来构建 |
稍后讨论 |
Dockerfile及配置管理(configuration management,CM)工具 |
与Dockerfile相同,不过将构建的控制权交给了更为复杂的CM工具 |
详见技巧47 |
从头创建镜像并导入一组文件 |
从一个空白镜像开始,导入一个含有所需文件的TAR文件 |
详见技巧10 |
如果用户所做的是概念验证以确认安装过程是否正常,那么第一种“手工”方式是没问题的。在这个过程中,用户应对所采取的步骤做记录,以便在需要时回到同一点上。
到某个时间点,用户会想要定义创建镜像的步骤。这就是第二种方式(也就是我们这里所用的方式)。
对于更复杂的构建,用户需要使用第三种方式,特别是在Dockerfile功能还不足以满足镜像要求的时候。
最后一种方式从一个空镜像开始,通过叠加一组运行镜像所需要的文件进行构建。如果用户想导入一组在其他地方创建好的自包含的文件,这将非常有用,不过这在主流应用中非常罕见。
现在,我们来看一下Dockerfile方法,其他方法将在本文后面再说明。
Dockerfile是一个包含一系列命令的文本文件。本示例中我们将使用的Dockerfile如下:
FROM node ⇽--- ❶ 定义基础镜像
MAINTAINER [email protected] ⇽--- ❷ 声明维护人员
RUN git clone -q https://github.com/docker-in-practice/todo.git ⇽--- ❸ 克隆todoapp代码
WORKDIR todo ⇽--- ❹ 移动到新的克隆目录
RUN npm install > /dev/null ⇽--- ❺ 运行node包管理器的安装命令(npm)
EXPOSE 8000 ⇽--- ❻ 指定从所构建的镜像启动的容器需要监听这个端口
CMD ["npm","start"] ⇽--- ❼ 指定在启动时需要运行的命令
Dockerfile的开始部分是使用FROM
命令定义基础镜像❶。本示例使用了一个Node.js镜像以便访问Node.js程序。官方的Node.js镜像名为node
。
接下来,使用MAINTAINER
命令声明维护人员❷。在这里,我们我们使用的是其中一个人的电子邮件地址,读者也可以替换成自己的,因为现在它是你的Dockerfile了。这一行不是创建可工作的Docker镜像所必需的,不过将其包含进来是一个很好的做法。到这个时候,构建已经继承了node容器的状态,读者可以在它上面做操作了。
接下来,使用RUN
命令克隆todoapp代码❸。这里使用指定的命令获取应用程序的代码:在容器内运行git
。在这个示例中,Git是安装在基础node镜像里的,不过读者不能对这类事情做假定。
现在移动到使用WORKDIR
命令新克隆的目录中❹。这不仅会改变构建环境中的目录,最后一条WORKDIR
命令还决定了从所构建镜像启动容器时用户所处的默认目录。
接下来,运行node包管理器的安装命令(npm
)❺。这将为应用程序设置依赖。我们对输出的信息不感兴趣,所以将其重定向到/dev/null上。
由于应用程序使用了8000端口,使用EXPOSE
命令告诉Docker从所构建镜像启动的容器应该监听这个端口❻。
最后,使用CMD
命令告诉Docker在容器启动时将运行哪条命令❼。
这个简单的示例演示了Docker及Dockerfile的几个核心功能。Dockerfile是一组严格按顺序运行的有限的命令集的简单序列。它们影响了最终镜像的文件和元数据。这里的RUN
命令通过签出并安装应用程序影响了文件系统,而EXPOSE
、CMD
和WORKDIR
命令影响了镜像的元数据。
读者已经定义了自己的Dockerfile的构建步骤。现在可以键入图1-7所示的命令从而构建Docker镜像了。
图1-7 Docker build
命令
读者将看到类似下面这样的输出:
Sending build context to Docker daemon 178.7 kB ⇽--- Docker会上传docker build指定目录下的文件和目录
Sending build context to Docker daemon
Step 0 : FROM node ⇽--- 每个构建步骤从 0 开始按顺序编号,并与命令一起输出
---> fc81e574af43 ⇽--- 每个命令会导致一个新镜像被创建出来,其镜像ID在此输出
Step 1 : MAINTAINER [email protected]
---> Running in 21af1aad6950
---> 8f32669fe435
Removing intermediate container 21af1aad6950 ⇽--- 为节省空间,在继续前每个中间容器会被移除
Step 2 : RUN git clone https://github.com/ianmiell/todo.git
---> Running in 0a030ee746ea
Cloning into 'todo'...
---> 783c68b2e3fc
Removing intermediate container 0a030ee746ea
Step 3 : WORKDIR todo
---> Running in 2e59f5df7152
---> 8686b344b124
Removing intermediate container 2e59f5df7152
Step 4 : RUN npm install
---> Running in bdf07a308fca
npm info it worked if it ends with ok ⇽--- 构建的调试信息在此输出(限于篇幅,本代码清单做了删减)
[...]
npm info ok
---> 6cf8f3633306
Removing intermediate container bdf07a308fca
Step 5 : RUN chmod -R 777 /todo
---> Running in c03f27789768
---> 2c0ededd3a5e
Removing intermediate container c03f27789768
Step 6 : EXPOSE 8000
---> Running in 46685ea97b8f
---> f1c29feca036
Removing intermediate container 46685ea97b8f
Step 7 : CMD npm start
---> Running in 7b4c1a9ed6af
---> 66c76cea05bb
Removing intermediate container 7b4c1a9ed6af
Successfully built 66c76cea05bb ⇽--- 此次构建的最终镜像ID,可用于打标签
现在,拥有了一个具有镜像ID(前面示例中的“66c76cea05bb”,不过读者的ID会不一样)的Docker镜像。总是引用这个ID会很麻烦,可以为其打标签以方便引用。
输入图1-8所示的命令,将66c76cea05bb替换成读者生成的镜像ID。
图1-8 Docker tag
命令
现在就能从一个Dockerfile构建自己的Docker镜像副本,并重现别人定义的环境了!
读者已经构建出Docker镜像并为其打上了标签。现在可以以容器的形式来运行它了:
docker run -p 8000:8000 --name example1 todoapp ⇽---❶ docker run子命令启动容器,-p将容器的 8000 端口映射到宿主机的8000端口上,--name给容器赋予一个唯一的名字,最后一个参数是镜像
npm install
npm info it worked if it ends with ok
npm info using npm@2.14.4
npm info using node@v4.1.1
npm info prestart todomvc-swarm@0.0.1
> todomvc-swarm@0.0.1 prestart /todo ⇽--- 容器的启动进程的输出被发送到终端中
> make all
npm install
npm info it worked if it ends with ok
npm info using npm@2.14.4
npm info using node@v4.1.1
npm WARN package.json todomvc-swarm@0.0.1 No repository field.
npm WARN package.json todomvc-swarm@0.0.1 license should be a
➥ valid SPDX license expression
npm info preinstall todomvc-swarm@0.0.1
npm info package.json statics@0.1.0 license should be a valid
➥ SPDX license expression
npm info package.json react-tools@0.11.2 No license field.
npm info package.json react@0.11.2 No license field.
npm info package.json node-jsx@0.11.0 license should be a valid
➥ SPDX license expression
npm info package.json ws@0.4.32 No license field.
npm info build /todo
npm info linkStuff todomvc-swarm@0.0.1
npm info install todomvc-swarm@0.0.1
npm info postinstall todomvc-swarm@0.0.1
npm info prepublish todomvc-swarm@0.0.1
npm info ok
if [ ! -e dist/ ]; then mkdir dist; fi
cp node_modules/react/dist/react.min.js dist/react.min.js
LocalTodoApp.js:9: // TODO: default english version
LocalTodoApp.js:84: fwdList =
➥ this.host.get('/TodoList#'+listId); // TODO fn+id sig
TodoApp.js:117: // TODO scroll into view
TodoApp.js:176: if (i>=list.length()) { i=list.length()-1; }
➥ // TODO .length
local.html:30:
model/TodoList.js:29:
➥ // TODO one op - repeated spec? long spec?
view/Footer.jsx:61: // TODO: show the entry's metadata
view/Footer.jsx:80: todoList.addObject(new TodoItem());
➥ // TODO create default
view/Header.jsx:25:
➥ // TODO list some meaningful header (apart from the id)
npm info start [email protected]
> [email protected] start /todo
> node TodoAppServer.js
Swarm server started port 8000
^C ⇽--- ❷ 在此按Ctrl+C终止进程和容器
$ docker ps –a ⇽--- ❸ 运行这个命令查看已经启动和移除的容器,以及其ID和状态(就像进程一样)
CONTAINER ID IMAGE COMMAND CREATED
➥ STATUS PORTS NAMES
b9db5ada0461 todoapp:latest "npm start" 2 minutes ago
➥ Exited (130) 2 minutes ago example1
$ docker start example1 ⇽--- ❹ 重新启动容器,这次是在后台运行
example1
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED
➥ STATUS PORTS NAMES
b9db5ada0461 todoapp:latest "npm start" 8 minutes ago
➥ Up 10 seconds 0.0.0.0:8000->8000/tcp example1 ⇽--- ❺ 再次运行ps命令查看发生变化的状态
$ docker diff example1 ⇽--- ❻ docker diff子命令显示了自镜像被实例化成一个容器以来哪些文件受到了影响
C /todo ⇽--- ❼ 修改了/todo目录
A /todo/.swarm
A /todo/.swarm/TodoItem
A /todo/.swarm/TodoItem/1tlOc02+A~4Uzcz
A /todo/.swarm/_log ⇽--- ❽ 增加了/todo/.swarm目录
A /todo/dist
A /todo/dist/LocalTodoApp.app.js
A /todo/dist/TodoApp.app.js
A /todo/dist/react.min.js
docker run
子命令启动容器❶。-p
标志将容器的8000端口映射到宿主机的8000端口上,读者现在应该可以使用浏览器访问http://localhost:8000来查看这个应用程序了。--name
标志赋予了容器一个唯一的名称,以便后面引用。最后的参数是镜像名称。
一旦容器启动,我们按下CTRL-C终止进程和容器❷。读者可以运行ps
命令查看被启动且未被移除的容器❸。注意,每个容器都具有自己的容器 ID 和状态,与进程类似。它的状态是Exited
(已退出),不过读者可以重新启动它❹。这么做之后,注意状态已经改变为Up
(运行中),且容器到宿主机的端口映射现在也显示出来了❺。
docker diff
子命令显示了自镜像被实例化成一个容器以来哪些文件受到了影响❻。在这个示例中,修改了todo目录❼,并增加了其他列出的文件❽。没有文件被删除,这是另一种可能性。
如读者所见,Docker“包含”环境的事实意味着用户可以将其视为一个实体,在其上执行的动作是可预见的。这赋予了Docker宽广的能力——用户可以影响从开发到生产再到维护的整个软件生命周期。这种改变正是本文所要描述的,在实践中展示Docker所能完成的东西。
接下来读者将了解Docker的另一个核心概念——分层。
Docker分层协助用户管理在大规模使用容器时会遇到的一个大问题。想象一下,如果启动了数百甚至数千个to-do应用,并且每个应用都需要将文件的一份副本存储在某个地方。
可想而知,磁盘空间会迅速消耗光!默认情况下,Docker在内部使用写时复制(copy-on-write)机制来减少所需的硬盘空间量(见图 1-9)。每当一个运行中的容器需要写入一个文件时,它会通过将该项目复制到磁盘的一个新区域来记录这一修改。在执行Docker提交时,这块磁盘新区域将被冻结并记录为具有自身标识符的一个层。
图1-9 启动时复制与写时复制对比
这部分解释了Docker容器为何能如此迅速地启动——它们不需要复制任何东西,因为所有的数据已经存储为镜像。
写时复制 写时复制是计算技术中使用的一种标准的优化策略。在从模板创建一个新的(任意类型)对象时,只在数据发生变化时才能将其复制进来,而不是复制整个所需的数据集。依据用例的不同,这能省下相当可观的资源。
图1-10展示了构建的to-do应用,它具有我们所感兴趣的3层。
因为层是静态的,所以用户只需要在想引用的镜像之上进行构建,变更的内容都在上一层中。在这个to-do应用中,我们从公开可用的node镜像构建,并将变更叠加在最上层。
图1-10 Docker中to-do应用的文件系统分层
所有这3层都可以被多个运行中的容器共享,就像一个共享库可以在内存中被多个运行中的进程共享一样。对于运维人员来说,这是一项至关重要的功能,可以在宿主机上运行大量基于不同镜像的容器,而不至于耗尽磁盘空间。
想象一下,你将所运行的to-do应用作为在线服务提供给付费用户,可以将服务扩散给大量用户。如果你正在开发中,可以一下在本地机器上启动多个不同的环境。如果你正在进行测试,可以比之前同时运行更多测试,速度也更快。有了分层,所有这些东西都成为了可能。
通过使用Docker构建和运行一个应用程序,读者开始见识到Docker能给工作流带来的威力。重现并分享特定的环境,并能在不同的地方落地,让用户在开发过程中兼具灵活性和可控性。
依据读者以往的Docker经验不同,本文可能存在一个陡峭的学习曲线。我们在短时间内讨论了大量的基础内容。
读者现在应该:
理解Docker镜像是什么;
知道Docker分层是什么,以及为什么它很有用;
能够从一个基础镜像提交一个新的Docker镜像;
知道Dockerfile是什么。
我们已经使用这项知识:
创建了一个有用的应用程序;
毫不费力地重现了一个应用程序中的状态。
接下来,我们将介绍几个有助于读者理解Docker如何工作的技巧,然后讨论有关Docker用法的一些更广泛的技术争论。前两个介绍性的章节构成了本文其余部分的基础,后者涵盖了从开发到生产的全过程,展示如何使用Docker来提升工作流。
本文摘自:《Docker实践》
101个实用技巧,重点解决Docke应用中可能出现的问题
一本Docker技术的进阶读物
这是一本涵盖了101 个技巧的实操指南,读者可以用它来获得Docker 的大部分知识。本书遵循手册风格的“问题/ 解决方案/ 讨论”模式,针对最重要的问题,如轻松的服务器管理和配置、部署微服务、为实验而创建安全的环境等,为读者提供了及时有用的解决方案。在阅读本书的过程中,读者不但能学到Docker 的基础知识,还能学到Docker 的最佳实践,如将Docker 和持续集成过程结合使用、使用Chef 来自动化复杂容器的创建过程以及使用Kubernetes 进行容器编排等。
本书针对的是具有一定Docker知识的中级开发人员。如果读者对本书的基础部分较熟悉,可随意跳到后续章节。本书的目标是揭示Docker所带来的现实世界的挑战,并展示其解决之道。不过,首先我们将提供一个Docker自身的快速回顾。如果读者想了解更全面的Docker基础,请查阅Jeff Nickoloff编写的《Docker in Action》一书(Manning Publications,2016)。
延伸推荐
2018年1月重磅新书
小学生开始学Python,最接近AI的编程语言:安利一波Python书单
政策升温:大家都在学大数据,一大波好书推荐
一本基于Python语言的Selenium自动化测试书
8本新书,送出一本你喜欢的
AI经典书单| 入门人工智能该读哪些书?
点击关键词阅读更多新书:
Python|机器学习|Kotlin|Java|移动开发|机器人|有奖活动|Web前端|书单
在“异步图书”后台回复“关注”,即可免费获得2000门在线视频课程;推荐朋友关注根据提示获取赠书链接,免费得异步图书一本。赶紧来参加哦!
扫一扫上方二维码,回复“关注”参与活动!
今日话题:你是从事什么职业的?
转发本文到朋友圈,异步图书君将选出1名读者赠送文中新书一本。1.8日抽奖选出。
昨日获奖读者:哈皮阿呆
请获奖读者,在表单中填写获奖信息https://www.wenjuan.in/s/m2iaqif/
点击阅读原文,购买《Docker实践》