第四篇:Compose多应用部署

Compose 项目是 Docker 官方的开源项目,可以用来对 Docker 容器集群进行快速编排。其代码目前在 https://github.com/docker/compose上开源。Compose 定位是 「定义和运行多个 Docker 容器的应用(Defining and running multi-container Docker applications)」,其前身是开源项目 Fig。当我们的项目需要多个模块、组件或者应用一起来完成时,就比较适合使用 Compose

Compose 中有两个重要的概念:

  • 服务 (service):一个应用的容器,实际上可以包括若干运行相同镜像的容器实例。

  • 项目 (project):由一组关联的应用容器组成的一个完整业务单元,在 docker-compose.yml 文件中定义。

Compose 的默认管理对象是项目,通过子命令对项目中的一组容器进行便捷地生命周期管理。

Compose 项目由 Python 编写,实现上调用了 Docker 服务提供的 API 来对容器进行管理。因此,只要所操作的平台支持 Docker API,就可以在其上利用 Compose 来进行编排管理。一般我们只需要一个模版文件(YAML格式,默认名称为 docker-compose.yml)来定义一组相关联的应用容器为一个项目(project)。本节会以一个基本的 WEATHER 服务(包含 Eureka、Mail模块、weather-app模块)来联系 Compose 的用法。安装部分在第一节已经介绍,这里忽略,直接从使用开始。

Compose 常用命令说明

命令对象与格式

对于 Compose 来说,大部分命令的对象既可以是项目本身,也可以指定为项目中的服务或者容器。如果没有特别的说明,命令对象将是项目,这意味着项目中所有的服务都会受到命令影响。

执行 docker-compose [COMMAND] --help 或者 docker-compose help [COMMAND] 可以查看具体某个命令的使用格式。

docker-compose 命令的基本的使用格式是

docker-compose [-f=...] [options] [COMMAND] [ARGS...]

命令选项

  • -f, --file FILE 指定使用的 Compose 模板文件,默认为 docker-compose.yml,可以多次指定。

  • -p, --project-name NAME 指定项目名称,默认将使用所在目录名称作为项目名。

  • --x-networking 使用 Docker 的可拔插网络后端特性

  • --x-network-driver DRIVER 指定网络后端的驱动,默认为 bridge

  • --verbose 输出更多调试信息。

  • -v, --version 打印版本并退出。

命令使用说明

指令 指令格式 指令用途
build docker-compose build [options] [SERVICE…] 构建(重新构建)项目中的服务容器。服务容器一旦构建后,将会带上一个标记名,例如对于 web 项目中的一个 db 容器,可能是 web_db。可以随时在项目目录下运行 docker-compose build 来重新构建服务。选项包括:* --force-rm 删除构建过程中的临时容器。* --no-cache 构建镜像过程中不使用 cache(这将加长构建过程)。* --pull 始终尝试通过 pull 来获取更新版本的镜像。
config 验证 Compose 文件格式是否正确,若正确则显示配置,若格式错误显示错误原因。
down 此命令将会停止 up 命令所启动的容器,并移除网络
exec    
images    
kill docker-compose kill [options] [SERVICE...] 通过发送 SIGKILL 信号来强制停止服务容器。支持通过 -s 参数来指定发送的信号,例如通过如下指令发送 SIGINT 信号。
logs docker-compose logs [options] [SERVICE…] 查看服务容器的输出。默认情况下,docker-compose 将对不同的服务输出使用不同的颜色来区分。可以通过 --no-color 来关闭颜色。可以加 -f 来跟踪日志。该命令主要方便容器调试和查询问题使用。
pause docker-compose pause [SERVICE…] 暂停一个服务容器
port docker-compose port [options] SERVICE PRIVATE_PORT 打印某个容器端口所映射的公共端口。选项:* --protocol=proto 指定端口协议,tcp(默认值)或者 udp。* --index=index 如果同一服务存在多个容器,指定命令对象容器的序号(默认为 1)。
ps docker-compose ps [options] [SERVICE…] 列出项目中目前的所有容器。选项:* -q 只打印容器的 ID 信息。
pull docker-compose pull [options] [SERVICE…] 拉取服务依赖的镜像。选项:*--ignore-pull-failures 忽略拉取镜像过程中的错误。
push    
restart docker-compose restart [options] [SERVICE…] 重启项目中的服务。选项:* -t, --timeout TIMEOUT 指定重启前停止容器的超时(默认为 10 秒)。
rm docker-compose rm [options] [SERVICE…] 删除所有(停止状态的)服务容器。推荐先执行 docker-compose stop 命令来停止容器。选项:* -f, --force 强制直接删除,包括非停止状态的容器。一般尽量不要使用该选项。* -v 删除容器所挂载的数据卷。
start    
stop docker-compose stop [options] [SERVICE…] 停止已经处于运行状态的容器,但不删除它。通过 docker-compose start 可以再次启动这些容器。选项:* -t, --timeout TIMEOUT 停止容器时候的超时(默认为 10 秒)。

run

格式为 docker-compose run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]

在指定服务上执行一个命令。

例如:

docker-compose run centos ping docker.com

将会启动一个 centos 服务容器,并执行 ping docker.com 命令。

默认情况下,如果存在关联,则所有关联的服务将会自动被启动,除非这些服务已经在运行中。

该命令类似启动容器后运行指定的命令,相关卷、链接等等都将会按照配置自动创建。

两个不同点:

  • 给定命令将会覆盖原有的自动运行命令;

  • 不会自动创建端口,以避免冲突。

如果不希望自动启动关联的容器,可以使用 --no-deps 选项,例如

docker-compose run --no-deps web python manage.py shell

将不会启动 web 容器所关联的其它容器。

选项:

  • -d 后台运行容器。

  • --name NAME 为容器指定一个名字。

  • --entrypoint CMD 覆盖默认的容器启动指令。

  • -e KEY=VAL 设置环境变量值,可多次使用选项来设置多个环境变量。

  • -u, --user="" 指定运行容器的用户名或者 uid。

  • --no-deps 不自动启动关联的服务容器。

  • --rm 运行命令后自动删除容器,d 模式下将忽略。

  • -p, --publish=[] 映射容器端口到本地主机。

  • --service-ports 配置服务端口并映射到本地主机。

  • -T 不分配伪 tty,意味着依赖 tty 的指令将无法运行。

scale

格式为 docker-compose scale [options] [SERVICE=NUM...]

设置指定服务运行的容器个数。

通过 service=num 的参数来设置数量。例如:

docker-compose scale web=3 db=2

将启动 3 个容器运行 web 服务,2 个容器运行 db 服务。

一般的,当指定数目多于该服务当前实际运行容器,将新创建并启动容器;反之,将停止容器。

选项:

  • -t, --timeout TIMEOUT 停止容器时候的超时(默认为 10 秒)。

up

格式为 docker-compose up [options] [SERVICE...]

该命令十分强大,它将尝试自动完成包括构建镜像,(重新)创建服务,启动服务,并关联服务相关容器的一系列操作。

链接的服务都将会被自动启动,除非已经处于运行状态。

可以说,大部分时候都可以直接通过该命令来启动一个项目。

默认情况,docker-compose up 启动的容器都在前台,控制台将会同时打印所有容器的输出信息,可以很方便进行调试。

当通过 Ctrl-C 停止命令时,所有容器将会停止。

如果使用 docker-compose up -d,将会在后台启动并运行所有的容器。一般推荐生产环境下使用该选项。

默认情况,如果服务容器已经存在,docker-compose up 将会尝试停止容器,然后重新创建(保持使用 volumes-from 挂载的卷),以保证新启动的服务匹配 docker-compose.yml 文件的最新内容。如果用户不希望容器被停止并重新创建,可以使用 docker-compose up --no-recreate。这样将只会启动处于停止状态的容器,而忽略已经运行的服务。如果用户只想重新部署某个服务,可以使用 docker-compose up --no-deps -d <SERVICE_NAME> 来重新创建服务并后台停止旧服务,启动新服务,并不会影响到其所依赖的服务。

选项:

  • -d 在后台运行服务容器。

  • --no-color 不使用颜色来区分不同的服务的控制台输出。

  • --no-deps 不启动服务所链接的容器。

  • --force-recreate 强制重新创建容器,不能与 --no-recreate 同时使用。

  • --no-recreate 如果容器已经存在了,则不重新创建,不能与 --force-recreate 同时使用。

  • --no-build 不自动构建缺失的服务镜像。

  • -t, --timeout TIMEOUT 停止容器时候的超时(默认为 10 秒)。

Compose 配置文件语法

version

Compose 配置文件版本号,不同版本支持的指令不太一样。和 Docker 引擎的版本对照如下:

|Compose file format|Docker Engine| |-|:-| |3.3 – 3.4|17.06.0+| |3.0 – 3.2|1.13.0+| |2.3|17.06.0+| |2.2|1.13.0+| |2.1|1.12.0+| |2.0|1.10.0+| |1.0|1.9.1+|

build

指定 Dockerfile 所在文件夹的路径。 Compose 将会利用它自动构建这个镜像,然后使用这个镜像。 build: /path/to/build/dir

command

覆盖容器启动后默认执行的命令。 command: bundle exec thin -p 3000

links

链接到其它服务中的容器。使用服务名称(同时作为别名)或服务名称:服务别名 (SERVICE:ALIAS) 格式都可以。

links:
- db
- db:database
- redis

使用的别名将会自动在服务容器中的 /etc/hosts 里创建。例如: 172.17.2.186 db 相应的环境变量也将被创建。

external_links

链接到 docker-compose.yml 外部的容器,甚至 并非 Compose 管理的容器。参数格式跟 links 类似。

external_links:
- redis_1
- project_db_1:mysql
- project_db_1:postgresql

ports

暴露端口信息。 使用宿主:容器 (HOST:CONTAINER)格式或者仅仅指定容器的端口(宿主将会随机选择端口)都可以。当使用 HOST:CONTAINER 格式来映射端口时,如果使用的容器端口小于 60 可能会得到错误得结果,因为 YAML 将会解析 xx:yy 这种数字格式为 60 进制。所以建议采用字符串格式而非整数格式。

ports:
- "3000"
- "8000:8000"
- "127.0.0.1:8001:8001"

expose

暴露端口,但不映射到宿主机,只被连接的服务访问。仅可以指定内部端口为参数:

expose:
- "3000"
- "8000"

volumes

卷挂载路径设置。可以设置宿主机路径 (HOST:CONTAINER) 或加上访问模式 (HOST:CONTAINER:ro)。

volumes:
- /var/lib/mysql
- cache/:/tmp/cache
- ~/configs:/etc/configs/:ro

