如何使用 Node.js 和 Docker 构建高质量的微服务

purpleladybug 发布于3年前
0 条问题
 

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

微服务的服务范围越来越广泛,尤其是在构建复杂应用中,下面我主要从以下几点分享如何使用 Node.js 和 Docker 构建高质量的微服务:

1. 什么是微服务

2. Node.js 异步流程控制及异常处理

3. 使用 Kong 构建 API gateway

4. 微服务持续集成与快速部署

1. 什么是微服务

微服务架构是一种构造应用程序的替代性方法。应用程序被分解为更小、完全独立的组件,这使得它们拥有更高的敏捷性、可伸缩性和可用性。一个复杂的应用被拆分为若干微服务,微服务更需要一种成熟的交付能力。持续集成、部署和全自动测试都必不可少。编写代码的开发人员必须负责代码的生产部署。

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

在实际基于 docker 构建微服务架构中,我们主要解决了以下5个问题:

1. 编写高质量的微服务

2. 微服务的持续集成与快速部署

3. 客户端到服务端以及微服务之间的高效通信

4. 服务器快速配置

5. 完善的运维与监控体系

微服务架构是由一个个微小的应用程序组成的,一个高质量的微服务是构建微服务架构的前提;在实际开发中还需要一个一体化的 DevOps 平台,这样才可以解决微服务的持续集成与快速部署;微服务多了之后,还需要解决客户端到服务端以及微服务之间的高效通信,我们通过 Kong 构建微服务的 API gateway,为客户端提供一个统一的 Rest API,微服务之间也通过 Rest API 进行通信。今天我们主要讨论前三个问题。

2. Node.js 异步流程控制及异常处理

Node.js 是构建微服务的利器,为啥这么说呢,请往下看:

1. Node.js 采用事件驱动、异步编程,为网络服务而设计

2. Node.js 非阻塞模式的IO处理给 Node.js 带来在相对低系统资源耗用下的高性能与出众的负载能力,非常适合用作依赖其它IO资源的中间层服务

3. Node.js轻量高效,可以认为是数据密集型分布式部署环境下的实时应用系统的完美解决方案。

这些优势正好与微服务的优势:敏捷性、可伸缩性和可用性相契合(捂脸笑)。

但是 Node.js 的异步特性也带来了一些问题,比如 callback 回调地狱以及“脆弱”的异常处理,当然我们可以通过使用 ES2015 的特性来控制异步流程,解决回调地狱,也可以加强异常处理机制规避一些未处理异常引起的程序崩溃,最终在实际部署中,通过多实例以及 K8s 的负载均衡特性保证程序的高可用。

目前 Node.js 的 LTS 版本早就支持了 Generator , Promise 这两个特性,也有许多优秀的第三方库 bluebird、q 这样的模块支持的也非常好,性能甚至比原生的还好,可以用 bluebird 替换 Node.js 原生的 Promise:

global.Promise = require('bluebird')

blurbird 的性能是 V8 里内置的 Promise 3 倍左右( bluebird 的优化方式 )。

2.1 Node.js 异步流程控制

2.1.1 ES2015 Generator

Generators are functions which can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances. --- https://developer.mozilla.org/ ... ction *

generator 就像一个取号机,你可以通过取一张票来向机器请求一个号码。你接收了你的号码,但是机器不会自动为你提供下一个。换句话说,取票机“暂停”直到有人请求另一个号码( next() ),此时它才会向后运行。下面我们看一个简单的示例:

function* idMaker(){

var index = 0

while(index < 3)

yield index++

}



var gen = idMaker()



gen.next() // {value: 0, done: false}

gen.next() // {value: 1, done: false}

gen.next() // {value: 2, done: false}

gen.next() // {value: undefined, done: true}

// ...

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

从上面的代码的输出可以看出:

  1. generator 函数的定义,是通过 function *(){} 实现的

  2. 对 generator 函数的调用返回的实际是一个遍历器,随后代码通过使用遍历器的 next() 方法来获得函数的输出

  3. 通过使用 yield 语句来中断 generator 函数的运行,并且可以返回一个中间结果

  4. 每次调用 next() 方法,generator 函数将执行到下一个 yield 语句或者是 return 语句。

