南十 | 基于 k8s + NodeJS 的 webSocket

2,833 阅读8分钟

稿定设计导出-20200214-172038.png

知识点补充

什么是 k8s ?

Kubernetes 是一个用于 容器集群 的自动化部署、扩容以及运维的开源 平台


k8s诞生的目的

k8s 孕育的初衷是培育出一个组件及工具的生态,帮助大家减轻在公有云及私有云上运行应用的负担,换言之,使得大型分布式应用的构建和运维变得更加简单(当然,越简单的表面意味着越复杂的内部细节)。

什么是 Websocket ?

WebSocket 建立在 TCP 协议之上,并且与 HTTP 协议有着良好的兼容性,最大的特点就是服务器可以主动向客户端推送消息,客户端也可以主动向服务器发送消息

开始搭建一个nodejs 服务端项目

框架选型

image.png

再结合自身项目,最终选用了 koa。原因有以下几点:

  • koa 既适用小型项目,又适用大型项目,为以后项目扩展做好准备。
  • koa 有着丰富的中间件,比 Express 更强大,比 Egg 更简易。
  • Koa 提供了更优于 Express 的错误处理。
  • midway 在之前就有关注,但到今年 1 月份才发布 1.0 版本,持续关注吧,业务项目就先不用它了。

小白都是先写个 app.js,没毛病,这里是我琢磨出的可用的初版,未做用户身份认证。

// app.js
import "babel-polyfill";
import Koa from "koa2";
import route from "koa-route";
import websockify from "koa-websocket";
import isEmpty from './public/isEmpty.js';
import logger from './public/logger.js';
import indexRouter from './routes/index';
import monitoringRouter from './routes/Monitoring';

const app = websockify(new Koa());
/**
 * 注册路由
 */
app.use(indexRouter.routes(), indexRouter.allowedMethods())
app.use(monitoringRouter.routes(), monitoringRouter.allowedMethods())

/**
 * websocket 部分
 */
let ctxs = {};
app.ws.use(
    route.all("/:type/:from", (ctx, next) => {
        /* 获取用户信息 */
        let query = ctx.request.query;
        if (isEmpty(query) || isEmpty(query.type) || isEmpty(query.from)) {
            ctx.websocket.close(500, '非法请求');
            logger.logInfo('', 'websocket close', '非法请求', JSON.stringify(query));
        }
        let type = query.type;
        let from = query.from;
        logger.logInfo(from, 'websocket open', 'type', type);

        if (isEmpty(ctxs[type])) {
            ctxs[type] = {};
        }
        ctxs[type][from] = ctx;

        /* 接收到消息并转发 */
        ctx.websocket.on("message", message => {
            if (type == "from") {
                if (!isEmpty(ctxs["to"]) && !isEmpty(ctxs["to"][from])) {
                    ctxs["to"][from].websocket.send(message);
                    logger.logInfo("给" + from, 'websocket message', '发送了:', message);
                }
            } else if (type == "to") {
                if (!isEmpty(ctxs["from"]) && !isEmpty(ctxs["from"][from])) {
                    ctxs["from"][from].websocket.send(message);
                    logger.logInfo("给" + from, 'websocket message', '发送了:', message);
                }
            }
        });
        /* 连接关闭时的处理 */
        ctx.websocket.on("close", message => {
            /* 连接关闭时, 清理 上下文数组, 防止报错 */
            let hasFind = false;
            for (const key in ctxs) {
                const item = ctxs[key];
                for (const itemKey in item) {
                    const user = item[itemKey];
                    if (ctx == user) {
                        delete ctxs[key][itemKey];
                        logger.logInfo(key, 'websocket close', '-', itemKey);
                        hasFind = true;
                        break;
                    }
                }
                if (hasFind) {
                    break;
                }
            }
            logger.logInfo('close', 'websocket close', 'end', message);
        });
    })
);

/**
 * 监听
 */
app.listen(3000);

image.png

优化项目基础配置

使用 import 代替 require

nodejs 对 es6 还不是完全的支持,使用 imort 的方法也有很多,例如使用 mjs 后缀就可以,我这里还是用了 babel 去做了处理。

新增一个 start.js (并安装 babel-polyfill(放在dependencies中)/babel-preset-env/babel-register(放在devDependencies中)),内容如下:

// start.js
require('babel-register')({
    presets: ['env']
})

module.exports = require('./app.js')

在app.js 中使用 import "babel-polyfill"; 引入 babel-polyfill。

log 输出到文件