volumes_from

从另一个服务或容器挂载它的所有卷。

volumes_from:
- service_name
- container_name

environment

设置环境变量。可以使用数组或字典两种格式。只给定名称的变量会自动获取它在 Compose 主机上的值,可以用来防止泄露不必要的数据。

environment:
- RACK_ENV=development
- SESSION_SECRET

env_file

从文件中获取环境变量,可以为单独的文件路径或列表。如果通过 docker-compose -f FILE 指定了模板文件,则 env_file 中路径会基于模板文件路径。如果有变量名称与 environment 指令冲突,则以后者为准。

env_file: .env
env_file:
- ./common.env
- ./apps/web.env
- /opt/secrets.env
环境变量文件中每一行必须符合格式,支持 # 开头的注释行。
# common.env: Set Rails/Rack environment
RACK_ENV=development

extends

基于已有的服务进行扩展。例如我们已经有了一个 webapp 服务,模板文件为 common.yml。

# common.yml
webapp:
  build: ./webapp
  environment:
    - DEBUG=false
    - SEND_EMAILS=false

编写一个新的 development.yml 文件,使用 common.yml 中的 webapp 服务进行扩展。

# development.yml
web:
  extends:
    file: common.yml
    service: webapp
    ports:
    - "8000:8000"
    links:
    - db
    environment:
    - DEBUG=true
db:
  image: postgres

后者会自动继承 common.yml 中的 webapp 服务及相关环节变量。

net

设置网络模式。使用和 docker client 的 –net 参数一样的值。

net: "bridge"
net: "none"
net: "container:[name or id]"
net: "host"

pid

跟主机系统共享进程命名空间。打开该选项的容器可以相互通过进程 ID 来访问和操作。

pid: "host"

dns

配置 DNS 服务器。可以是一个值,也可以是一个列表。

dns: 8.8.8.8
dns:
- 8.8.8.8
- 9.9.9.9

cap_add, cap_drop

添加或放弃容器的 Linux 能力(Capabiliity)。

cap_add:
- ALL
cap_drop:
- NET_ADMIN
- SYS_ADMIN

dns_search

配置 DNS 搜索域。可以是一个值,也可以是一个列表。

dns_search: example.com
dns_search:
- domain1.example.com
 - domain2.example.com

其他

working_dir, entrypoint, user, hostname, domainname, mem_limit, privileged, restart, stdin_open, tty, cpu_shares 这些都是和 docker run 支持的选项类似。

cpu_shares: 73
working_dir: /code
entrypoint: /code/entrypoint.sh
user: postgresql
hostname: foo
domainname: foo.com
mem_limit: 1000000000
privileged: true
restart: always
stdin_open: true
tty: true

Compose 运行 WEATHER 微服务应用

主要分为以下三个步骤:生成微服务镜像、定义 WEATHER 项目、启动并验证微服务。在 生成微服务镜像 这一步,我们会创建一个基本镜像,然后各应用都在这个基础镜像上添加 jar 包,移交时以包含 jar 包的完整镜像移交。在 定义 WEATHER 项目 这一步,我们会创建一个 docker-compose.yaml 文件,然后在这里维护我们的 WEATHER 项目的应用信息。在 启动并验证微服务 这一步,我们会启动我们的 WEATHER 项目,然后会通过日志和 WEB 界面观察应用信息,同时也会介绍增量发布的步骤。

生成微服务镜像

首先我们制作一个运行 java 应用的基本镜像 weather-base,如第三节介绍,我们在centos 7.2 基础上安装 JDK 包,并添加公司的 yum 源并设置系统编码。之后我们添加了一个入口脚本 docker-entrypoint.sh,这个入口脚本基本作用就是将启动时的一些环境变量信息和应用名等参数注入到启动脚本中(启动脚本在 docker-entrypoint.sh 中生成),然后执行启动指令 (exec "$@")。

制作完基本镜像后,我们可以在此基础上制作我们的应用镜像,应用镜像的制作就比较简单了,主要是把应用 jar 包添加到镜像中即可。比如 eureka 镜像的 dockerfile 如下:

FROM 10.214.168.58/weather-test/weather-base:latest

USER root
ADD weather-eureka-server*.jar /opt/idc/apps/weather-eureka-server.jar

EXPOSE 8761

只需要添加 weather-eureka-server.jar 到镜像,然后开放应用占用的端口(在apollo配置中心中配置)即可。同理, weather-mail 的 dockerfile 如下:

FROM 10.214.168.58/weather-test/weather-base:latest

USER root
ADD weather-mail*.jar /opt/idc/apps/weather-mail.jar

EXPOSE 9017

创建完dockerfile,将 [devlops 平台][wanda-develop] 构建生成的应用 jar 包分别放置在和各项目的 dockerfile 平级的文件夹下进行构建并发布:

# 构建 weather-base 镜像
docker build -t weather/weather-base:0.0.7 .
# 压缩 weather-base 镜像
docker-squash -t weather/weather-base:latest weather/weather-base:0.0.7
# 发布 weather-base 镜像
docker login -u liyanjie -p xxx -e liyanjie 10.214.168.58
docker tag weather/weather-base:latest 10.214.168.58/weather-test/weather-base:latest
docker push 10.214.168.58/weather-test/weather-base:latest
docker logout 10.214.168.58
# 构建应用镜像
cd weather-eureka
docker build -t weather/weather-eureka-server:0.0.2 .
cd ../weather-mail
docker build -t weather/weather-mail:0.0.2 .
# 发布应用镜像
docker login -u liyanjie -p xxx -e liyanjie 10.214.168.58
docker tag weather/weather-eureka-server:0.0.2 10.214.168.58/weather-test/weather-eureka-server:0.0.2
docker tag weather/weather-mail:0.0.2 10.214.168.58/weather-test/weather-mail:0.0.2
docker push 10.214.168.58/weather-test/weather-eureka-server:0.0.2
docker push 10.214.168.58/weather-test/weather-mail:0.0.2
docker logout 10.214.168.58

定义 WEATHER 项目

接下来我们通过 Compose 创建 WEATHER 项目,为了可以在一个地方维护环境、版本号等信息,我们创建了一个 docker-compose.sh 脚本,这个脚本中把这些基本信息(ENV、CLUSTER、TAG)通过脚本传参的方式接受,并通过环境变量的方式注入到 docker-compose.yaml 文件中;同时这个脚本也为 WEATHER 项目专门创建了一个名为 weather 的 bridge 类型网络,这也是 Compose 默认支持的网络。 bridge类型网络 只能单机访问,如果需要多机访问,需要使用 overlay,但是 overlay 网络的支持需要 cancel 等组件配合使用,这时我们可以使用 swarm 、kubernetesopenshift 等集群方案,这些在后继章节逐步介绍。

创建如下项目配置到 docker-compose.yaml:

# for docker-compose
version: '2'
services:
  eureka:
    image: ${registry}/weather-eureka-server:${tag}
    network_mode: "weather"
    ports:
      - "8761:8761"
    command: /entrypoint.sh weather-eureka-server
    environment:
      - LANG=en_US.UTF-8
      - LC_ALL=en_US.UTF-8
      - WEATHER_ENV=$weather_env
      - WEATHER_CLUSTER=$weather_cluster

  mail:
    image: ${registry}/weather-mail:${tag}
    network_mode: "weather"
    ports:
      - "9017:9017"
    command: /entrypoint.sh weather-mail
    environment:
      - LANG=en_US.UTF-8
      - LC_ALL=en_US.UTF-8
      - WEATHER_ENV=$weather_env
      - WEATHER_CLUSTER=$weather_cluster
    working_dir: /opt/idc/apps
    depends_on:
      - eureka

在 WEATHER 项目中创建了两个应用:eureka 和 依赖于 eureka 的 mail(通过 depends_on 指定), 设置环境变量 WEATHER_ENV、WEATHER_CLUSTER,这两个参数会在容器启动时注入到 entrypoint.sh 脚本中,然后通过 entrypoint.sh 启动应用。如果我们在 docker-compose.sh 中指定了 weather_env='dev',weather_cluster='docker-dev',则启动 mail 时生成的 entrypoint.sh 脚本为:

#!/bin/sh
cd /opt/idc/apps
java -server -Djava.security.egd=file:/dev/./urandom -Dapollo.env=dev -Dapollo.cluster=docker-dev -jar /opt/idc/apps/weather-mail.jar

然后会继续执行这个脚本,从而启动了 weather-mail 应用,eureka 应用也类似。

启动并验证微服务

通过 ./docker-compose.sh up -d 后台运行起来 WEATHER 项目,执行 ./docker-compose.sh ps 查看应用启动情况:

    Name                   Command               State           Ports
-------------------------------------------------------------------------------
weather_eureka_1    /docker-entrypoint.sh /ent ...   Up      0.0.0.0:8761->8761/tcp
weather_mail_1      /docker-entrypoint.sh /ent ...   Up      0.0.0.0:9017->9017/tcp
weather_weather-app_1   /docker-entrypoint.sh /ent ...   Up      0.0.0.0:9006->9006/tcp

如果发现应用有问题(比如 mail 应用有问题),可以使用 ./docker-compose.sh logs -f mail 来查看应用日志,也可以通过 ./docker-compose.sh stop mail./docker-compose.sh start mail 等指令来管理项目中的应用。项目运行起来后,访问 weather-eureka, 如下(项目名称已由weather更改为mdp):

代表应用已正常启动。这时就可以通过接口等方式来访问应用了。

第三篇:Docker镜像制作

在制作镜像时我们会发现 dockerfile 镜像的第一行,都是 FROM 一个镜像名称,到目前为止这里的镜像一般都是官方镜像或者别人制作的镜像,虽然在大多数情况下这已经可以满足我们的需求,但是如果我们需要在一个可控的操作系统上做一些事情(比如我们的JDK需要按照公司标准安装在固定目录),这时自己制作一个基本的 Docker 基础镜像将会非常有必要。本节会主要介绍镜像制作的两种主要方式。镜像制作完毕后我们还会对镜像做压缩以方便交付和传递,本节在结束时也会介绍镜像的压缩的一些技巧以及镜像打包的方法。

通过 docker commit 的方式构建镜像

经第一节的介绍我们知道,Docker 镜像是分层存储的,每一层都是在上一层的基础上做了一些修改;而容器同样也是多层存储,是在以镜像为基础层,在其基础上加一层作为容器运行时的存储层。如下:

docker镜像分层文件系统

docker容器分层文件系统

现在让我们以定制一个 Web 服务器为例子,来讲解镜像是如何构建的。

docker run --name web -d -p 80:80 nginx