下面我们就对上面代码的每次next调用进行一个详细的解释:

  1. 第1次调用 next() 方法的时候,函数执行到第一次循环的 yield index++ 语句停了下来,并且返回了 0 这个 value ,随同 value 返回的 done 属性表明 generator 函数的运行还没有结束

  2. 第2次调用 next() 方法的时候,函数执行到第二循环的 yield index++ 语句停了下来,并且返回了 1 这个 value ,随同 value 返回的 done 属性表明 generator 函数的运行还没有结束

  3. ... ...

  4. 第4次调用 next() 方法的时候,由于循环已经结束了,所以函数调用立即返回, done 属性表明 generator 函数已经结束运行, valueundefined 的,因为这次调用并没有执行任何语句

2.1.2 ES2015 Promise

The Promise object is used for asynchronous computations. A Promise represents an operation that hasn't completed yet, but is expected in the future. --- https://developer.mozilla.org/ ... omise

所谓 Promise,就是一个对象,用来传递异步操作的消息。它代表了某个未来才会知道结果的事件(通常是一个异步操作),并且这个事件提供统一的 API,可供进一步处理。

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

一个 Promise 一般有3种状态:

1. pending : 初始状态, 不是 fulfilled ,也不是 rejected .

2. fulfilled : 操作成功完成.

3. rejected : 操作失败.

一个 Promise 的生命周期如下图:

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

下面我们看一段具体代码:

function asyncFunction() {

return new Promise(function (resolve, reject) {

setTimeout(function () {

  resolve('Async Hello world')

}, 16)

})

}



asyncFunction().then(function (value) {

console.log(value)  // => 'Async Hello world'

}).catch(function (error) {

console.log(error)

})

asyncFunction 这个函数会返回 Promise 对象, 对于这个 Promise 对象,我们调用它的 then 方法来设置 resolve 后的回调函数, catch 方法来设置发生错误时的回调函数。

该 Promise 对象会在 setTimeout 之后的 16ms 时被 resolve , 这时 then 的回调函数会被调用,并输出 'Async Hello world' 。

在这种情况下 catch 的回调函数并不会被执行(因为 Promise 返回了 resolve ), 不过如果运行环境没有提供 setTimeout 函数的话,那么上面代码在执行中就会产生异常,在 catch 中设置的回调函数就会被执行。

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

小结

如果是编写一个 SDK 或 API,推荐使用传统的 callback 或者 Promise,不使用 generator 的原因是:

* generator 的出现不是为了解决异步问题

* 使用 generator 是会传染的,当你尝试 yield 一下的时候,它要求你也必须在一个 generator function 内

《如何用 Node.js 编写一个 API 客户端》 @leizongmin)

由此看来学习 Promise 是水到渠成的事情。

2.2 Node.js 异常处理

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

一个友好的错误处理机制应该满足三个条件:

  1. 对于引发异常的用户,返回 500 页面

  2. 其他用户不受影响,可以正常访问

  3. 不影响整个进程的正常运行

下面我们就以这三个条件为原则,具体介绍下 Express、Koa 中的异常处理:

2.2.1 Express 异常处理

在 Express 中有一个内置的错误处理中间件,这个中间件会处理任何遇到的错误。如果你在 Express 中传递了一个错误给 next() ,而没有自己定义的错误处理函数处理这个错误,这个错误就会被 Express 默认的错误处理函数捕获并处理,而且会把错误的堆栈信息返回到客户端,这样的错误处理是非常不友好的,还好我们可以通过设置 NODE_ENV 环境变量为 production ,这样 Express 就会在生产环境模式下运行应用,生产环境模式下 Express 不会把错误的堆栈信息返回到客户端。

在 Express 项目中可以定义一个错误处理的中间件用来替换 Express 默认的错误处理函数:

app.use(errorHandler)

function errorHandler(err, req, res, next) {

if (res.headersSent) {

return next(err)

}

res.status(500)

switch(req.accepts(['html', 'json'])) {

case 'html':

  res.render('error', { error: err })

  break

default:

  res.send('500 Internal Server Error')

}

}

在所有其他 app.use() 以及路由之后引入以上代码,可以满足以上三个友好错误处理条件,是一种非常友好的错误处理机制。

2.2.2 Koa 异常处理

我们以 Koa 1.x 为例,看代码:

app.use(function *(next) {

try {

yield next

} catch (err) {

this.status = err.status || 500

this.body = err

this.app.emit('error', err, this)

}

})

把上面的代码放在所有 app.use() 函数前面,这样基本上所有的同步错误均会被 try{} catch(err){} 捕获到了,具体原理大家可以了解下 Koa 中间件的机制。

2.2.3 未捕获的异常 uncaughtException

上面的两种异常处理方法,只能捕获同步错误,而异步代码产生的错误才是致命的, uncaughtException 错误会导致当前的所有用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个 no data received 错误。

