Node.js 16 初体验

Node.js 16已经于4月20日发布,主要包括如下更新:

  • 升级V8引擎至9.0
  • 预构建的Apple silicon二进制文件以支持使用苹果芯片的Mac系统
  • 稳定部分API

详细更新日志可参考:https://nodejs.org/zh-cn/blog/release/v16.0.0/

Node.js发布时间表

可以在官网下载最新发布体验,或者使用版本管理工具(本文以跨平台的nvs为例)。

# 下载V16的Node.js
nvs add 16

# 在当前终端使用V16的Node.js(推荐)
nvs use 16
# 或者:设置全局默认的Node.js至V16
nvs link 16

查看node & npm版本

Node.js 16将会替代15并于接下来半年成为当前发布线(期间可能会有部分非紧要的代码更新),之后将于十月份进入长期支持(LTS)阶段并将该版本命名为Gallium。PS:V12会在2022年4月结束LTS阶段,V14为2023年4月,V10将于本月结束LTS阶段。

V8引擎的升级

每次V8引擎的升级都会带来性能的调整与提升以及支持最新的JavaScript语言特性,因此Node.js会常态化更新V8版本,Node.js 16使用V8 9.0(15使用的是V8 8.6)。

这次V8的升级带来了ECMAScript RegExp比对索引功能,能提供字符串的开始与结束索引。当正则表达式带有/d标签时,可以通过indices属性访问到索引数组。

RegExp match indice范例

更多V8更新请参考:https://v8.dev/

稳定Timers Promises API

Timers Promises API提供了可返回Promise对象的timer函数(无需再将timer通过util.promisify进行Promise化),该API于Node.js 15新增(本次发布将其从experimental状态更新为stable状态)。

import { setTimeout } from 'timers/promises';

async function run() {
  const res = await setTimeout(3000, 'fullFilledValue');
  console.log(`Get result=>${res} after 3s`);
}

run(); // 3s后输出:Get result=>fullFilledValue after 3s

其他特性

新的编译器与平台最低要求

Node.js针对不同的平台提供预构建的二进制文件,对于每个主要版本,在适当的情况下评估并提出最少的工具链。

Node.js v16.0.0是首个为apple芯片交付预构建二进制文件的版本,虽然将分别为Intel(darwin-x64)和ARM(darwin-arm64)架构提供压缩包,但macOS安装程序(.pkg)将作为“fat”(多架构)二进制文件提供。在基于Linux的平台上,构建Node.js 16的最低GCC级别将为GCC 8.3。详情请参考Node.js BUILDING.md文件

弃用

Node.js项目使用名为CITGM(Canary in the Goldmine)的工具,测试任何重大变化(包括弃用)对大量流行生态系统模块的影响,以在执行这些变化之前提供更多的参考。Node.js v16主要的弃用包括对多个核心模块内部执行process.binding()的运行时弃用,如process.binding(‘http_parser’)

云原生时代,拥抱微服务(二)—— 容器化应用

前文我们已经搭建好k8s集群,接下来就可以对部分项目进行容器化改造了。

“万丈高楼平地起,敲好代码是关键。” —— 皮皮叨叨

创建镜像文件Dockerfile

以Node.js应用为例:

FROM node:12-alpine

# install tzdata so that we can set timezone which default is UTC
RUN apk add tzdata
ENV TZ Asia/Shanghai

# set our node environment, either development or production
# defaults to production, can override this to development on build and run
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV

# default to port 3000 for node, and 9229 and 9230 (tests) for debug
ARG PORT=3000
ENV PORT $PORT
EXPOSE $PORT 9229 9230

# install dependencies first, in a different location for easier app bind mounting for local development
# due to default /opt permissions we have to create the dir with root and change perms
RUN mkdir /your-app && chown node:node /your-app
RUN mkdir /var/log/your-app && chown node:node /var/log/your-app
WORKDIR /your-app
# the official node image provides an unprivileged user as a security best practice
# but we have to manually enable it. We put it here so yarn installs dependencies as the same
# user who runs the app.
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
COPY --chown=node:node . .
RUN yarn install --production=true
ENV PATH /your-app/node_modules/.bin:$PATH

CMD [ "yarn", "start" ]

PS: .dockerignore文件可通过gitignore.io生成。

应用配置信息及密钥管理

普通配置信息

ConfigMaps是一种Kubernetes资源,允许你为容器保存配置信息,这些配置后续可以通过挂载为容器的环境变量或者文件进行访问。

# your-config.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: your-api
data:
  mongodb_host: your.mongodb.host
  mongodb_database: your-database-name