这条命令会用 nginx 镜像启动一个容器,命名为 web,并且映射了 80 端口,这样我们可以用浏览器去访问这个 nginx 服务器。这时我们就会看到默认的 Nginx 欢迎页面。

假设我们需要更改这个欢迎页面的文案为"Hello WEATHER!",这时就可以使用 docker exec 命令进入容器,修改其内容。

$ docker exec -it web bash
root@124f4168be89:/# echo '<h1>Hello WEATHER!</h1>' > /usr/share/nginx/html/index.html
root@124f4168be89:/# exit

我们以交互式终端方式进入 web 容器,并执行了 bash 命令打开命令行终端。

然后,我们用 <h1>Hello WEATHER!</h1> 覆盖了 /usr/share/nginx/html/index.html 的内容。

现在我们再刷新浏览器的话,会发现内容被改变了。

整个过程,我们通过修改容器中的文件来改变了改动了容器的存储层。我们可以通过 docker diff 命令看到具体的改动。

$ docker diff web
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html
C /root
A /root/.bash_history
C /run
A /run/nginx.pid
A /run/secrets
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp

现在我们定制好了变化,接下来希望能将其保存下来形成镜像。

要知道,当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像,也就是在原有镜像的基础上,再叠加上容器的存储层构成了新的镜像。以后我们运行这个新镜像的时候,就会保持原有容器最后的文件变化信息。通过执行以下指令将刚刚更改过的容器保存为镜像:

docker commit -m "update ngxin index.html" web weather-nginx:1.0.1

docker commit 的语法和 git commit的语法类似,不在赘述。

提交之后会返回提交镜像的摘要信息,这时执行 docker images 就可以看到我们新制作的镜像信息了:

[root@ctum2-vmo-214168059 userapp]# docker images
REPOSITORY                        TAG                 IMAGE ID            CREATED              SIZE
weather-nginx                         1.0.1               6070317fe28c        About a minute ago   108.5 MB

通过 docker history 指令可以查看镜像内做的改动记录(第一行带注释的为我们的改动记录)。

[root@ctum2-vmo-214168059 userapp]# docker history 6070317fe28c
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
6070317fe28c        4 minutes ago       nginx -g daemon off;                            168 B               update ngxin index.html
9e7424e5dbae        8 days ago          /bin/sh -c #(nop)  CMD ["nginx" "-g" "daemon    0 B
<missing>           8 days ago          /bin/sh -c #(nop)  STOPSIGNAL [SIGTERM]         0 B
<missing>           8 days ago          /bin/sh -c #(nop)  EXPOSE 80/tcp                0 B
<missing>           8 days ago          /bin/sh -c ln -sf /dev/stdout /var/log/nginx/   0 B
<missing>           8 days ago          /bin/sh -c set -x  && apt-get update  && apt-   53.22 MB
<missing>           8 days ago          /bin/sh -c #(nop)  ENV NJS_VERSION=1.13.7.0.1   0 B
<missing>           8 days ago          /bin/sh -c #(nop)  ENV NGINX_VERSION=1.13.7-1   0 B
<missing>           3 weeks ago         /bin/sh -c #(nop)  LABEL maintainer=NGINX Doc   0 B
<missing>           3 weeks ago         /bin/sh -c #(nop)  CMD ["bash"]                 0 B
<missing>           3 weeks ago         /bin/sh -c #(nop) ADD file:45233d6b5c9b91e943   55.25 MB

如果还需要其他一些操作,都可以使用相同的方式,在容器里修改完成后通过 docker commit 提交。至此我们已经完成了自己的镜像制作,我们可以将自己的镜像推送到 Registry 中方便后面使用。但是一般在实际中我们不会通过这种方式来生成镜像,因为这样容易使容器的层级过多,使容器过于臃肿。我们一般会利用下面 docker build 的方式来构建镜像,这可以合并一些操作,减少分层。

通过 docker build 的方式构建镜像

通过 docker build 方式构建镜像是我们制作镜像的主要方式,这种方式中主要的操作都会定义在 dockerfile 文件中,里面包含了制作镜像的一条条指令,通过这种配置的方式生成了镜像,这样更方便做历史记录和目标文件存档。

首先介绍下 dockerfile 的基本语法:

| 指令 | 指令用途 | | – | :- | |FROM| 基于哪个镜像 | |RUN| 在镜像内通过docker引擎执行指令 | |MANTAINER|镜像的创建和维护者信息 | |CMD| container 启动时执行的命令,一个 container 只能有一个 CMD 指令,多条的话只有最后一个生效 | |ENTRYPOINT| container 启动时执行的命令,一个 container 只能有一个 ENTRYPOINT 指令,多条的话只有最后一个生效| |USER|使用哪个用户跑container | |EXPOSE|开放端口,可以有多个。开放的端口需要在 run 时指定 host-container 端口映射,run时格式为 ”-p <主机IP:>主机端口:容器端口“| |ENV|设置环境变量 | |ADD|将文件 src 拷贝到 container 的文件系统对应的路径 dest,所有拷贝到 container 中的文件和文件夹权限为0755,uid和gid为0,如果文件是可识别的压缩格式,则docker会帮忙解压缩,注意要ADD本地文件必须在构建时的构建上下文中 | |COPY|同ADD,但是压缩包不会自动解压 | |VOLUME|将本地文件夹或者其他container的文件夹挂载到container中 | |WORKDIR|切换目录用,可以多次切换(相当于cd命令),对RUN,CMD,ENTRYPOINT生效 | |ONBUILD|ONBUILD 指定的命令在构建镜像时并不执行,而是在它的子镜像中执行 |

这其中有几个注意点:

ADD 和 COPY 指令的使用

它们的格式都是: 指令 src dest ,使用 ADD 时如果是压缩包,docker 引擎会自动把压缩包解压,但是 COPY 指令会保存源文件的格式。而且 COPY 的 src 不支持使用url,所以在使用 docker build – < somefile 时该指令不能使用。

容器指令入口

指令入口可以通过 ENTRYPOINT 或 CMD 来指定,它们都是在镜像运行时执行。

CMD 的语法有三种:① CMD [“executable”, “param1”, “param2”],将会调用exec执行,首选方式;②CMD [“param1”, “param2”],当使用ENTRYPOINT指令时,为该指令传递默认参数;③ CMD command [ param1|param2 ] 将会调用/bin/sh -c执行。 ENTRYPOINT 的语法有两种:① ENTRYPOINT [“executable”, “param1”, “param2”] ,将会调用exec执行,这是首选方式;② ENTRYPOINT command param1 param2,将会调用/bin/sh -c执行。

它们的区别是① CMD 指令指定的容器启动时命令可以被 docker run 指定的命令覆盖,而ENTRYPOINT指令指定的命令不能被覆盖,而是将 docker run指定的参数当做 ENTRYPOINT 指定命令的参数;② CMD指令可以为 ENTRYPOINT 指令设置默认参数,而且可以被 docker run 指定的参数覆盖。

所以通过 docker run -l 指令启动的容器会把 -l 参数传递给 ENTRYPOINT 指令定义的命令会覆盖 CMD 指令中定义的默认参数(如果有的话),但不会覆盖ENTRYPOINT定义的参数,可以通过这种方法来指定默认参数:ENTRYPOINT 指定入口定义,CMD 指定默认参数。在制作 weather-base 镜像时我们就使用了这个技巧,通过 ENTRYPOINT 设置了启动脚本,通过 CMD 传递进去要启动的应用名称。

构建上下文

docker build 的格式如下:

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

通过 dockerfile 构建时会指定 .,这个代表当前文件夹,通过这个路径我们可以设置构建的上下文。当 Docker 执行构建的时候,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的,在这种客户端/服务端的架构中,为了让服务端获得本地文件,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

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

下面以制作我们的 WEATHER 基本 java 环境镜像为例说明这个过程:

# 基于原始操作系统镜像构建
FROM centos:7.2.1511

# 切换为root用户执行以下指令
USER root
# 设置环境变量值
ENV TAG 20171130
ENV LANG en_US.UTF-8
ENV LC_ALL en_US.UTF-8
ENV JAVA_HOME /usr/java/jdk1.8.0_112
ENV JRE_HOME $JAVA_HOME/jre
ENV CLASSPATH .:$JAVA_HOME/lib:$JRE_HOME/lib
ENV PATH $PATH:$JAVA_HOME/bin

# 挂载文件卷
VOLUME /tmp

# 添加本地文件到镜像,注意一般dockerfile放在专有的文件夹下,里面不要放置无关的文件,也可以放置一个文件名为 .dockerignore 的文件,里面列出需要忽略的文件。ADD被忽略的文件时会报错退出。
ADD wanda-centos-7.repo /etc/yum.repos.d/wanda-centos-7.repo
ADD i18n /etc/sysconfig/i18n
ADD docker-entrypoint.sh /docker-entrypoint.sh

# 执行目录创建、包安装等指令
RUN set -x \
    && mkdir -p /opt/idc/apps \
    && mkdir -p /opt/idc/soft \
    && mkdir -p /etc/yum.repos.d/backup \
    && mv /etc/yum.repos.d/CentOS-*.repo /etc/yum.repos.d/backup \
    && yum update -y \
    && curl -SL http://10.214.124.68/linux-softwares/jdk-8u112-linux-x64.rpm > /opt/idc/soft/jdk-8u112-linux-x64.rpm \
    && rpm -ivh /opt/idc/soft/jdk-8u112-linux-x64.rpm \
    && rm -rf /opt/idc/soft

# 设置容器入口指令
ENTRYPOINT ["/docker-entrypoint.sh"]

然后执行docker build -t weather/weather-base:0.0.1 . 即可构建出我们通过 dockerfile 生成的第一个镜像。

镜像压缩

一般我们使用默认指令构建出来的镜像会比较大,这就不方便镜像的分发和传输,我们需要想办法来精简镜像。一般镜像精简有以下方法:

使用精简的基础镜像

选择比较小的基础镜像可以在一开始就让我们的镜像保持精简。

| 操作系统 | 大小 | | – | :- | |busybox:latest|1.129 MB| |alpine:latest|4.139 MB| |debian:latest|55 MB| |ubuntu:latest|122.8 MB| |centos:7.2.1511|194.6 MB|

但是这里为了方便我们从原系统迁移,使用了和虚拟机上一样的操作系统 centos 7.2.

减少镜像分层

因为在镜像中的每次操作都会生成新的分层,所以我们可以通过减少指令和简化指令的方式来减少精简镜像。常用的一个方法是将需要执行的指令合并,比如我们制作 weather-base 镜像时在 RUN 指令中将需要执行的指令合并在一起执行。

减少镜像文件