这是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 uncaughtException 导致服务器崩溃。在Node.js 我们可以通过以下代码捕获 uncaughtException 错误:

process.on('uncaughtException', function (err) {

console.error('Unexpected exception: ' + err)

console.error('Unexpected exception stack: ' + err.stack)

// Do something here:

// Such as send a email to admin

// process.exit(1)

})

捕获 uncaughtException 后,Node.js 的进程就不会退出,但是当 Node.js 抛出 uncaughtException 异常时就会丢失当前环境的堆栈,导致 Node.js 不能正常进行内存回收。也就是说,每一次 uncaughtException 都有可能导致内存泄露。既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程以便重启服务。当然还可以利用 domain 模块做更细致的异常处理,这里就不做介绍了。

3.使用 Kong 构建 API gateway

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

Kong 是一个基于 Nginx 开发的开源 API gateway,下面主要从以下3个方面介绍 Kong:

1. docker 中运行 Kong

2. Kong 高可用

3. Kong plugin 使用举例

3.1 docker 中运行 Kong

1. 启动数据库容器,以 postgres 为例

docker run -d --name kong-database \

          -p 5432:5432 \

          -e "POSTGRES_USER=kong" \

          -e "POSTGRES_DB=kong" \

          postgres:9.4

2. 启动 Kong

docker run -d --name kong \

          --link kong-database:kong-database \

          -e "KONG_DATABASE=postgres" \

          -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \

          -e "KONG_PG_HOST=kong-database" \

          -p 8000:8000 \

          -p 8443:8443 \

          -p 8001:8001 \

          -p 7946:7946 \

          -p 7946:7946/udp \

          kong

3. 检查 Kong 是否运行正常

Kong 启动以后,会监听 8000 和 8001 两个端口。其中 8001 作为 Admin API Server

curl http://127.0.0.1:8001

3.2 Kong 高可用

可以通过 Nginx 或者 k8s 实现 Kong 高可用,开启高可用后,系统的典型构架如下:

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

使用 Nginx 实现高可用可参考以下脚本:

upstream backend {

ip_hash;

    server 192.168.1.1:8000;

    server 192.168.1.2:8000;

    server 192.168.1.3:8000;

    }

server {

listen  8000;

# ssl     on;

# ssl_certificate      /etc/nginx/conf.d/server.cert;

# ssl_certificate_key  /etc/nginx/conf.d/server.key;

location / {

    #设置主机头和客户端真实地址,以便服务器获取客户端真实IP

    proxy_set_header Host $host;

    proxy_set_header X-Real-IP $remote_addr;

    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    #禁用缓存

    proxy_buffering off;

    #反向代理的地址

    proxy_pass https://backend;

}

location /status {

    stub_status on;

    auth_basic "NginxStatus";

}

}

3.3 Kong plugin 使用举例

Kong 依赖于 cassandra 或 postgres,但 Kong Server 自己维护 cache,只有 plugin 需要使用额外的数据库,下面以 key-authentication 为例说明如何给 API 增加 Authentication:

准备

创建一个 hello world 服务(监听端口 8889),假设宿主机 IP 为 192.168.1.4

docker run -d --name=hello -p 8889:80 index.tenxcloud.com/tenxcloud/hello-world

将 hello world API 添加到 Kong:(由于Kong运行在容器中,upstream_url 不能使用 localhost)

curl -i -X POST \

    --url http://localhost:8001/apis/ \

    --data 'name=hello' \

    --data 'upstream_url=http://192.168.1.4:8889' \

    --data 'request_host=hello-world'

如果运行成功, 执行以下脚本,能够看到 Kong 的 Response 头信息和 hello world 页面。

curl -i -X GET \

    --url http://localhost:8000/ \

    --header 'Host: hello-world'

使用 apiKey 进行身份认证

1. 为 特定API 添加 权限验证:

curl -i -X GET \

    --url http://localhost:8000/ \

    --header 'Host: hello-world'

这里 {api} 必须是 API 的 ID 或 Name,这里以 hello 为例。

添加成功以后,可以执行以下脚本查看该 API 的插件:

curl -X GET http://localhost:8001/apis/hello/plugins

此时,我们再次运行以下脚本 ,会得到 401 Unauthorized 的结果:

root@ubuntu:~# curl -i -X GET \

--url http://localhost:8000/ \

--header 'Host: hello-world'



HTTP/1.1 401 Unauthorized

Date: Mon, 10 Oct 2016 07:56:54 GMT

Content-Type: application/json; charset=utf-8

Transfer-Encoding: chunked

Connection: keep-alive

WWW-Authenticate: Key realm="kong"