通过在k8s集群中使用kubectl apply命令创建上述命名为your-apiConfigMap资源(如果该资源已经存在,则进行更新动作)。

kubectl apply -f path-to/your-config

密钥等敏感信息

不像ConfigMapsSecrets是一种用于存放敏感信息的Kubernetes资源,一般可用于存放各类凭证、ssh密钥,TLS密钥及证书,或者其他在应用中需要使用的敏感数据。Secrets同样可以挂载为容器的环境变量或者文件。

Secrets被保存为base64编码的字符串,我们需要在Secrets声明文件中将对应敏感信息(如数据库用户名与密码)转换为base64格式。可使用如下命令:

echo -n "db-user-name" | base64
# output: ZGItdXNlci1uYW1l

echo -n "db-pwd" | base64
# output: ZGItcHdk

注意,上述命令输出后的base64编码字符串就是我们需要保持到Kubernetes中的Secrets信息。

# your-secrets.yaml
---
apiVersion: v1
kind: Secret
metadata:
  name: your-api
data:
  mongodb_username: ZGItdXNlci1uYW1l
  mongodb_password: ZGItcHdk

使用kubectl apply命令创建对应Secrets资源:

kubectl apply -f path-to/your-secrets.yaml

【温馨提示】base64编码的字符串并不是被加密的,需要将上述Secrets声明文件保存到足够安全的地方,并且防止泄露!!!建议在Kubernetes集群中统一进行管理。

为应用程序创建Deployment工作负载

工作负载(Workloads)资源可以理解为在Kubernetes中的应用程序,主要包括:DeploymentStatefulSetDaemonSetJobCronJob等类型。

Deployment主要用于扩展与更新无状态容器Pod,适用于我们无状态的应用程序。Deployment会自动创建副本集(ReplicaSet)资源,以用于替换失败的Pods,弹性扩展或减少Pods

# your-deployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: your-api
  labels:
    app: your-api-label
spec:
  replicas: 3
  selector:
    matchLabels:
      app: your-api-label
  template:
    metadata:
      labels:
        app: your-api-label
    spec:
      containers:
        - name: your-api
          image: your-api-image:1.0.0
          env:
            - name: MONGODB_HOST
              valueFrom:
                configMapKeyRef:
                  name: your-api
                  key: mongodb_host
            - name: MONGODB_DATABASE
              valueFrom:
                configMapKeyRef:
                  name: your-api
                  key: mongodb_database
            - name: MONGODB_USER
              valueFrom:
                secretKeyRef:
                  name: your-api
                  key: mongodb_username
            - name: MONGODB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: your-api
                  key: mongodb_password

使用kubectl apply命令创建对应Deployment资源:

kubectl apply -f path-to/your-depoyment.yaml

使用Service公开Deployment工作负载

我们还需要通过创建Service资源以更好地访问不同Pod中的应用程序,并且能做到负载均衡。这主要是由于Pod是短暂的,一旦由于某种原因被销毁,其对应的IP地址也会被吊销,然后新生的Pod将获得一个不同的IP地址。

# your-service.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: your-api
spec:
  type: ClusterIP
  selector:
    app: your-api-label
  ports:
    - protocol: TCP
      port: 80
      targetPort: http

使用kubectl apply命令创建对应Service资源:

kubectl apply -f path-to/your-service.yaml

总结

我们首先通过创建Dockerfile以用于生成应用程序的容器镜像,然后通过使用ConfigMapsSecrets资源管理应用程序的配置及敏感信息,最后再运用DeploymentService资源部署及公开应用程序。

微服务高楼的基石貌似已经搭好了,胜利的曙光就在眼前。但是机智且追求效率的你一定发现了:一个应用程序这样做还好,可如果有多个应用程序也需要容器化,都分别去声明各种Kubernetes资源岂不是很繁琐?你这个ClusterIP类型的Service似乎还无法从外网访问?

Node.js日志优化之使用RequestId关联日志

背景


现状

现有系统采用pino用于普通日志输出,采用pino-http用于输出服务器请求日志。

./src/createServer.js文件

基于Express创建Server。为了直观简洁,以下仅展示与log相关代码。

'use strict';

const express = require('express');
const logger = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');

const createServer = async () => {
  logger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestLogger)
  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/utils/logger.js

封装pino日志组件,所有日志输出均调用该组件。

'use strict';

const pino = require('pino');

const logger = pino()

module.exports = logger;

./src/middlewares/requestLogger.js*