首先我们可以通过减少镜像上下文中文件数量和大小来减少镜像文件,通过上文中镜像上下文的分析我们知道在构建目录下的文件,除非列在.dockerignore 中,否则都会同步到 docker 引擎中,所以我们尽量只放置需要的文件或文件夹到构建目录。另外,在执行完指令时我们要尽量把不需要的文件或压缩包删除,同时删除操作尽量和放置文件的操作放在同一层(比如尽量使用 wget 方式来下载安装包,通过 rpm -ivh rpm网络链接的方式安装软件,使用后再删除安装包。在制作 weather-base 制作时使用的 jdk 包就是通过这样的方式做了精简),从而防止添加文件和删除文件不在同一层时添加层镜像并没有精简的问题(不在同一层时上层添加了文件后不删除的话文件就一直存在,另一层做删除时只是把另外层的对应文件标识为了删除,本质上并不会改变其它层的文件系统)。

压缩镜像分层

可以通过镜像打包的方式进行常规压缩,可以使用 export 和 import 指令来打包,比如:

docker run -d weather/weather-base:0.0.3
# 记录上一步的 container_id
docker export ${contianer_id} | docker import - weather/weather-base:1.0.0

但是通过以上指令导入导出有两个问题:首先必须先将容器运行起来才可以打包,另外,打包后,容器中的一些基本信息会丢失,比如 导出端口,环境变量,默认指令 等。所以我们只能借助于其他方法。

在 Docker 1.13 以上版本中,Docker 的构建指令自带 --squash 参数来启动构建时镜像压缩,如果我们不能使用这个指令,就需要考虑其他办法。一般我们可以使用压缩工具来做到这一点。在 Docker 1.10 版本以前,我们可以使用 Go 语言版本的 [docker-squash-1][docker-equash-1] 这个工具来完成这一操作,指令为:

docker save weather/weather-base:0.0.1 | docker-squash -verbose -t weather/weather-base:latest | docker load

以上指令代表将 weather-base:0.0.1 版本镜像压缩,并将新镜像打tag为 weather/weather-base:latest

但是上面的工具在 Docker 1.10 以上版本失效了,主要原因是 Docker 1.10 之后的版本 docker 镜像的分层逻辑做了调整,而工具的作者一直没有对应作调整,具体可参考 Github 上讨论。之后找到了一个 python 版本的 [docker-squash-2][docker-equash-2],这个可以比较好的支持 Docker 1.10 以上版本。安装和使用指令如下:

pip install docker-squash
docker-squash -t weather/weather-base:latest weather/weather-base:0.0.1

压缩完镜像后,我们就可以把这个镜像提交到 Registry 中了。

镜像打包和交付

镜像打包一般有两种方式:导出后再导入(export-import)、保存后再加载(save-load)。

导出后再导入(export-import) 基本操作如下:

docker run -d weather-base:1.0.0
# export 容器到压缩包,类似快照功能
docker export <CONTAINER ID> > /tmp/weather-base.tar
# 导入为镜像
cat /tmp/weather-base.tar | sudo docker import - weather-base:latest

保存后再加载(save-load) 基本操作如下:

# save 镜像到压缩包
docker save weather-base:1.0.0 > /tmp/weather-base.tar
# 导入为镜像
docker load < /tmp/weather-base.tar

他们的主要区别是:导出后再导入(exported-imported)的镜像会丢失所有的历史,而保存后再加载(saveed-loaded)的镜像没有丢失历史和层(layer)。这意味着使用导出后再导入的方式,你将无法回滚到之前的层(layer),同时,使用保存后再加载的方式持久化整个镜像,就可以做到层回滚(可以执行docker tag LAYER_ID IMAGE_NAME 来回滚之前的层,通过 docker images --tree可以查看分层 )。

 

第二篇:Docker仓库介绍

对应于存储 jar 包的 maven 仓库,python 包的 pypi 库,docker 镜像也需要有一个地方来做统一管理,随之就有了 Docker Registry 。

一个 Docker Registry 中包含多个 仓库(Repository),一个仓库一般对照一个应用或服务,比如 weather-app 这样的应用或者 MySQL 这样的组件。仓库的版本使用 标签(tag) 的概念, 由 Registry地址/Repository名称:<标签> 唯一对照一个镜像。如果不指定标签,将会以 latest 作为默认标签。

Docker Repository 一般可以分为两种类型:开放到公网使用的公开镜像仓库和私有镜像仓库。以下简单介绍两种仓库:

公开镜像仓库

公开镜像仓库是开放给用户使用、允许用户管理的 Registry 服务。一般这类仓库允许用户自主下载公开的镜像,注册平台账号后也可以自主上传自己的镜像,大多也提供商业镜像托管服务供企业或个人用户使用。我们使用 docker search 默认搜索的就是 Docker 官方的 Registry Docker Hub,这里有大量的高质量官方镜像,大多其他公开镜像也会默认同步这里的镜像并以加速镜像服务的方式提供给用户使用。当因为网络等原因直接不能直接从 Docker Hub 下载镜像或者下载速度太慢时,就可以选择这些加速镜像服务。

国内常用的开放 Docker Registry 有 阿里云镜像仓库DaoCloud 镜像仓库网易云镜像仓库 等,这些镜像仓库本身也会提供镜像加速功能,比如阿里云的加速器,使用加速功能的方法为把加速的 registry 地址设置在 docker daemon 的配置文件中:

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": ["https://xxxx.mirror.aliyuncs.com"]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

私有镜像仓库

一般在公司内部,我们会自己搭建私有的镜像仓库。Docker 官方也提供了 Docker Registry 镜像,我们可以直接通过这个镜像来构建自己的私有镜像仓库。以下会介绍通过这种方式构建的操作方法,整个过程也可以参考 Docker 官方教程,里面详细介绍了本教程的大多数操作方法。

docker run -d -p 5000:5000 --restart=always --name registry registry:2

以上指令代表在本地5000启动启动私有镜像镜像。之后我们就可以使用常规方式来进行镜像的拉取和推送了:

docker build -t weather/weather-eureka-server:0.0.1 .
docker tag weather/weather-eureka-server:0.0.1 localhost:5000/weather-eureka-server
docker push localhost:5000/weather-eureka-server

因为此时使用的是 http 方式来访问的 Registry,push 的时候会报错,这时可以通过设置 --insecure-registry 参数来跳过这个错误。具体方法为:修改 Docker 的配置文件:

vim /etc/sysconfig/docker
OPTIONS='--selinux-enabled --log-driver=journald --insecure-registry 10.214.168.58:80'
# 重启 docker 服务
service docker restart

不过默认的 Registry 安装之后缺少企业仓库常用的一些功能,比如用户权限管理、后台管理 等功能。社区在开源的 Docker Registry 镜像提供的 Docker Registry API 服务基础上开发了一些高级功能,比如 VMWare Harbor 和 Sonatype Nexus。下面会介绍使用部署 harbor 来管理 Registry,同时使用自认证证书的方式以SSL方式开放私有Registry。 Harbor 的安装可以参考官方说明文档。环境要求为 docker版本为 1.10.0 以上,docker-compose版本为 1.6.0 以上。可以采用离线包安装的方式,首先下载最新版本的 Harbor release 包,解压后通过 harbor.cfg 设置相关参数z。主要设置的参数如下:

# 访问harbor的域名或ip,这里我们直接设置测试服务器ip,如果有做过DNS解析的域名,可以配置为域名,不需要加http或https这样的协议前缀
hostname = 10.214.168.58
# 访问Harbor WEBUI 的协议,默认http方式不够安全,客户端push前也需要做排除证书校验的一些设置。这里我们设置为https,后面会设置自认证证书
ui_url_protocol = https
# 自认证证书存放位置
ssl_cert = /data/cert/10.214.168.58.crt
ssl_cert_key = /data/cert/10.214.168.58.key
# key 、数据库等文件的存放位置
secretkey_path = /data

其他的配置还有ldap配置、WEBUI 密码、SMTP 服务等可以按照需求自己配置。配置好之后开始创建自认证证书, 可以参考为Harbor配置https访问方式

openssl req \
    -newkey rsa:4096 -nodes -sha256 -keyout ca.key \
    -x509 -days 365 -out ca.crt

openssl req \
    -newkey rsa:4096 -nodes -sha256 -keyout 10.214.168.58.key \
    -out 10.214.168.58.csr

echo subjectAltName = IP:10.214.168.58 > extfile.cnf
openssl x509 -req -days 365 -in 10.214.168.58.csr -CA ca.crt -CAkey ca.key -CAcreateserial -extfile extfile.cnf -out 10.214.168.58.crt

cp 10.214.168.58.crt /data/cert/
cp 10.214.168.58.key /data/cert/
cp ca.crt /etc/docker/certs.d/10.214.168.58/
cp /data/cert/10.214.168.58.crt /etc/pki/ca-trust/source/anchors/reg.10.214.168.58.crt

然后完成 Harbor 的安装:

sudo ./install.sh

Notary(公正服务,安装后会展示镜像是否经过认证)、Clair(漏洞扫描功能,可以确保镜像的安全性) 的配置请参考官方安装文档,这里忽略这一步。

安装完成后如果需要修改 Harbor 的配置,则在修改完 harbor.cfg 文件后需要先执行一下 ./prepare 来注入配置,Harbor 运行时配置信息保存在安装目录下的 common 文件中,数据库、证书等存放在配置中 secretkey_path 参数设置的文件夹下。

至此,Harbor 已经安装完成,执行 docker ps 可以看到 Harbor 把容器内的 80、443、4443 端口开放到外面的 80、443、4443 端口,这时我们可以通过 https://10.214.168.58 来访问 Harbor UI,用户名为 admin,密码为配置中设置的密码,默认为 Harbor12345 。在后台可以查看私有仓库中的镜像,也可以以项目的方式规划镜像、划分镜像的访问权限等。基本流程如下:

Harbor 还提供同步功能,通过在 Harbor 后台配置, 一个Harbor仓库的镜像可以同步到另一个 Harbor 仓库。在多环境隔离的公司环境下,这样可以保证镜像在各环境之间自动同步。流程如下:

Harbor 环境搭建完成后,我们可以以项目方式设置镜像的访问权限,当用户需要推送时,就需要先授权通过:

# 注意login时直接使用内网测试服务器 10.214.168.58,而不是 https://10.214.168.58 或 http://10.214.168.58
docker login -u admin -p Harbor12345 10.214.168.58
docker tag weather/weather-eureka-server:0.0.1 10.214.168.58/weather-test/weather-eureka-server:0.0.1
docker push 10.214.168.58/weather-test/weather-eureka-server:0.0.1
docker logout 10.214.168.58

第一篇:Docker基础入门