Server: kong/0.9.2



{"message":"No API key found in headers or querystring"

2. 增加 Consumer,并为Consumer 增加 apiToken

不管使用 Key 进行身份认证,还是实现 client 粒度的限速,都需要一个 Consumer ID 。

Consumer 本质上与 用户 是一个概念。Consumer ID 与 api Token 配合使用,实现身份认证的功能;还可以与 Rate limiting 配合使用,实现对特定用户限速的功能。

Consumer 的相关操作参考 API 文档 Consumer Object

增加名为 test 的 consumer:

root@ubuntu:~# # add consumer "test"

root@ubuntu:~# curl -X POST http://localhost:8001/consumers/ \

--data "username=test" \

--data "custom_id=id_test"

{"custom_id":"id_test","username":"test","created_at":1478075521563,"id":"2bbd5f40-f4f3-456d-9695-2e1466633615"}

为 consumer "test" 增加一个 api key:

root@ubuntu:~# # add api key for consumer "test"

root@ubuntu:~# curl -X POST http://localhost:8001/consumers/test/key-auth \

--data 'key=my-customized-key'

{"created_at":1478075713267,"consumer_id":"2bbd5f40-f4f3-456d-9695-2e1466633615","key":"my-customized-key","id":"b9e4db78-2e1a-43d0-8a22-01b889b47952"}

测试生成的 api key 是否正确:

root@ubuntu:~# # check whether api key is valid or not

root@ubuntu:~# curl -i -X GET \

--url http://localhost:8000/ \

--header "Host: hello-world" \

--header "apiKey: b9e4db78-2e1a-43d0-8a22-01b889b47952"

HTTP/1.1 200 OK

...

...

...

4.微服务持续集成与快速部署

由于我们是基于 docker 构建的微服务架构,所以在部署时,首先我们要为每个应用程序都写一个 Dockerfile

4.1 如何编写 Dockerfile

4.1.1 基础镜像选择

我们先选用 Node.js 官方推荐的 node:argon 官方 LTS 版本最新镜像,镜像大小为 656.9 MB (解压后大小,下文提到的镜像大小没有特殊说明的均指解压后的大小)

The first thing we need to do is define from what image we want to build from. Here we will use the latest LTS (long term support) version argon of node available from the Docker Hub --- https://nodejs.org/en/docs/gui ... bapp/

我们事先写好了两个文件 package.json , app.js :

{

"name": "docker_web_app",

"version": "1.0.0",

"description": "Node.js on Docker",

"author": "Zhangpc <zhangpc@tenxcloud.com>",

"main": "app.js",

"scripts": {

"start": "node app.js"

},

"dependencies": {

"express": "^4.13.3"

}

}
// app.js

'use strict';



const express = require('express')



// Constants

const PORT = 8080



// App

const app = express()

app.get('/', function (req, res) {

res.send('Hello world\n')

})



app.listen(PORT)

console.log('Running on http://localhost:' + PORT)

下面开始编写 Dockerfile,由于直接从 Dockerhub 拉取镜像速度较慢,我们选用时速云的docker官方镜像 docker_library/node ,这些官方镜像都是与 Dockerhub 实时同步的:

Dockerfile.argon

FROM index.tenxcloud.com/docker_library/node:argon

Create app directory

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

Install app dependencies

COPY package.json /usr/src/app/

RUN npm install

Bundle app source

COPY . /usr/src/app

Expose port

EXPOSE 8080

CMD [ "npm", "start" ]

执行以下命令进行构建:

docker build -t zhangpc/docker_web_app:argon .

最终得到的镜像大小是 660.3 MB ,体积略大,Docker 容器的优势是轻量和可移植,所以承载它的操作系统即基础镜像也应该迎合这个特性,于是我想到了 Alpine Linux ,一个面向安全的,轻量的 Linux 发行版,基于 musl libcbusybox

下面我们使用 alpine:edge 作为基础镜像,镜像大小为 4.799 MB

Dockerfile.alpine

FROM index.tenxcloud.com/docker_library/alpine:edge

Install node.js by apk

RUN echo '@edge http://nl.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories

RUN apk update && apk upgrade

RUN apk add --no-cache nodejs-lts@edge

If you have native dependencies, you'll need extra tools

RUN apk add --no-cache make gcc g++ python

Create app directory

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

If your project depends on many package, you can use cnpm instead of npm

RUN npm install cnpm -g --registry= https://registry.npm.taobao.org

RUN cnpm install

Install app dependencies

COPY package.json /usr/src/app/

RUN npm install

Bundle app source

COPY . /usr/src/app

Expose port

EXPOSE 8080

CMD [ "npm", "start" ]

执行以下命令进行构建:

docker build -t zhangpc/docker_web_app:alpine .

最终得到的镜像大小是 31.51 MB ,足足缩小了20倍,运行两个镜像均测试通过。

4.1.2 还有优化的空间吗?

首先,大小上还是可以优化的,我们知道 Dockerfile 的每条指令都会将结果提交为新的镜像,下一条指令将会基于上一步指令的镜像的基础上构建,所以如果我们要想清除构建过程中产生的缓存,就得保证产生缓存的命令和清除缓存的命令在同一条 Dockerfile 指令中,因此修改 Dockerfile 如下:

Dockerfile.alpine-mini

FROM index.tenxcloud.com/docker_library/alpine:edge

Create app directory and bundle app source

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

COPY . /usr/src/app

Install node.js and app dependencies

RUN echo '@edge http://nl.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories \

&& apk update && apk upgrade \

&& apk add --no-cache nodejs-lts@edge \

&& npm install \

&& npm uninstall -g npm \

&& rm -rf /tmp/* \

&& rm -rf /root/.npm/

Expose port

EXPOSE 8080

CMD [ "node", "app.js" ]

执行以下命令进行构建:

docker build -t zhangpc/docker_web_app:alpine .

最终得到的镜像大小是 21.47 MB ,缩小了10M。

其次,我们发现在构建过程中有一些依赖是基本不变的,例如安装 Node.js 以及项目依赖,我们可以把这些不变的依赖集成在基础镜像中,这样可以大幅提升构建速度,基本上是秒级构建。当然也可以把这些基本不变的指令集中在 Dockerfile 的前面部分,并保持前面部分不变,这样就可以利用缓存提升构建速度。

最后,在构建生产环境镜像时可以设置 NODE_ENV 环境变量为 production ,可以提升应用的性能。

小结

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

我们构建的三个镜像大小对比见上图,镜像的大小越小,发布的时候越快捷,而且可以提高安全性,因为更少的代码和程序在容器中意味着更小的攻击面。使用 node:argon 作为基础镜像构建出的镜像(tag 为 argon)压缩后的大小大概为 254 MB ,也不是很大,如果对 Alpine Linux 心存顾虑的童鞋可以选用 Node.js 官方推荐的 node:argon 作为基础镜像构建微服务。

4.2 持续集成与快速部署

我们内部基于 k8s 和 docker 开发了一套 DevOps 解决方案,可以实现持续集成与快速部署,流程大体如下图:

DockOne微信分享( 九十二):如何使用 Node.js 和 Docker 构建高质量的微服务

开发团队提交应用程序代码后,会触发代码仓库的 webhook,从而触发构建节点的自动构建,然后构建节点构建成功后,将应用程序的镜像 push 到私有镜像的仓库,最后当 push 完成后触发自动部署。当然这只是最简单的场景,还有一些复杂的应用场景也是支持的。

5. Q&A

Q:请问 k8s的网络用的什么?

A:这个要看具体业务场景,简单的 Flannel , iptables 就够了

Q:请问外部访问服务和内部访问微服务方式是一样的吗?都是通过API Gateway的话,是否有性能压力?另外,对外暴露的服务要分配外网地址,纯内部服务只要内网地址,怎么区分?

A:内部用或者微服务之间访问可以通过 内网地址 访问,外部用绑定一个外网地址就可以了,考虑性能的话,可以通过 Nginx 等实现 Kong 的高可用

Q:感谢分享!我想问一下容器网络对微服务的影响,需要自定义网络吗?还是用k8s就可以了?有更好的方案吗?

A:在我们实践过程中,是没有自定义网络的,微服务之间通过 rest api 进行交互,对客户端通过 Kong 提供统一入口,然后用 k8s 的负载均衡就差不多了

Q:感谢分享! 能讲一下多个容器运行的时候日志是怎么收集汇总的吗?

A:这个有很多工具吧,fluentd,elasticsearch 都可以

Q:node.js和vue.js如何选择

A:童鞋,这两个是完全不同的东西,node.js 是后端,vue.js 是一个前端库,如果你非要选择,我选择 react

参考资料

* 《微服务实战(一):微服务架构的优势与不足》

* 《微服务选型之Modern Node.js》

* 《Node 出现 uncaughtException 之后的优雅退出方案》

* 《Express Error handling》

* 《Promise 迷你书》

* 《如何把 Callback 接口包装成 Promise 接口》

* 《Kong Docker Installation》

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。