新增 log.js 文件,可以将 koa 运行中的项目日志输出到 access.log 中。再结合 tail -f access.log cat access.log快速查询日志。

// log.js
import { config } from '../config/config.js';
import fs from 'fs';

let options = {
    flags: 'a',     // append模式
    encoding: 'utf8',  // utf8编码
};

// 添加format方法
Date.prototype.format = function (format) {
    if (!format) {
        format = 'yyyy-MM-dd HH:mm:ss';
    }

    // 用0补齐指定位数
    let padNum = function (value, digits) {
        return Array(digits - value.toString().length + 1).join('0') + value;
    };

    // 指定格式字符
    let cfg = {
        yyyy: this.getFullYear(),             // 年
        MM: padNum(this.getMonth() + 1, 2),        // 月
        dd: padNum(this.getDate(), 2),           // 日
        HH: padNum(this.getHours(), 2),          // 时
        mm: padNum(this.getMinutes(), 2),         // 分
        ss: padNum(this.getSeconds(), 2),         // 秒
        fff: padNum(this.getMilliseconds(), 3),      // 毫秒
    };

    return format.replace(/([a-z]|[A-Z])(\1)*/ig, function (m) {
        return cfg[m];
    });
}

let logTime = new Date().format('yyyy-MM-dd');

let stdout = fs.createWriteStream(config.logDir + 'access.' + logTime + '.pipe', options);
let stderr = fs.createWriteStream(config.logDir + 'error.' + logTime + '.log', options);

// 创建logger
let logger = new console.Console(stdout, stderr);


logger.logInfo = (user = '', func = '', title = '', msg = '') => {
    let time = new Date().format('yyyy-MM-dd HH:mm:ss.fff');

    let str = `[${user}] [${func}] [${title}] [${msg}]`;
    logger.info(`[${time}] - INFO - ${str}`);
}
export default logger;


使用方法:

logger.logInfo('-','login','-',JSON.stringify(result));

image.png

添加 MySQL

在搭建 MySQL 时,要注意 MySQL 连接不释放的问题。
现象:www.cnblogs.com/hibiscus-be…

后面通过看 mysql/PoolConnection.js 源码解决,网上很多 MySQL 的 Demo 都会出连接不释放的问题。

image.png

// mysql.js
import mysql from 'mysql';
import { config } from '../config/config.js';
import logger from './logger';
class Mysql {
    constructor() {
    }
    create(configName) {
        return mysql.createPool({
            host: config[configName].host,
            user: config[configName].username,
            password: config[configName].password,
            database: config[configName].dbname
        });
    }
    query(dbName, sql, values) {
        return new Promise((resolve, reject) => {
            let poolName = '';
            switch (dbName) {
                case 'db_name1': {
                    poolName = 'db1';
                } break;
                case 'db_name2': {
                    poolName = 'db2';
                } break;
                default: { resolve([]); } break;
            }
            if (poolName == '') {
                resolve([]);
                return;
            }
            let pool = this.create(poolName);
            pool.getConnection((err, connection) => {
                if (err) {
                    logger.logInfo('-', 'err', 'connection', JSON.stringify(err));
                    reject(err)
                } else {
                    connection.query(sql, values, (err, rows) => {
                        if (err) {
                            logger.logInfo('-', 'err', 'query', JSON.stringify(err));
                            reject(err)
                        } else {
                            logger.logInfo('-', 'rows', 'query', JSON.stringify(rows));
                            resolve(rows)
                        }
                        connection.release();
                    })
                }
            })
        })
    }
}
let DoSql = new Mysql();
export default DoSql;

使用方法:

let sql = "SELECT `p_id` FROM `user_information_table` WHERE `p_name` = ?";
let result = await DoSql.query('db2', sql, ['小雅xx']);

配置 Redis

redis 是我们经常使用的缓存手段之一。

// redis.js
import { config } from '../config/config.js';
import redis from 'redis';


var RDS_PORT = config['redis'].port;     //端口号
var RDS_HOST = config['redis'].ip;

var RDS_OPTS = {
    auth_pass: config['redis'].auth,
    db: config['redis'].db,
};
var client = redis.createClient(RDS_PORT, RDS_HOST, RDS_OPTS);

function redisHGet(map, key) {
    return new Promise((resolve, reject) => {
        client.hget(map, key, function (err, res) {
            if (err) {
                reject(err);
            } else {
                resolve(res);
            }
        });
    })
}
.......略

使用方式:

let nowTime = new Date().getTime();
await redisHSet('socket', 'readnesstime', nowTime);
let mapValue = await redisHGet('socket', 'readnesstime');

在线上运行

image.png

首先,得有一个k8s集群,找了个文档,由于这里我们已经有了集群,所以直接下一步。
blog.csdn.net/u010039418/…

创建一个 node + tengine + centos 的基础镜像


这个镜像是基于 centos 的,所以我们只要再将 node 和 tengine 的环境搭出来就可以了。

FROM centos:7.6.1810
ENV TZ=Asia/Shanghai
COPY source/node-v10.13.0-linux-x64.tar.gz /home/aiyong/source/
#set -ex作用就是,当下面的命令执行出错后,就退出执行,不在继续往下执行。
RUN set -x \
           \
        && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
        && yum install -y wget \
        && mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup \
        && wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.cloud.aliyuncs.com/repo/Centos-7.repo \
        && wget -O /etc/yum.repos.d/epel.repo http://mirrors.cloud.aliyuncs.com/repo/epel-7.repo \
        && yum clean all \
        && yum makecache \
        && yum update -y \
        && yum install -y \
                git gcc automake autoconf libtool make postgresql-devel \
                pcre-devel openssl openssl-devel readline-devel bind-utils traceroute mlocate lrzsz openssl openssl-libs \
                openssl-devel unzip screen optipng libjpeg-turbo-utils gifsicle libmcrypt libmcrypt-devel xvfb  libXfont  \
                libxml2 libxml2-devel libcurl libcurl-devel libjpeg-turbo libjpeg-turbo-devel libpng libpng-devel bison \
                logrotate supervisor psmisc net-tools ImageMagick xorg-x11-server-Xvfb.x86_64 pango-devel  libldap freetype \
                freetype-devel openldap openldap-devel re2c file glibc-static libstdc++-static cyrus-sasl-plain cyrus-sasl \
                cyrus-sasl-devel  cyrus-sasl-lib nscd \
        && yum clean all \
        && mkdir -p /home/aiyong/source \
        # 安装 tengine 环境
        && cd /home/aiyong/source \
        && git clone https://github.com/simpl/ngx_devel_kit \
        # && git clone https://github.com/openresty/lua-nginx-module \
        && git clone https://github.com/openresty/set-misc-nginx-module \
        && git clone https://github.com/openresty/redis2-nginx-module \
        && git clone https://github.com/openresty/echo-nginx-module \
        && git clone https://github.com/openresty/ngx_postgres \
        && wget http://tengine.taobao.org/download/tengine-2.3.0.tar.gz \
        && tar zxvf tengine-2.3.0.tar.gz \
        && cd tengine-2.3.0 \
        && ./configure --prefix=/usr/local/webserver/tengine --pid-path=/usr/local/webserver/tengine/nginx.pid --with-http_stub_status_module  --with-ld-opt=-Wl,-rpath,/usr/lib --with-http_ssl_module  --with-http_v2_module --add-module=/home/aiyong/source/ngx_devel_kit --add-module=/home/aiyong/source/redis2-nginx-module --add-module=/home/aiyong/source/echo-nginx-module --add-module=/home/aiyong/source/set-misc-nginx-module  --with-ipv6 --add-module=/home/aiyong/source/ngx_postgres \
        # --add-module=/home/aiyong/source/lua-nginx-module
        && make -j2 \
        && make install \
        # 安装nodjs 环境
        && cd /home/aiyong/source \
        && tar zxvf node-v10.13.0-linux-x64.tar.gz \
        && cd node-v10.13.0-linux-x64 \
        && ln -s /home/aiyong/source/node-v10.13.0-linux-x64/bin/node /usr/local/bin \
        && ln -s /home/aiyong/source/node-v10.13.0-linux-x64/bin/npm /usr/local/bin \
        && node -v \
        # 这里输出了那就是成功了
        && echo "alias ll='ls -l'" >> /root/.bashrc \
        && echo "alias vim='vi'" >> /root/.bashrfc
RUN set -x \
           \
        && mkdir -p /data/syslog/ \
        && echo "1" > /data/syslog/nodjs.log \
        && groupadd -f nginx \
        && useradd -s /sbin/nologin -g nginx nginx \
        && mkdir /var/log/nginx \
        && chown nginx.nginx /var/log/nginx \
        && chmod a+w /var/log \
        && chmod a+w /var/log/nginx \
        && cp /etc/cron.daily/logrotate /etc/cron.hourly