一、Docker 简介

Docker是一个开源的应用容器引擎,他诞生于由 dotCloud 公司创始人 Solomon Hykes 发起的公司内部项目,于 2013年3月以 Apache 2.0 授权协议开源。主要项目托管在GitHub,后来还进入了Linux基金会并成立 开放容器联盟(OCI),目前围绕Docker生态环境也诞生了许多优秀项目:kubernetesSwarmOpenShiftRancher

Docker 使用Go语言开发实现,主要使用 Linux 内核的Cgroup(占用资源限制)、NameSpace(环境隔离, 宿主机可查看 /sys/fs/cgroup/memory/system.slice/docker-* 文件夹查看各容器分配情况)、Aufs(分层文件系统) 等技术来完成CPU、内存、进程、网络、文件系统的隔离以及文件系统的分层存储。在快速发展中,Docker 在容器的基础上完善了文件系统、存储、网络等资源管理工具,同时提供便利的操作指令和 API 供使用者使用,这些都很大程度上简化了容器的创建、使用和维护,使得Docker 技术比虚拟机技术更为轻便,也比其他容器技术更受关注。

Cgroup 控制 CPU、内存资源分配

Namespace 做进程、网络等资源隔离

AUFS 分层镜像

Docker 通过操作系统内核的能力,在操作系统内核的基础上实现轻量级的虚拟化隔离。下图展示其与虚拟机技术的区别:

二、Docker 能做什么

Docker 并没有让事情变得更简单,他只是让我们做的事情更加流程化、标准化。Docker 在工业流程体系中更像集装箱这样的运输工具,而不是生产工具。

所以 Docker 更适合在 CI/CD 领域有所建树。我们可以像操作沙盒一样干净透明地管理要部署应用的载体,可以更方便地管理和分配应用的占用资源,也可以很方便地通过启动、停止、扩容等方式管理应用。

使用Docker的主要优点有以下方面:

  • 高效的资源利用

Docker不需要进行硬件虚拟,也不需要运行完整的操作系统,而且他还有高效的资源隔离能力,这些特性使得Docker对资源的利用率更高,相同的硬件资源情况下,使用Docker可以比使用传统虚拟机运行更多的应用。

  • 可控的运行时环境,方便交付和部署

应用在各环境中运行的环境不一致是开发和运维中的一个常见问题,这样的问题一般也比较隐蔽,所以往往会浪费我们很多时间。使用容器化部署应用可以保证应用在各个环境中的环境是一致的,这就避免了环境污染的干扰。一般我们会将应用的环境信息定义在 Dockerfile 中,交付时以 Docker Image 的方式提供给发布者,这就避免了手动配置环境时的各种异常情况,使得环境的准备工作更加高效和标准。

  • 快速的运行

因为容器本身比较轻量,而且应用的运行时环境和应用是打包在一起的,所以在不同硬件环境下,都可以快速地完成应用的启动,这一特性也方便应用做底层运行架构调整。

  • 方便维护和扩展

Docker 分层存储的技术使得不同版本应用信息可以很方便地重用,也很方便做应用的升级或回滚操作。完善的API接口和插件机制也使得扩展开发更方便。

  • 完善的生态支持和活跃的社区

自从 Docker 开源以后,很快就吸引了世人的眼光,云服务商开放相关 Docker 托管服务,也有一大批开源组织和作者开放了一些高质量的镜像方便用户直接使用。线上线下也有很多论坛和组织提供相关技术交流活动,这些都使我们可以方便地使用 Docker。

三、Docker 基本概念

Docker 是目前最流行的容器引擎。基本的 Docker 生态主要包含以下内容:

  • 镜像

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

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

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

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

  • 容器实例

一个镜像启动后就生成了容器实例,镜像是静态的定义,容器实例是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。 在Docker中,容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的 命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。

注意的是容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。因此对于需要持久化的数据或日志等,可以存储在数据卷中保存以防止容器销毁时数据的丢失。

  • 数据卷

数据卷可以将一个正常的容器或外部存储作为数据卷,让其他容器通过挂载这个容器实现数据共享。数据卷容器会降低I/O性能,方便容器做数据持久化。Docker 也支持使用 NAS、Swift 等外接存储作为数据卷供容器使用。

  • 链接

我们在使用Docker的时候,经常可能需要连接到其他的容器,比如:WEB 服务需要连接数据库。这时我们就可以在WEB 服务中通过链接(–link 参数)来访问DB服务。

  • 仓库

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

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

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

具体仓库相关介绍会在 Docker 仓库 中介绍。

四、Docker 安装

  • 公司服务器安装Docker

公司内部服务器已配置了 Docker 的 yum 源,因为服务器操作系统版本为 CentosOS Linux release 7.2.1511,内核版本为3.10.0,所以安装最新版本Docker(截止本文最新稳定版为17.09)会报错,所以我们采用默认 yum 安装的方式。安装 docker 和 docker-compose 步骤如下:

yum install docker -y
# 设置开机启动
systemctl enable docker
systemctl start  docker
  • 私有服务器安装Docker
yum remove  docker \
            docker-common \
            docker-selinux \
            docker-engine​yum install -y yum-utils \
            device-mapper-persistent-data \
            lvm2
yum-config-manager \
    --add-repo \
    https://download.docker.com/linux/centos/docker-ce.repo
yum-config-manager --enable docker-ce-edge
yum install docker-ce
  • 安装docker-compose
sudo curl -L https://github.com/docker/compose/releases/download/1.17.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# 添加 export PATH=$PATH:/usr/local/bin 到 /etc/profile, 并 source /etc/profile

五、Docker 基本指令

Docker 基本指令汇合了镜像查询和安装、镜像构建和发布、实例管理和维护 等一系列操作。可以通过 docker help 来查看所有指令。这些指令中本身包含了 docker image 、docker container、docker volumn、docker registry 、docker swarm 等一系列概念,从 1.13 版本之后,可以直接通过这些子命令直接操作。

指令 指令用途
image 操作  
docker images 展示当前所有本地Docker容器镜像
docker build 构建docker镜像,一般在一个包含Dockerfile 的文件夹中执行docker build -t ${image_name}:${tag} . 来构建名字为 ${image_name},版本号为 ${tag} 的镜像
docker pull 拉取镜像,如果不指定tag,默认拉取最新版本(latest)。比如 docker pull ubuntu, docker pull ubuntu:14.04
docker push 推送镜像到镜像库,一般镜像库会有权限验证,这时需要首先使用docker login完成认证
docker history 查看一个image 操作历史信息
docker save 将一个image保存到压缩包,比如 docker save mdp-app:latest > /tmp/mdp-app.tar
docker load 从一个压缩包中load出image,和save对照。比如 docker load < /tmp/mdp-app.tar
docker import 从一个一个容器快照文件导入为image,本指令和docker export对照,导入前为容器快照文件,导入后为镜像。如 docker import /tmp/export.tar mdp-app
docker inspect 展示一个镜像的基本信息
docker tag 给镜像打标签,一般用于提交仓库前统一命名
docker rmi 删除image
container 操作  
docker search 在Docker Hub查找镜像,比如 docker search ubuntu
docker commit 提交更改过的容器并生成新的镜像。这种方式一般不使用。比如 docker commit ${CONTAINER_ID} mdp-app:1.0.1
docker export 持久化容器,导出容器快照到文件。注意不是镜像。比如 docker export ${CONTAINER_ID} > /tmp/export.tar
docker run 启动容器,可以指定 link、network、volumn、entrypoint、cmd 等参数
docker stop 停止容器
docker stop 重启容器
docker ps 查看启动的容器,docker ps -a 可以查看到所有启动过的容器,包括已经停止或退出的。
docker exec 在启动的容器中执行指令,一般查问题时使用
docker attach 挂载到一个容器上,一般使用 docker attach –sig-proxy=false ${CONTAINER_ID} 来挂载,通过 ctrl+c 退出
docker cp 在本地文件和container之间传输文件
docker rm 删除容器,只能删除已经停止的容器,除非使用 -f 参数强制删除,但一般不建议这样用。
docker logs 查看容器的基本输出,可以通过-f 参数跟踪输出
其他常用操作  
docker network docker 网络指令,一般会用 docker network ls 查看网络,docker network create ${network_name} 创建网络
docker login 登陆docker registry

微服务应用Docker化教程

微服务 Docker 化主要解决微服务应用部署流程过于繁琐、应用环境不容易标准化等问题。本示例将循环渐进,逐步从 Docker 基础知识出发,最终完成整个微服务平台的 Docker 化。

一、Docker基础入门

介绍docker的由来、docker带来的便利和思考以及docker的基本操作。

二、Docker镜像仓库介绍

docker镜像仓库一般可分为公有镜像仓库和私有镜像仓库。本节主要会介绍私有镜像仓库的搭建和相关配置方式。

三、Docker镜像制作

本节主要介绍Docker镜像制作的基本知识,同时会介绍一些精简镜像的一些方法。

四、Compose多应用部署

在正式项目的应用中,一般存在多个组件和应用。为了避免分开维护过多的 dockerfile 、网络配置、磁盘卷配置、端口映射等基本信息,可以使用 Compose 来统一化管理和配置这些信息。本节会介绍通过 Compose 管理微服务应用信息。

json 中key顺序二三事

上周在处理一个json字符串转换json时遇到一些问题,事后对json 对象中key的顺序在各语言或框架中的异同做了些进一步的分析,现记下这个过程。

事情的背景:查询一个接口返回的内容,内容是json格式(数据结构事先不确定)。对比两个json的内容,如果后一次的json数据比前一次的数据做了改动,则记录下改动信息(当然, 如果只是json里各项内容的顺序有所调整,内容都不变,应该是算作没有改动的)。

一、使用jackson库做json字符串对比

然后实现对比的时候,默认使用了jackson库,代码如下(字符串已替换为测试数据):

String testA = "{\"ansible\":{\"dd\":\"4321\"}, \"biggest\": \"456\", \"c\": \"789\"}";
String testB = "{\"biggest\":\"456\", \"ansible\":{\"dd\":\"4321\"}, \"c\": \"789\"}";
ObjectMapper mapper = new ObjectMapper();
JsonNode nodeAa = mapper.readTree(testA);
JsonNode nodeBb = mapper.readTree(testB);
System.out.println("Use jackson , aa is " + nodeAa);
System.out.println("Use jackson , bb is " + nodeBb);
System.out.println(nodeAa2.toString().equals(nodeBb2.toString()));

返回结果为:

Use jackson , aa is {"ansible":{"dd":"4321"},"biggest":"456","c":"789"}
Use jackson , bb is {"biggest":"456","ansible":{"dd":"4321"},"c":"789"}