封装服务器请求日志中间件pino-http,用于打印请求日志。

'use strict';

const pinoHttp = require('pino-http');
const uuidv4 = require('uuid/v4');
const logger = require('../utils/logger.js');

const requestLogger = pinoHttp({
  logger,
  // Have default ID which is increasing number per process, better use UUID
  // to avoid collection in server cluster
  genReqId: () => uuidv4(),
});

module.exports = requestLogger;

./src/middlewares/errorHandlers.js*

封装错误处理中间件,用于在最后统一处理请求错误。

'use strict';

module.exports = (err, req, res, next) => {
  req.log.error({
    context: 'errorHandler middleware',
    query: req.query,
    body: req.body,
    stack: err.stack.split('\n'),
  });
  const {
    errorCode,
    statusCode,
    name,
    message,
  } = err;
  res
    .status(statusCode)
    .json({
      errors: [
        {
          errorCode,
          name,
          message,
        },
      ],
    });
};

存在的问题

以上代码能满足基本的日志需求,直到有一天。

程序猿小程抱怨道:“哇,好蛋疼~,上周末ON CALL有问题让我DEBUG,看着成堆的日志查找了半天才发现是评论团队那边的XXXAPI出了问题。”

程序猿小序:“em,所有请求都要经过我们的API Gateway,同一时间的日志确实很多,我也感觉每次DEBUG挺耗时间的,而且每次有问题都会先找我们查找原因🤣。”

程序猿大猿:“我们的错误处理中间件会打印出该请求的错误信息,但有些中间过程产生的错误(如请求其他team的API)并不需要抛出到错误处理中间件处理,这些日志信息的确不太好找,以及在Services或者Drivers里面的日志。我们最好能将每个请求相关的所有日志都串起来,这样不就好排查问题了吗。”

运维小运:“Kibana对日志的搜索功能还是很强大的,而且Amazon CloudFront会给每个请求头添加唯一的ID,你们倒是可以通过对字段x-amz-cf-id进行搜索🤔。”

程序猿大猿:“嗯,那小程你负责去跟进下这个日志的优化问题。”

小程欣然地答应了。

使用RequestId关联日志


思考

“RequestId在request这个对象里面,要怎么使用RequestId关联现有的日志呢?”,小程思考了片刻,最先想到的方法是直接通过函数传值,于是他兴奋地开了个branch尝试去做。

改了几个文件后。。。

“不对啊,这样的方式也太蠢了吧!要在ControllersServicesDriversUtils......,几乎每个函数都加上requestId这个参数,而且这样写出来的代码即难看又冗余。但是不这么做,要如何做到呢?”,小程自言自语到。

小序不小心听到了小程的疑虑,也思考了片刻,“em~看来有点棘手啊,貌似只能从上下文(Context)这个角度入手”。

“嗯,对logger进行进一步的封装,使其拥有不断回溯上下文的能力,这样就可以溯源到request这个对象,进而获取RequestId打印出来。但具体要如何做呢?”,小程回答道。

“这种问题算是比较常见,坊间应该也有对应的方案,何不Google一下?”,小程继续补充到。

通过查阅相关资料,可以通过使用cls-rtracer,或者pino社区推荐的cls-proxify来实现。本文将使用cls-proxify实现。

实现

有了具体的实现思路后,小程兴奋的捣鼓起来。。。

./src/constants/log.js

新增常量便于后期维护

'use strict';

module.exports = {
  CLS_KEY: 'clsKeyLogger',
  REQUEST_ID: 'X-Request-Id',
};

./src/middlewares/reqeustId.js

requestId中间件,确保RequestId全局统一性。

'use strict';

const uuidv4 = require('uuid/v4');

const CF_HEADER_NAME = 'X-Amz-Cf-Id';
const { REQUEST_ID } = require('../constants/log');

module.exports = (req, res, next) => {
  req.id = req.headers[CF_HEADER_NAME.toLowerCase()] || uuidv4();

  res.setHeader(REQUEST_ID, req.id);
  next();
};

./src/createServer.js

使用requestId中间件

'use strict';

const express = require('express');
const logger = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');
const requestId = require('./middlewares/requestId');

const createServer = async () => {
  logger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestId); // use requestId middleware
  app.use(requestLogger)
  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/middlewares/requestLogger

requestLogger的生成的ID使用RequestId中间件生成的,确保统一。

'use strict';

const pinoHttp = require('pino-http');
const logger = require('../utils/logger.js');

