如何编写Dockerfile文件

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

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

构建镜像分为两种方式:单阶段构建和多阶段构建

  • 单阶段:Dockerfile中定义一个FROM
  • 多阶段:Dockerfile中定义多个FROM

单阶段构建

例如: 以定制 nginx 镜像为例,这次我们使用 Dockerfile 来定制,如下:

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

Dockerfile文件的内容如下:

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

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

1、FROM 指定基础镜像

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

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

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

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

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

2、RUN 执行命令

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

shell 格式: 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 -O 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 -O 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/* \
&& 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 文件所在目录执行命令如下:

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

上图中的ab55dfb13c1d即为构建后新的镜像ID

上图中使用了 docker build 命令进行镜像构建。其格式为:

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

上图中-t 后面为构建后新镜像的名字,后面的点(.)表示上下文目录(说是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/xxxx /app 无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

将需要构建到镜像中的文件全部放在和Dockerfile相同路径下即可

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

一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore ,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

默认情况下,如果 不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。

实际上 Dockerfile 的文件名并不要求必须为 Dockerfile ,而且并不要求 必须位于上下文目录中,比如可以用 -f ../Dockerfile.php 参数指定某个文件作为 Dockerfile 。 例如:docker build -t 新镜像 -f ../Dockerfile.php .

3、COPY 复制文件

和 RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。 语法格式如下:

COPY <源路径>... <目标路径>           //方法一
COPY ["<源路径1>",... "<目标路径>"]   //方法二

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

COPY package.json /usr/src/app/

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

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

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

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

4、COPY复制目录

COPY复制目录的时候,需要提前在容器中创建响应的目录才可以,例如:

FROM alpine
mkdir /usr/local/go/conf          //先创建配置文件目录
COPY  conf  /usr/local/go/conf    //将上下文的conf复制到容器中来

注意:上述COPY命令执行后,实际是将conf目录下的全部文件复制到容器中创建的conf目录下,而不是复制conf进去

5、ADD 更高级的复制文件

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

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

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

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

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

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

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

6、CMD 容器启动命令

CMD 指令的格式和 RUN 相似,也是两种格式:

shell 格式: CMD <命令>
exec 格式: CMD ["可执行文件", "参数1", "参数2"...]
参数列表格式: CMD ["参数1", "参数2"...] 。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。

之前介绍容器的时候曾经说过,Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。 CMD 指令就是用于指定默认的容器主进程的启动命令的。

在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此 一定要使用双引号 ” ,而不要使用单引号。

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

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

1、下面的Dockerfile通过CMD实现输出命令,如图:

  • LABEL: 通过键值对的形式设定标签,上图中定义作者为gongguan

打包镜像,执行命令如下:

cd dockerfile               #进入Dockerfile文件所在目录
docker build -t gongguan .  #打包镜像

临时运行一下镜像,查看结果,如图:

如果在启动容器的时候指定了命令即可替换内容的CMD命令,如图:

7、ENTRYPOINT 入口点

ENTRYPOINT 的格式和 RUN 指令格式一样,分为 exec 格式和 shell 格式。

ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。 ENTRYPOINT 在运行时 也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 –entrypoint 来指定。

当指定了 ENTRYPOINT 后, CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令,换句话说实际执行时,将变为:

<ENTRYPOINT> "<CMD>"

下面通过ENTRYPOINT实现跟上面CMD同样的功能,如图:

运行结果如下:

也可以在运行命令的时候进行覆盖,例如:

docker run --rm --entrypoint=echo  gongguan "hello,gongguan"

如果CMD和ENTRYPOINT同时出现,CMD内容将作为参数传递给ENTRYPOINT,如图:

8、ENV 设置环境变量

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

格式有两种:

ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...

环境变量也支持换行,如下:

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.ta
r.xz" \
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
&& grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=
1 \
&& rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
&& ln -s /usr/local/bin/node /usr/local/bin/nodejs

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

下列指令可以支持环境变量展开:

ADD 、 COPY 、 ENV 、 EXPOSE 、 LABEL 、 USER 、 WORKDIR 、 VOLUME 、 STOPSIGNAL 、 ONBU ILD 。

环境变量可以使用的地方很多,很强大。通过环境变量,我 们可以让一份 Dockerfile 制作更多的镜像,只需使用不同的环境变量即可。

9、VOLUME 定义匿名卷

格式为:

VOLUME ["<路径1>", "<路径2>"...]
VOLUME <路径>

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

例如: VOLUME /data

挂载到宿主机的路径为/data/docker/volumes/30713a21e14a37ab3fa29573f69e62b964fa83e760639ce0/_data,/data/docker为安装时配置的安装路径,可在daemon.json中自定义,30713a21e14a37ab3fa29573f….的值为docker自带的存储卷的名字,通过docker volume ls可查看,每台机器不同

上面的 /data 目录就会在运行时自动挂载为匿名卷,任何向 /data 中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设 置。比如: docker run -d -v mydata:/data xxxx

在Dockerfile中可以这样写:VOLUME[“/data”] 表示将容器内的data目录挂载出来,VOLUME[“/data”,”abcd”] 表示将容器的两个目录挂载出来,注意:中括号内用双引号,可以通过docker inspect 容器名 查看对应的宿主机的挂载路径(在Mounts模块下)

10、EXPOSE 声明端口

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

EXPOSE 指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。

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

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

11、WORKDIR 指定工作目录

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

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

例如:在Dockerfile中编写内容如下:

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

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

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

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

12、USER 设置容器运行用户

使用USER设置启动容器的用户,在生产环境中一般不建议使用root启动容器,所以可以根据公司业务场景自定义启动容器的用户

例子:打包jar项目到容器中,设置时区与编码,如图:

FROM centos:7.4.1708
COPY sign-core-system-0.1.1-SNAPSHOT.jar /opt/
COPY AdobeSongStd-Light.otf /usr/share/fonts/
COPY signfonts /usr/share/fonts/
COPY bootstrap.properties /opt/
COPY handle_seal /usr/local/handle_seal/
COPY opencv /usr/lib/opencv
ENV  TZ 'Asia/Shanghai'
ENV  LANG en_US.UTF-8
ENV  LANGUAGE en_US:en
ENV  LC_ALL en_US.UTF-8
COPY opencv.conf /etc/ld.so.conf.d/
RUN  mkdir -p /usr/local/document/temp/  /usr/local/document/seal/  /usr/local/services/auth/temp \
     &&  yum -y install libjpeg-turbo libpng libtiff libjpeg zlib jasper-libs gtk2 gstreamer java-1.8.0-openjdk* \
     &&  yum clean all \
     &&  cp -a /usr/share/zoneinfo/Asia/Shanghai /etc/localtime  \
     &&  echo 'Asia/Shanghai' > /etc/timezone \
     &&  ldconfig
COPY license.lic /usr/local/services/auth/
WORKDIR /opt
ENTRYPOINT ["java","-jar"]
CMD ["sign-core-system-0.1.1-SNAPSHOT.jar","-Dsun.jnu.encoding=UTF-8","-Dfile.encoding=UTF-8"]

多阶段构建

多阶段构建就是在Dockerfile中定义多个FROM,每个FROM下有多个不同的指令,一般可简单分为构建步骤和生成业务应用镜像步骤,前面的一个或者多个阶段用于构建,产生业务应用的包后,在后面的阶段将上述产生的包再次进行构建成镜像,此时,没有构建时产生的缓存文件,可以起到优化镜像体积的作用

1、定义一个go文件,如图:

2、定义Dockerfile文件,内容如图:

从上图看出,Dockerfile是以golang为基础镜像进行构建,执行命令进行构建

docker build -t gongguan .

查看构建前后的镜像大小,如图:

从上图可看出,构建前的基础镜像就为992M,构建后为994M,也就是说实际的go的二进制文件才2M

通过镜像运行容器,如图:

3、修改Dockerfile,进行多阶段构建,如图:

  • 第1行:定义基础镜像并使用别名为buildgo
  • 第9行:在上面的构建包基础上进行构建,并以alpine为基础镜像
  • 第11行:指定将来自buildgo中的/opt目录下的test复制到当前镜像的/opt目录下

构建后再次查看镜像大小,如图:

在生产环境中构建自己的业务镜像时,可以根据不同的语言制定不同的构建过程,拆分代码编译过程和生成业务应用镜像过程

注意:如果通过Dockerfile来构建nginx镜像那么在CMD位置应该使用nignx -g daemon off; 如下:

CMD ["nignx","-g","daemon off;"]   //注意后面有分号;

标签