#logrotate是个十分有用的工具,它可以自动对日志进行截断(或轮循)、压缩以及删除旧的日志文件
COPY biz /etc/logrotate.d/
#nignx 配置
COPY nginx.conf  /usr/local/webserver/tengine/conf/
EXPOSE 80 443

#CMD ["supervisord" ,"-n", "-c", "/home/supervisord.conf"]
CMD ["/sbin/init"]

基础镜像搭好后,创建 nodejs 项目的 Dockerfile。

FROM node-tengine-centos:latest

WORKDIR /data/srv/{projectName}
COPY vhost /usr/local/webserver/tengine/conf/vhost/
COPY code /data/srv/{projectName}/
COPY block /usr/local/webserver/tengine/conf/block/
COPY conf /data/conf/
COPY nginx.conf /usr/local/webserver/tengine/conf/

RUN mkdir -p /data/srv/{projectName}/logs \
        && yum install -y logrotate supervisor psmisc net-tools \
        && chmod 777 /data/srv/{projectName}/logs \
        && mkdir -p /var/log/nginx \
        && chown nginx.nginx /var/log/nginx \
        && chmod a+w /var/log \
        && chmod a+w /var/log/nginx \
        && cd /data/srv/{projectName}/
        && npm install 

EXPOSE 80
CMD ["supervisord" ,"-n", "-c", "/home/supervisord.conf"]

补充知识点

Supervisor(supervisord.org/)是用 Python 开发的一个 client/server 服务,是 Linux/Unix 系统下的一个进程管理工具,不支持 Windows 系统。它可以很方便的监听、启动、停止、重启一个或多个进程。用 Supervisor 管理的进程,当一个进程意外被杀死,supervisor 监听到进程死后,会自动将它重新拉起,很方便的做到进程自动恢复的功能,不再需要自己写 shell 脚本来控制。

这里的 conf 文件也是需要加上启动 node 项目的命令:

[supervisord]
nodaemon=true
logfile=/tmp/supervisord.log ;

[program:nginx]
command=/usr/local/webserver/tengine/sbin/nginx  -g "daemon off;"
priority=100
stopsignal=QUIT
autostart=true ;
autorestart=true ;

[program:npm]
command=npm run start
autostart=true ;
autorestart=true ;

再来个简易的 nginx 配置:

server {
    listen 80;
    server_name x.x.com;
    resolver x.x.x.x;
    resolver_timeout 30s;
  
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Headers Content-Type;
    add_header Access-Control-Allow-Methods GET,POST;
    add_header Access-Control-Allow-Credentials true;
    error_log /var/log/nginx/error.log error;
    access_log off;
    client_max_body_size   4M;
    location / {
        proxy_pass    http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

deployment.yml:

deployment是用于管理pod的抽象层,它的定位类似于docker-compose。

k8s一个很巧妙的地方在于它把deployment层设计成“过程无关”的,你只需要声明你所期望的最终状态,k8s将会自动为你调度pod并保证它们满足你的预期。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {k8sName}-deployment
  namespace: xxxx #命名空间
spec:
  template:
    metadata:
     labels:
       app: {k8sName}
       logisticType: consumer
    spec:
      dnsPolicy: "None"
      dnsConfig:
        nameservers:
          - x.x.x.x
          - x.x.x.x
        options:
        - name: timeout
          value: "1"
        - name: attempts
          value: "2"
        - name: rotate
        - name: single-request-reopen
      imagePullSecrets:
      - name: registry-secret
      containers:
      - image: x.x.com/{dockerName} #镜像名
        imagePullPolicy: Always
        name: {k8sName}
        env:
        - name: SLS_LOG_TYPE
          value: "socket-tengine-node"
        resources:
					requests:
            cpu: 0.5
            memory: "500Mi"
          limits:
            cpu: 4
            memory: "12Gi"
---
apiVersion: v1
kind: Service
metadata:
  name:  {k8sName}-service
  labels:
    name:  {k8sName}-service
  namespace: xxxx #命名空间
spec:
  type: ClusterIP
  ports:
  - port: 80
    name: {k8sName}-http
    targetPort: 80
 selector:
    app: {k8sName}
---
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: {k8sName}-hpa
  namespace: xxxx #命名空间
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {k8sName}-deployment
  minReplicas: 1
  maxReplicas: 1
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 600
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: {k8sName}-ingress
  namespace: xxxx #命名空间
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
- host: x.x.com   #此service的访问域名
    http:
      paths:
        - path: /
          backend:
            serviceName: {k8sName}-service
            servicePort: 80
---

image.png