const requestLogger = pinoHttp({
  logger,
  // Have default ID which is increasing number per process, better use UUID
  // to avoid collection in server cluster
  genReqId: (req) => req.id, // use req.id which generate from requestId middleware
});

module.exports = requestLogger;

应用cls-proxify,使得每个request都有相应的child logger。记得安装依赖:npm i cls-proxify cls-hooked

./src/createServer.js

'use strict';

const express = require('express');
const { clsProxifyExpressMiddleware } = require('cls-proxify/integration/express');
const { defaultLogger } = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');
const requestId = require('./middlewares/requestId');
const { CLS_KEY, REQUEST_ID } = require('./constants/log');

const createServer = async () => {
  defaultLogger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestId); // use requestId middleware
  app.use(requestLogger);

  // Integrate pino with CLS.For creating dynamically configured child loggers for each request.
  // NOTE: Put any third party middleware that does not need access to requestId before it.
  // Details see: https://github.com/keenondrums/cls-proxify
  app.use(
    clsProxifyExpressMiddleware(CLS_KEY, req => defaultLogger.child({ [REQUEST_ID]: req.id })),
  );

  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/utils/logger.js

'use strict';

const pino = require('pino');
const { clsProxify } = require('cls-proxify');
const { CLS_KEY } = require('../constants/log');

const defaultLogger = pino(config.get('logger'));
const logger = clsProxify(CLS_KEY, defaultLogger);

module.exports = {
  defaultLogger, // default logger, won't print RequestId
  logger, // logger with CLS feature, will print RequestId if has.
};

大功告成。。。

本地测试通过后,小程兴奋的提了个PR,并注明:

此PR的一些参考资料:

https://itnext.io/request-id-tracing-in-node-js-applications-c517c7dab62d https://nodejs.org/api/async_hooks.html pinojs/pino#601 https://github.com/keenondrums/cls-proxify#live-demos

大猿review了这个PR,Approve并评论:

Nice Job! 基于我们这个Api gateway的特点,好的日志是很有必要的。 至于性能方面,我们有做Cache,并且Devops在Amozon ClouldFront也做了CDN CACHE,所以影响暂时不会很大。

小程merge这个branch,等CI部署成功后,到Kibana上看了看日志,嘴角微微上扬,喝起了82年的卡布奇洛。

kibana

实现到此已告一段落,对原理感兴趣的读者可以继续往下阅读。以下仅供参考,如有错误的地方,欢迎指正。

追根溯源

纸上得来终觉浅,小程不满足于仅仅停留在用完即弃的层面,想要进一步探究其来龙去脉。

在许多其他语言及平台(如Java Servlet container),HTTP服务器是基于多线程的架构及阻塞I/O,这使得它们天然就可以将特定请求所在的线程标识用于跟踪该请求的日志信息。另外,也可以通过使用Thread-local-storage(TLS)以键值对的方式存储/获取当前线程关联的上下文。在我们这个例子中,可以用TLS存储每个请求的ReqeustId或者其他诊断信息。很多的日志框架会默认内置TLS这个特性。

那为什么在Node.js的世界不可以这样做呢?小程思考到。

由于Node.js基于事件循环的异步特性,TLS自然派不上用场,毕竟JS代码是在一个主线程处理的。

“虽然Node.js没有类似TLS的API,但我们也会有类似的需求,该怎么办呢?似乎只能从Context Object(上下文对象)这块入手。”,小程继续思索到。

其实Node.js社区很早前就注意到了这个问题,最终提出了continuation-local-storage(CLS)的概念。CLS就像线程编程中的TLS,但其基于Node.js的回调链而不是线程。起初CLS基于使用process.addAsyncListener API,直到Node.js v0.12该API不可用,之后是通过使用该API的polyfill async-listener来实现。由于这个polyfill做了很多封装内置Node API的猴子补丁,所以不太推荐使用。

幸运的是,CLS库衍生出了后续版本cls-hooked,使用了Node.js v8.2.1后内置的API async_hooks。尽管async_hooks仍然处于试验阶段,但其仍然比使用猴子补丁CLS要好些。

cls-hooked是挺好的,能解决回溯context的问题,但是我还是需要做大量的工作封装并将其集成进express以及pino中,不知有没有以及做好这些工作的库?”。小程百思不得其解,直到找到pino社区开发的cls-proxify

cls-proxify基于cls-hooked并使用了代理模式,可以为每个请求创建一个包含动态上下文的child logger,原理如下图:

how cls-proxify works

“探索知识的过程真的是非常有趣呢!”,小程心想,愉快地将剩下的卡布奇诺一饮而尽。