可见,虽然json的内容是一样的,但是顺序是不同的,这时转化为string后再做对比,也确实是不相等的。

二、换个轮子试一下

接着,我又使用国产的fastjson库(至于jackjson 和 fastjson 的对比,网上已有一大堆,推荐 fastjson这么快老外为啥还是热衷 jackson? ,主要是说jackson 功能比较全也比较稳,fastjson的代码有点乱,主打快这一优点,有失json的整体功能)来测试了一把,代码如下:

String testA = "{\"ansible\":{\"dd\":\"4321\"}, \"biggest\": \"456\", \"c\": \"789\"}";
String testB = "{\"biggest\":\"456\", \"ansible\":{\"dd\":\"4321\"}, \"c\": \"789\"}";
ObjectMapper mapper = new ObjectMapper();
JSONObject aa = JSON.parseObject(testA);
JSONObject bb = JSON.parseObject(testB);
System.out.println("here is aa" + aa);
System.out.println("here is bb" + bb);
System.out.println(aa.toJSONString().equals(bb.toJSONString()));

这次,发现返回结果是:

here is aa{"c":"789","biggest":"456","ansible":{"dd":"4321"}}
here is bb{"c":"789","biggest":"456","ansible":{"dd":"4321"}}
true

这次如愿以偿,json中的key貌似先做了排序,然后进行比对。到此,问题已经可以解决。但是,机智如你我,为什么两个库组装json的方式不一样呢,遥想php和python中,json解析完分别可以对照一个关联数组和字典,这两个数据结构本身是无序的,做对比时默认key就是按照hash值排列好的,对比的表现形式也和fastjson 的结果一致。那么,这其中到底发生了什么呢?

三、查看json的定义

搜索资料,看json到底应该符合哪种标准。找来的介绍如下:

JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式,易于阅读和编写,同时也易于机器解析和生成。它基于ECMA262语言规范(1999-12第三版)中JavaScript编程语言的一个子集。 JSON采用与编程语言无关的文本格式,但是也使用了类C语言(包括C, C++, C#, Java, JavaScript, Perl, Python等)的习惯,这些特性使JSON成为理想的数据交换格式。

JSON的结构基于下面两点:
1. "名称/值"对的集合 不同语言中,它被理解为对象(object),记录(record),结构(struct),字典(dictionary),哈希表(hash table),键列表(keyed list)等 ,

一个对象以“{”(左括号)开始,“}”(右括号)结束。每个“名称”后跟一个“:”(冒号);“‘名称/值' 对”之间使用“,”(逗号)分隔。


2. 值的有序列表 多数语言中被理解为数组(array) ,数组是值(value)的有序集合。一个数组以“[”(左中括号)开始,“]”(右中括号)结束。值之间使用“,”(逗号)分隔。

基于上述json的严格定义,一个json中的key是不应该有顺序的,这样说来,除了jackson,其他几个库或语言的实现方式是比较准确的。(其他语言的实现查看本文第六步)

四、分析fastjson 库json存储的实现

接下来就分析下fastjson中是怎么组织json数据的。这里我犯了一个大错:以为fastjson默认是把key有序的组织起来了。此处要注意的是 json 中key有序和无序的意义: json中key是无序的,代表不论是先存入一个值,还是后存入一个值,都不会影响这个json最终的结果(表现的就像key是“有序”的)。这分析fastjson实现时,我就混淆了这个概念,以为在转换时做了什么操作,使得key可以有序的存放,但是分析代码,发现存储json内容时实现方式如下(代码片段引自 JSONObject.java ):

public JSONObject(int initialCapacity, boolean ordered){
        if (ordered) {
            map = new LinkedHashMap<String, Object>(initialCapacity);
        } else {
            map = new HashMap<String, Object>(initialCapacity);
        }
    }

默认是使用了HashMap。后来终于转过来了, HashMap本身是无序的,key值得存放依赖于key的hash code值, 这样,也就反过来印证了默认使用HashMap时key值像是排序了一样的结果。

同时,我们也注意到了初始化时是支持有序map的,只需要传入需要排序的参数即可:

JSONObject cc = JSON.parseObject(testA, Feature.OrderedField);
JSONObject dd = JSON.parseObject(testB, Feature.OrderedField);
System.out.println("here is cc" + cc);
System.out.println("here is dd" + dd);
System.out.println(cc.toJSONString().equals(dd.toJSONString()));

结果如下:

here is cc{"ansible":{"dd":"4321"},"biggest":"456","c":"789"}
here is dd{"biggest":"456","ansible":{"dd":"4321"},"c":"789"}
false

五、回头再看jackson默认排序json 中key的问题:

既然fastjson都能很好地支持多种转换方式,作为标准化做的比较完善的jackson应该不会太弱,然后我就又找了下他是否可以支持不做key排序的调用方法。然后发现他有着强大的feature属性支持,打开feature类后看到了 ORDER_MAP_ENTRIES_BY_KEYS 这一项,看字面意思应该是设置key值是否排序用的,测试一下:

String testA = "{\"ansible\":{\"dd\":\"4321\"}, \"biggest\": \"456\", \"c\": \"789\"}";
String testB = "{\"biggest\":\"456\", \"ansible\":{\"dd\":\"4321\"}, \"c\": \"789\"}";
ObjectMapper mapper = new ObjectMapper();
 mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);

JsonNode nodeAa2 = mapper.readTree(testA);
JsonNode nodeBb2 = mapper.readTree(testB);
System.out.println("Use jackson , aa2 is " + nodeAa2);
System.out.println("Use jackson , bb2 is " + nodeBb2);
System.out.println(nodeAa2.equals(nodeBb2));
System.out.println(nodeAa2.toString().equals(nodeBb2.toString()));

Object objA = mapper.treeToValue(nodeAa2, Object.class);
String strA = mapper.writeValueAsString(objA);
Object objB = mapper.treeToValue(nodeBb2, Object.class);
String strB = mapper.writeValueAsString(objB);
System.out.println("Use jackson , obja is " + objA);
System.out.println("Use jackson , objb is " + objB);
System.out.println("Use jackson , strA is " + strA);
System.out.println("Use jackson , strB is " + strB);

结果如下:

Use jackson , aa2 is {"ansible":{"dd":"4321"},"biggest":"456","c":"789"}
Use jackson , bb2 is {"biggest":"456","ansible":{"dd":"4321"},"c":"789"}
true
false
Use jackson , obja is {ansible={dd=4321}, biggest=456, c=789}
Use jackson , objb is {biggest=456, ansible={dd=4321}, c=789}
Use jackson , strA is {"ansible":{"dd":"4321"},"biggest":"456","c":"789"}
Use jackson , strB is {"ansible":{"dd":"4321"},"biggest":"456","c":"789"}

至此,我们可以自由切换是否使用key排序了。

六、 与js、python、php中的实现对比

1. 首先看看js中json的对比:(js 中 object之间不能直接以=来比较是否想等)

{"a":"b","c":"d"} == {"c":"d","a":"b"} false

{"a":"b","c":"d"} == {"a":"b","c":"d"} false

JSON.stringify({"c":"d","a":"b"}) == JSON.stringify({"a":"b","c":"d"}) false

JSON.stringify({"a":"b","c":"d"}) == JSON.stringify({"a":"b","c":"d"}) true

如果想要实现key值顺序排列,对不起,默认是没有的,我们需要手动来做一下key的排序,方法如下:

// 方式1
Object.keys = Object.keys || function(o) {  
var result = [];  
for(var name in o) {  
    if (o.hasOwnProperty(name))  
      result.push(name);  
}  
    return result;  
};​

var o = {c: 3, a: 1, b: 2};
var n = sortem(o);

function sortem(old){
  var newo = {}; Object.keys(old).sort().forEach(function(k) {new[k]=old[k]});
  return newo;
}

// deep
function sortem(old){
  var newo = {}; Object.keys(old).sort().forEach(function(k){ newo[k]=sortem(old[k]) });
  return newo;
}
sortem({b:{b:1,a:2},a:{b:1,a:2}})




// 方式2( ES5 functional way)
function sortObject(obj) {
    return Object.keys(obj).sort().reduce(function (result, key) {
        result[key] = obj[key];
        return result;
    }, {});
}

// 方式3 (use ES2015 version of above (formatted to "one-liner"))
 
function sortObject(o) {
    return Object.keys(o).sort().reduce((r, k) => (r[k] = o[k], r), {});
}

2. 再看看python中json的操作(使用默认的json模块)

from collections import OrderedDict
import json


stra = u'{"ask":{"b":"milk"}, "cask":"d"}'
strb = u'{"cask":"d", "ask":{"b":"milk"}}'

print(json.loads(stra))
print(json.loads(strb))
print(json.loads(stra) == json.loads(strb))

print (json.dumps(json.loads(strb), sort_keys=True))

print(json.loads(stra, object_pairs_hook=OrderedDict))
print(json.loads(strb, object_pairs_hook=OrderedDict))
print(json.loads(stra, object_pairs_hook=OrderedDict) == json.loads(strb, object_pairs_hook=OrderedDict))

print(json.loads(stra, object_pairs_hook=dict))
print(json.loads(strb, object_pairs_hook=dict))
print(json.loads(stra, object_pairs_hook=dict) == json.loads(strb, object_pairs_hook=dict))

返回如下:

{u'ask': {u'b': u'milk'}, u'cask': u'd'}
{u'ask': {u'b': u'milk'}, u'cask': u'd'}
True
{"ask": {"b": "milk"}, "cask": "d"}
OrderedDict([(u'ask', OrderedDict([(u'b', u'milk')])), (u'cask', u'd')])
OrderedDict([(u'cask', u'd'), (u'ask', OrderedDict([(u'b', u'milk')]))])
False
{u'ask': {u'b': u'milk'}, u'cask': u'd'}
{u'ask': {u'b': u'milk'}, u'cask': u'd'}
True

可见,python中默认json是无序的,像fastjson库的表现一样,如果要实现有序json,就需要设置使用 OrderedDict 来代替默认的 dict。

3. php中json的实现:

php中使用json_encode 和 json_decode 来做json的序列化和反序列化。测试代码如下:

$strA = '{"ask":{"b":"milk"}, "cask":"d"}';
$strB = '{"cask":"d", "ask":{"b":"milk"}}';

var_dump(json_decode($strA, true));
var_dump(json_decode($strB, true));
var_dump(json_decode($strA, true) == json_decode($strB, true));
var_dump(json_encode(json_decode($strA, true)));
var_dump(json_encode(json_decode($strB, true)));

返回如下:

array(2) {
  ["ask"]=>
  array(1) {
    ["b"]=>
    string(4) "milk"
  }
  ["cask"]=>
  string(1) "d"
}
array(2) {
  ["cask"]=>
  string(1) "d"
  ["ask"]=>
  array(1) {
    ["b"]=>
    string(4) "milk"
  }
}
bool(true)
string(31) "{"ask":{"b":"milk"},"cask":"d"}"
string(31) "{"cask":"d","ask":{"b":"milk"}}"

可见,在php中关联数组本身就是无序的,如果你需要对key进行排序,那就借助php强大的数组处理函数尽情玩耍吧(ksort等函数即可实现)。

至此,分析了一圈回来,对json又有了更深一步的了解。画个表格记录下:

对比项 默认是否有序 有序实现方式 无序实现方式
jackson 是 
mapper.readValue(testA,JsonNode.class);
mapper.readTree(testA);
mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);

 

fastjson
JSON.parseObject(jsonStr,Feature.OrderedField)
JSON.parseObject(jsonStr)
python 的json模块
json.loads(jsonStr,object_pairs_hook=OrderedDict
)
json.loads(jsonStr)
js 中JSON模块 JSON.parse(jsonStr)
php中 json_encode,json_decode

json_decode(jsonStr, true)

然后ksort

json_decode(jsonStr, true)

 

16年总结,列下今年的规划

去年发生的事情大致列一下:

1. 年初在陆金所负责的发布系统功能基本完善,投入使用几个月并基本稳定运行。

2. 更多关注前段和移动端的开发,在lu完成了配置系统、脚本平台等几个系统的设计。

3. 五月份决定到万达金融从事web全栈开发工作。

4. 在万达先是做小贷后端接口开发,然后一直负责征信app的前端h5开发,期间对移动前段性能优化、大前端架构有了更深的了解。

5. 关注接口化开发流程,设计接口自动化平台、接口监控平台等促进前后端开发交流。

6. 在kindle上看完了几本书(主要是科幻和侦探类),《三体》和《白夜行》记忆尤深。年初买了树莓派和一大堆配件,开始了硬件折腾之路。年底又买了朝思夜想的rmbp 2016,了却了大半年的心愿。

 

想到年末玩的小游戏,一个轮播的动画,上面播放一串文字,播放后随机截张图来说明上一年的心情。截图两次,一个买,一个穷。还是挺形象的。

 

欢乐辞旧岁,幸福迎新年。 第一天上班,大致列下今年的规划:
1. 和小小同志奔跑两年了,今年要结个巢了,预计五一先让家人互相拜访下,端午或是十一把她拉到我家户口本上,嘻嘻。

2. 16年年底开始看了些java web方面的资料,为了漫长的架构师之路,今年上半年继续深入学习spring mvc 以及java 设计模式 等内容,争取把天气预报这个项目做完(时间节点五一,内容包括 后端 、公众号端、iOS客户端)

3. 持续深入前端技术,在前端自动化,前端性能优化方面继续修炼。

4. 完善去年做的征信爬虫项目,今年加上房地产数据爬取,为触不可及的房子做点力所能及的贡献吧。

5. 完善去年开始的接口话文档平台和监控平台,预计在9月份前完成一个较为通用的平台。

6. 多读些书,多跑点步,完成日语2级。

 

暂时想到这些,先记下吧。顺祝新年大吉大利,事业一帆风顺。

API自动文档管理和接口监控系统

  在现代化前后端分离的方式进行编程时,接口定义和接口对接已经成为很重要的一环。 对此我也有了一些简单的考虑,详情可以参考V站上的讨论: 面向接口编程的一些思考(接口管理、接口健康监控)。 主要思考点有两个: 

  1. 接口文档自动化完成并自动生成mock
  2. 接口自动监控,异常时报警。

为此,希望能有一个工具做到以下几点:

  1. 接口文档和 mock 通过代码自动生成。
  2. 接口版本管理。
  3. 接口健康度监控,可以定时或手动的调用 mock 或是正式的接口(可以通过一个开关控制)。首页可以是一个项目所有接口的 dashboard 监控展示,点击每一项可以看到这个接口的监控或是出错信息。

经过一段时间调研和开发。 接口文档化这一块采用了swagger 对接java doc 进行自动化文档生成。定义文档时不需要手动进行文档描述,只需要按照项目抽象出数据model,然后使用java doc的方式注解上接口的描述等信息,接口请求参数和返回信息等也通过swagger api的方式直接定义。完成定义后,运行项目,自动生成基于swagger的项目接口文档和mock server,方便前后端对接,当model做了修改后swagger文档也对应做修改(后继需要做接口版本管理功能)。展示如下图:

api

接口监控采用定时接口请求,然后保存请求数据到influxdb 中, 展示部分使用grafana 直接展示influxdb中获取到的监控数据。接口监控的详细技术栈如下:

1. 接口监控基于statusok(go开发的并发接口状态监控程序),通过生产者-消费者模型,并发无阻塞地进行接口监控。

2. 数据库采用influxdb, 自动生成基于时间序列化的数据序列,方便后期进行汇总、分析和展示。

3. 展示部分使用grafana,可以原生支持influxdb数据的展示,官方有许多插件,方便扩展,同时本身是基于Angular开发的纯web展示,方便进行定制化开发。

接口监控部分基于statusok做了一些扩展:

1. 接口返回码有influxdb中的field中提取到了tag中,因为tag是添加索引的,而field无索引。在数据展示时返回码也是很重要的一种类别,需要做group等操作,所以添加索引。(注意influxdb中tag部分数据只能为string类型,所以入库时需要做相应的类型转换)。

2. 因为项目的接口经常需要有权限校验逻辑,所以添加了自动权限验证逻辑。具体实现仿照scrapy的逻辑,添加一个pre_request 方法,在这个方法中做一些接口请求的前置操作,比如先获取到相关的token信息,然后放入后继请求的头部cookie中。

grafana中接口监控的dashboard如下如所示,也可以很方便地进行数据的汇总、计算等操作。

grafana

swagger api 开源地址: https://github.com/liyj144/swagger_springdoc.git

监控dashboard 开源地址: https://github.com/liyj144/statusok

scrapy打造分布式带自动登陆的企业信息爬虫

题记:

  最近再做一个征信的项目,当查询企业征信时需要输入公司名称全称和企业所在的城市,而且你还不能输错,我就不想多吐槽了。反正,我就是感觉不爽,怎么办,自己动手,丰衣足食。就自己来获取下这些数据吧。

正文:

  为了获取全国企业的简要信息,我开始从网上找有这些信息的网站,最后找到了几个: 一个是企查查,一个是知企业,还有就是各个城市自己的工商系统。最后,我就选择先从知企业这里获取到公司的基本信息和公司所在地信息。

  分析完需求,开始进行技术选型: 爬虫就选用功能强大的scrapy,爬取的数据结构类似文档类型,选用mongodb比较合适。考虑到数据量比较大,大致计算下单机跑要很多天,程序中断等异常情况很容易发生,所以记录下程序爬取的记录很有必要,分析目标网站,每个公司由32位16进制字符标识,这样的话,存储几百万数据到内存中占用的空间也不大,就直接丢到redis中吧。然后爬虫本身加一个布隆过滤,已经爬过的就不在爬取。

  首先配置mongo,一开始我希望把redis装在开放到公网的树莓派上(路由器刷华硕固件后,开启ngrok将内网的树莓派等设备映射到公网),但是即使是树莓派3了,arm也已经是v7了,支持mongodb3时还是有点力所不能及(因为希望用mongodb3以上的WiredTiger引擎,看官方数据,这个比默认的MMAP引擎性能上提升了将近30%)。折腾了一番,无果,就放到阿里云上吧,相关配置如下:

systemLog:
  destination: file
  logAppend: true
  path: /var/log/mongodb/mongod.log
storage:
  dbPath: /var/lib/mongo
  journal:
    enabled: true
  engine: wiredTiger
  wiredTiger:
    engineConfig:
      cacheSizeGB: 1
      #cache_size: 300M
      directoryForIndexes: true
    collectionConfig:
      blockCompressor: snappy
    indexConfig:
      prefixCompression: true
processManagement:
  fork: true  # fork and run in background
  pidFilePath: /var/run/mongodb/mongod.pid
net:
  port: 27017
  bindIp: 0.0.0.0
security:
  authorization: enabled

。之后还发生了个小插曲,爬虫跑起来一段时间后我的主机竟然不能连接上去了,看监控信息内存爆掉了,看mongo文档,wiredTiger 引擎如果不配置cacheSizeGB 这个参数,则默认使用40% 系统内存,不过如果40%内存的值小于1G,就直接使用1G。果然很霸道,我这个机器的内存只有2G呀,mongo 引擎都要1G,再加上mongo自身需要的内存,大约1.3G就这样没了,而我这个机器上还跑的有别的程序,所以内存很容易就跑光了。最不可思议的是cacheSizeGB 这个参数是个整型,这样一来,你至少也得配1G,看来只能妥协了,加点内存吧,于是开启swap,添加1G的磁盘容量为swap内存,暂时解决了这个问题。

爬虫基本结构构造如下: 从首页初始化后,选择各个省份,然后进入到城市、地区,然后把这个页面上企业的url加入到爬取队列中,因为这个页面是分页的,所以分析完这个页面继续进入到下个页面进行分析,核心代码如下:

    """
    分析省份详情
    """
    def parse_province(self, response):
        # 获取省份名称,回调进入城市分析

    """
    分析城市详情
    """
    def parse_city(self, response):
        # 获取城市名称,回调进入区域分析

    """
    分析区域详情
    """
    def parse_area(self, response):
        # 获取区域名称,回调页面分析

    """
    分析页面上企业名单, 如果有下一页, 则在下一页继续执行本方法
    """
    def parse_page(self, response):
        xxx
        ar_corp = corp.get_corp_list(response)
        for corp_url in ar_corp:
            corp_id = corp.get_corp_id_by_url(corp_url)
            yield Request("%s%s" % (self.url_prefix, corp_url),
                          meta={"province": province,
                                "city": city,
                                "area": area,
                                "corp_id": corp_id}, callback=self.parse_corp)
        # 将下一页加入到爬取队列
        next_page = corp.get_next_page_url(response)
        if next_page:
            yield Request("%s%s" % (self.url_prefix, next_page),
                          meta={"province": province,
                                "city": city,
                                "area": area}, callback=self.parse_page)

    """
    分析企业详情
    """
    def parse_corp(self, response, meta=False):
        # 拼装item

  当我的爬虫愉快地爬了几天后,突然报异常了:页面上一些元素不能找到。赶紧到网站上一看,管理员在此期间,加了登陆限制,非登录用户只能查看很少一部分数据。还好登陆机制不太复杂,赶紧加了自动登陆的代码,在爬虫初始化后触发自动登陆,拿到授权cookie后再进行爬取。示例如下:

def start_requests(self):
        logging.info("start to login ...")
        return [Request("http://www.zhiqiye.com/index.html",
                    callback=self.post_login)]

def post_login(self, response):
    current = int(time.time() * 1000)
        return [FormRequest.from_response(response,
                                        url="http://www.zhiqiye.com/account/login/",
                                        method="POST",
                                        formdata={
                                            'time': "%s" % current,
                                            'username': self.user_name,
                                            'pass': self.password,
                                            'f': 'true'
                                        },
                                        dont_filter=True,
                                        callback=self.after_login)]

不过,爬取了几个小时候,发现又断了,看日志,继续报元素找不到异常。分析下来,原因是session过期了,看来需要做登陆态检查了。这里花了一番功夫,本想检查到登出后重新触发初始化就行了,然后发现并不行,后来了解到parse函数中做了return操作,这样就会触发数据保存并中断回调的操作,所以并不能在这里加回调操作。最终找到解决办法(v站讨论地址:https://www.v2ex.com/t/302345)就是定义一个重新登陆的函数,检测到登出态(通过捕捉爬虫元素报IndexError异常的方式)时调用这个函数(其实这里少了一个步骤:把本次失败的请求重新加入到爬虫队列中)。 示例如下:

try:
            corp = CorpParseModel()
            corp.get_corp_tips(response, item)
            corp.get_contact(response, item)
            corp.get_commercial(response, item)
            corp.get_corp_info(response, item)
            corp.get_relate_corp(response, item)
            return item
except IndexError:
            self.log("------------- start to login again -------------")
            if is_retry:
                self.log("------ retry login and fails ------")
            else:
                return self.post_login(response, True)

最后把mongo、redis 添加密码访问和ip限制,然后开放到公网,这样就可以多个设备一起愉快地爬取了。

心得:

1. 开发过程中注意对异常的处理,确保系统的健壮性。前期一定要有比较完整的日志收集,出问题时可以快速定位问题、分析问题。

2. scrapy 中可以使用 inspect_response 调试正则、附带的参数、cookie等信息,方便观察请求的细节。

3. 开发爬虫时可以定义一个测试用的爬虫,新的功能开发和测试可以现在测试爬虫完成后再放入到目标爬虫。

4. 监控很重要,要实时监控我们的系统运行的状态。开发这个爬虫项目时我加了个获取爬虫状态的功能到我的微信公众号,只要输入相关指令即可获取爬虫当前的状态(爬取总数、各省份爬取数量、模糊搜索等)

附:

1. 爬虫地址:https://github.com/liyj144/corporation_spider

2. 微信机器人地址(包含爬虫状态监控):https://github.com/liyj144/weixin_robot

vue 介绍

  这两天把玩了一个vue 这个前端小框架。作为一个面向前端展示层的框架,vue的表现确实出众。本系列文章将会简单介绍一下这个技术栈。本文第一篇,介绍一下vue的基本概念。

  vue的基本思想是: 采用 数据驱动 + 组件化 的方式进行前端界面开发, 特点是面向视图层,轻量级,方便与其他库结合使用。同时存在的一些不足有成熟的组件较少;而本身比较轻量,默认不包含路由、ajax等功能,需要开发人员造一些轮子,同时也缺少一些大型项目经验的分享(对比angular)。另提一点,作者是个国内小伙,在国内社区(知乎、V2、微博等)比较活跃,方便交流。

  首先介绍下数据驱动,vue的数据驱动采用来MVVM的模型结构,通过VM(ViewModel)连通前端view和数据层model,图示如下:

举例说明, 首先定义一个view层(你可以定义一个1.html):

<div id="example-1">
  Hello {{ name }}
</div>

然后定义数据model层:(你可以定义一个1.js)

var indexData = {
    name: 'Vue.js'
}

然后定义VM层来连接数据和页面,达到通过数据控制页面展示的效果:(下面的同样放入到1.js)

var indexVM = new Vue({
    el: ‘#example-1,
    data: indexData
})

这样就完成了我们第一个入门demo。 后继如果需要更改数据的内容, 只需要直接更改model的值即可,当然,推荐使用VM开放出来的方法和属性来进行修改:

方式1: indexData.name = 'liyj'   
方式2:  indexVM.$data.name = 'good'  (其中 indexVM.$data === indexData)
方式3(推荐): indexVM.$set("name", "exp")

简单了解完数据驱动、VM数据连接和绑定后,我们开始详细介绍数据驱动的一些知识点,只要分以下四个方面:数据控制、表单、动画效果 和 消息传递。

一、 数据控制

首先就是通过控制model中的数据,可以改变view层显示的内容,这点上面已经介绍了(通过VM的set方法来改变model的数据值).然后在VM中也定义了很多方法来改变视图层的相关内容,常用的方法如下:

data: Object或者Function, 数据model,Vue实例的数据对象。定义组件时类型为函数,返回原始数据对象。

props:Array 或者Object, 包含期望使用的父组件数据的属性,用于类型检查、验证,默认值等。

methods: Object类型, 实例方法。

computed: Object类型,实例计算属性值。getter和setter的this自动绑定到实例。

watch: Object类型,键是观察表达式,值是对应回调,主要功能是值发生变化时做一些触发性的动作,类似angular中使用watch定义的一些方法。

还有一些组特有的方法比如 filters,components 等,在示例中可以慢慢体会。

二、表单

view层比较重要的一块是表单,所以主要是单向数据绑定(注意这个单向指的是数据是由上到下,由父到子的,而子节点的数据改动不会影响到父节点)的vue也定义了v-model这个指令来在表单控件元素上创建双向数据绑定(关于双向数据绑定可以看一篇文章 AngularJS的数据双向绑定是怎么实现的,他的实现是事件触发的,而不是定时检测的。这种设计是一个比较优雅的理念,永远不要让我们的架构引入一堆不可控的组件比如定时器、消费队列等,相反,我们可以通过消息驱动、任务驱动和事件驱动等达到这一目的,后继会开一篇文章来介绍一下任务系统聊聊这个话题)。然后表单组件还会有一些事件触发的绑定,属性更新的绑定(普通组件也会需要到),vue中使用v-on和v-bind 这些指令来实现: v-bind 绑定属性: v-bind:href / :href, v-bind:class / :class, v-on 绑定事件: v-on:click / @click, v-on:submit / @submit。 绑定示例如下:

1. <input type="text" v-model="name" />
2. <div
      :class="{bold: isFolder}"
      @click="toggle"
      @dblclick.stop="changeType">
      {{model.name}}
      <span v-if="isFolder">[{{open ? '-' : '+'}}]</span>
    </div>
3. <ul v-show="open" v-if="isFolder">
      <item
        class="item"
        v-for="model in model.children"
        :model="model">
      </item>
      <li @click="addChild">+</li>
    </ul>

三、动画效果

页面展示要实现一些绚丽效果,动画是必不可少的。vue中使用过渡的概念来定义动画,通过在目标元素上添加transition属性来实现过渡的效果。定义过渡效果时主要关系enter 和 leave 这两个特性。然后我们就可以通过常用的几种前端动画实现方式来定义动画了:1. 通过class切换组件入和出。2. 设置CSS帧动画。3.通过JS动画实现。 示例如下:

1. 切换class
Vue.transition('expand', {
	enterClass: 'ex_in',
	leaveClass: 'ex_out'
});
css 定义:
.expand-transition {
	  transition: all .3s ease;
	  height: 30px;
	  padding: 10px;
	  background-color: #eee;
	  overflow: hidden;
}
.ex_in, .ex_out {
	  height: 0;
	  padding: 0 10px;
	  opacity: 0;
}
2. css帧动画:
.bounce-transition {
	  display: inline-block; /* 否则 scale 动画不起作用 */
	}
	.bounce-enter {
	  animation: bounce-in .5s;
	}
	.bounce-leave {
	  animation: bounce-out .5s;
	}
	@keyframes bounce-in {
	  0% {
	    transform: scale(0);
	  }
	  50% {
	    transform: scale(1.5);
	  }
	  100% {
	    transform: scale(1);
	  }
	}
	@keyframes bounce-out {
	  0% {
	    transform: scale(1);
	  }
	  50% {
	    transform: scale(1.5);
	  }
	  100% {
	    transform: scale(0);
	  }
	}
3. JS 动画:
Vue.transition('fade', {
	css: false,
	enter: function(el, done){
		$(el)
      		.css('opacity', 0)
      		.animate({ opacity: 1 }, 1000, done);
	},
	enterCancelled: function(el){
		$(el).stop();
	},
	leave: function(el, done){
		$(el).animate({ opacity: 0 }, 1000, done);
	},
	leaveCancelled: function(el){
		$(el).stop();
	}
})

四、数据传递

这里说的数据传递主要是组件间的数据传递。vue中组件在父子之间是单向传递的(广播的方式),由父到子,而在不同组件之间,作用域是孤立的。子组件的数据默认是不能流向父组件的,在程序中可以定义成消息触发的方式来传递到父组件(消息分发的方式),这样就消除了数据传递混乱的现象(父组件的数据可能被未知的子组件改变)。总结下来就是:

•1. 组件之间作用域是孤立的

•2. 父组件可以通过props 传递数据到子组件

•3. 子组件可以通过 this.$parent 和 this.$root 来访问父组件和根实例,但是尽量避免这样做

•4. 子组件可以通过事件和父组件通信

说完数据驱动,就很有必要在说一说今天的另一个重头:组件化。

vue中,我们希望页面是由一个个组件构成的(当然,组件中可以包含组件,比如一般我们会定义一个大的组件叫app,里面再包含页面上的其他组件),每个组件定义自己的VM,然后程序中通过控制VM来完成前端的逻辑。

组件可以定义为全局的或者局部的,建议公共组件定义为全局的,其他的定义为局部的。示例如下:

// global
// define global component:
var MyComponent = Vue.extend({
	template: '<p>{{msg}}</p>',
	props: ['msg']
});
// register component global
Vue.component('my-component', MyComponent);
// define VM:
new Vue({
	el: '#example-4'
})


// local
// define VM and component:
new Vue({
    el: ‘#example-4’,
    components : {
         “myComponent”: {
            props: ['msg'],
            template: '<span>{{ msg }}</span>'
        }
    }
})

然后页面上就可以通过 

<my-component msg="Hello World!"></my-component>

来引入了。

关于vue的介绍就基本到这里了,本篇文章的ppt和代码见github: https://github.com/liyj144/vue_introduction .