星球的眼睛 发表于 2024-6-14 21:47:51

【云计算】基于AWS和Node.js的作业提交体系

简介



[*]application: Node.js的Express框架
[*]custom ami:Packer
[*]AWS和GCP资源:Pulumi-iac(js)
[*]domain:AWS Route53
[*]email:Mailgun
[*]Lambda函数:Node.js
流程

webapp哀求处理流程

用户发送POST哀求到:http://mydomain.tld/v1/assignments/:id/submission

[*]域名配置A record指向AWS中的load balancer,load balancer将哀求转发到EC2实例中webapp运行的端口上
[*]webapp运行在AWS EC2上,在cloud watch上监控CPU使用率,通过auto scaling调解EC2实例数目
[*]webapp与RDS通信处理数据库变更和查询
[*]webapp写入log和自定义metrics,cloud watch监控logs和metrics
[*]webapp向SNS发送包括用户邮箱和提交url的message
[*]SNS调用Lambda函数
[*]Lambda函数中从用户提交的url中下载zip然后上传到GCP的bucket
[*]Lambda函数中使用mailgun的api发送邮件到用户告知提交状态,并在DynamoDB中记录邮件发送
体系构建流程


[*]注册域名并为域名添加email service(Mailgun / AWS SES)
[*]使用Packer创建配置好webapp和cloud-watch-agent主动运行的custom ami
[*]使用Pulumi创建AWS和GCP资源
Web applicaiton

User story


[*]用户可以新建assignment,指定assignment的可提交次数和deadline。
[*]用户可以查看所有assignments,或者按id查看指定assignment。
[*]用户可以修改自己创建的assignment。
[*]用户可以删除自己创建的assignment。
[*]用户可以提交submission到指定assignment。
[*]用户可以收到提交状态的邮件反馈。
数据库

使用mariadb
创建database

const mariadb = require('mariadb');
const mariaPool = await mariadb.createPool({
        host: "DATABASE_HOST",
        user: "DATABASE_USER",
        password : "DATABASE_PASSWORD"
});
const conn = await mariaPool.getConnection();
await conn.query(`CREATE DATABASE IF NOT EXISTS ${"DATABASE_NAME"}`);
conn.end(); 使用Sequelize连接mariadb

const Sequelize = require('sequelize');
const mariadb = require('mariadb');
const db = new Sequelize("DATABASE_NAME","DATABASE_USER","DATABASE_PASSWORD",{
    host: "DATABASE_HOST",
    port:3306,
    dialect:"mariadb",
    pool:{
        max:5,
        min:0,
        idle:10000 
    },
    debug:true, 
}) 注:创建 Sequelize 实例时,该操作自己并不立即建立数据库连接,只是配置 Sequelize 实例,准备与数据库连接的相干参数。

Tables布局

使用sequelize.define()方法定义表布局。
使用sequelize.sync()方法创建或更新({alter: true})定义的表。
   UserKeyidfirst_namelast_nameemailpasswordTypeUUID STRING()
STRING()
STRING()
STRING()
#
   AssignmentKeyid user_id
namepoints num_of_attemps
deadline
TypeUUIDUUID STRING()
INTEGER
INTEGER
DATE
 
   SubmissionKeyid assignment_id
user_id submission_url
TypeUUIDUUIDUUID STRING()


注:创建 Sequelize 实例时,该操作自己并不立即建立数据库连接,只是配置 Sequelize 实例,准备与数据库连接的相干参数。
注:UUID的范例为sequelize.DataTypes.UUID,默认值为sequelize.DataTypes.UUIDV4。
导入user信息

将存储在csv文件中的用户信息导入到数据库表,存储加密后的密码。
const fs = require('fs');
const parse = require('csv-parser');
const bcrypt = require('bcrypt');
const salt = bcrypt.genSaltSync(10);

fs.createReadStream("CSV_FILE_PATH")
    .pipe(parse())
    .on('data', async (row) => {
      try {
            const hashPass = bcrypt.hashSync(row.password, salt);
            const = await User.findOrCreate({
                where:{
                first_name: row.first_name,
                last_name: row.last_name,
                email: row.email
            },defaults:{
                password: hashPass
            }})         
      } catch (error) {
            console.log(error);
      }
    }) Functions

getUser

根据哀求header中的Basic Authorization信息,从User表中获得该user的UUID。


[*]如果header中没有Basic Auth信息或者密码不正确,response发送401 Unauthorized,返回0;
[*]通过验证,返回user的UUID。
checkContent

检查哀求body中的所需字段是否存在和范例正确。


[*]如果url不正确,body中须要的字段缺失或者范例不正确,response发送400 Bad Request,返回false;
[*]通过检查返回true。
Api接口

GET /healthz

检查数据库连接
try{
    db.authenticate();      //sequelize.authenticate
    res.status(200).send(); //200 OK
} catch(e){
    res.status(503).send(); //503 Service Unavailable
} GET /v1/assignments

查看所有assignment,返回一个list
try{
    const userId = await getUser(basicAuth(req),res); //检查Authorization
    if(userId == 0 || !await checkContent(req,res)){//如果Authorization没通过或者请求body格式不对
      return;
    }else{
      const result = await Assignment.findAll();
      res.status(200).json(result);                //200 OK
    }      
} catch(e){
    res.status(503).send();                        //503 Service Unavailable
} GET /v1/assignments/:id

查看id对应的assignment
try{
    const userId = await getUser(basicAuth(req),res); //检查Authorization
    if(userId == 0 || !checkContent(req,res)){      //如果Authorization没通过或者请求body格式不对
      return;
    }else{
      const result = await Assignment.findOne({
            where: {
                id:req.params.id                      //根据id查找assignment
            }
      });
      if(result == null){   
            res.status(404).send();                   //404 Not Found
      }
      else{
            res.status(200).json(getResult(result));//200 OK
      }            
    }      
} catch(e){
    res.status(503).send();                           //503 Service Unavailable
} POST  /v1/assignment

创建assignment
try {
    const userId = await getUser(basicAuth(req),res);      //检查Authorization
    if(userId == 0 || !checkContent(req,res)){               //如果Authorization没通过或者请求body格式不对
      return;
    }else{
      Assignment.create({
            user_id: userId,
            name: req.body.name,
            points: req.body.points,
            num_of_attemps: req.body.num_of_attemps,
            deadline: req.body.deadline               
      }).then((created) => res.status(201).json(created)); //201 Created
    }
} catch (e) {
    if (e.name === 'SequelizeUniqueConstraintError') {
      res.status(409).json(result);                        //409 Conflict
    } else {
      res.status(503).send();                              //503 Service Unavailable
    }                           
} 注:定义模型的时间name字段设置为unique,如果试图创建名字一样的assignment,会返回sequelize的错误。
POST /v1/assignment/:id/submission

向id对应的assignment举行提交
try{
    const userId = await getUser(basicAuth(req),res);          //检查Authorization
    if(userId == 0){
      return;
    }else if(!req.body.submission_url){                        //检查body字段
      logger.info(`No submission content.`);
      res.status(400).send();                              //400 Bad Request
    } else{
      const result = await Assignment.findOne({            //找到id对应的assignment
            where: {
                id:req.params.id
            }
      });
      if(result == null){
            res.status(404).send();                            //404 Not Found
      }
      else{
            const now = new Date();
            if(now > result.deadline){                         //提交时间晚于deadline
                res.status(403).send();                        //403 Forbidden
            }else{
                const subs = await Submission.findAll({where: {//得到该user在该assignment上的所有提交
                  assignment_id:req.params.id,
                  user_id: userId,
                }});
                if(subs.length == result.num_of_attemps){      //如果达到最大可提交次数
                  res.status(403).send();                  //403 Forbidden
                }else{
                  Submission.create({                        //201 Created
                        assignment_id: req.params.id,
                        user_id: userId,
                        submission_url: req.body.submission_url,
                  }).then((created) => res.status(201).json(getResult(created)));
                }
            }
      }            
    }      
} catch(e){
    res.status(503).send();                                    //503 Service Unavailable
} PUT /v1/assignment/:id

修改assignment
try {
    const userId = await getUser(basicAuth(req),res);//检查Authorization
    if(userId == 0 || !checkContent(req,res)){         //如果Authorization没通过或者请求body格式不对
      return;
    }else{
      Assignment.findOne({                           //根据id查找要修改的assignment
            where: {
                id: req.params.id,
            }
      }).then((result) => {
            if(result == null){         
                res.status(404).send();                //404 Not Found
            }
            else if(result.user_id == userId){         //如果是请求用户创建的assignment,有修改权限
                Assignment.update({
                  user_id: userId,
                  name: req.body.name,
                  points: req.body.points,
                  num_of_attemps: req.body.num_of_attemps,
                  deadline: req.body.deadline
                },
                {
                  where: {
                        id: req.params.id
                  }
                }).then(ok => res.status(204).send()); //204 No Content
            }
            else{
                res.status(403).send();                //403 Forbidden   
            }
      });
    }
} catch (e) {
    res.status(503).send();                            //503 Service Unavailable
} DELETE /v1/assignment/:id

删除该id对应的assignment
try {
    const userId = await getUser(basicAuth(req),res);//检查Authorization
    if(userId == 0 || !checkContent(req,res)){         //如果Authorization没通过或者请求body格式不对
      return;
    }else{
      await Assignment.findOne({                     //根据id找到要删除的assignment
            where: {
                id:req.params.id
            }
      }).then((result) => {
            if(result == null){
                res.status(404).send();                //404 Not Found
            }
            else if(result.userId == userId){          //assignment是请求用户创建的,有删除权限
                Assignment.destroy(
                {
                  where: {id: req.params.id}   
                }).then(ok =>res.status(204).send());//204 No Content
            }
            else{
                res.status(403).send();                //403 Forbidden
            }
      });
    }
} catch (e) {
    res.status(503).send();                            //503 Service Unavailable
} 其他哀求



[*]对于其他method好比PATCH,返回405 Method Not Avaliable。
[*]对于其他哀求url,返回400 Bad Request。
Test

使用mocha举行api测试
const request = require('supertest');
const app = require('./app'); // my webapp in app.js
const chai = require('chai');
const expect = chai.expect;
const describe = require('mocha').describe;

describe('GET /healthz', () => {
    it('should return a 200 status code if the MySQL connection is healthy', (done) => {
      request(app)
      .get('/healthz')
      .expect(200)
      .end((err, res) => {
          if (err) return done(err);
          done();
      });
    });
}); Packer

Packer作用

使用Packer创建一个可以主动运行webapp的ami。


[*]source ami:Debian 12
[*]subnet id:该地域中默认vpc的一个public subnet
[*]build:

[*]在本地打包webapp的所有文件到zip
[*]将zip传入
[*]安装unzip、node和amazon-cloudwatch-agent
[*]创建一个user用来运行webapp
[*]将webapp的zip解压到创建的user的home目录
[*]将配置webapp主动运行的webapp.service文件移动到/lib/systemd/system/下
[*]设置webapp和amazon-cloudwatch-agent开机自启(sudo systemctl enable webapp)
[*]新建cloudwatch配置文件中要监控的log文件并配置权限
Packer指令

其中fmt如果不加-check会主动改正,加-check如果有格式错误会直接报错。
packer init ami.pkr.hcl
packer fmt -check ami.pkr.hcl
packer validate ami.pkr.hcl
packer build ami.pkr.hcl GitHub Workflow

从forked堆栈向主堆栈提交pull request时,workflow包罗对于packer格式的检查,如果检查不通过将拒绝merge。
on:
pull_request_target:
    types:
      - opened
      - synchronize merge到主堆栈后,触发workflow举行api测试,创建webapp的artifact并创建ami
on:
push:
    branches:
      - main Domain

在AWS Route53中申请域名,并创建Hosted Zone。
之后会在Pulumi中为域名添加A record,指向webapp地点的ec2实例,这样就可以从域名访问webapp而不须要每次使用新的ip地点。
Mailgun(AWS SES)

使用mailgun配置应用程序邮件服务,将注册的domain添加到mailgun,然后将须要添加的记录在Hosted Zone中创建,通过verify之后即可使用mailgun的api。
const formData = require('form-data');
const Mailgun = require('mailgun.js');
const mailgun = new Mailgun(formData);

const mg = mailgun.client({username: 'api', key: "MY_API_KEY"});
mg.messages.create('sandbox-123.mailgun.org', {
      from: "Excited User <mailgun@MY_DOMAIN>",
      to: ,
      subject: "Hello",
      text: "Testing some Mailgun awesomeness!",
      html: "<h1>Testing some Mailgun awesomeness!</h1>"
    })
    .then(msg => console.log(msg)) // logs response data
    .catch(err => console.log(err)); // logs any error AWS CloudWatch

使用cloudwatch监控webapp的log和自定义matrics统计api调用。
cloudwatch的配置可以在终端举行也可以直接写入cloudwatch-config.json文件,在终端配置也是生成文件,如果该配置文件不存在则无法运行cloud-watch-agent。
const StatsD = require('hot-shots');
const { createLogger, format, transports } = require('winston');
const logger = createLogger({
    format: format.combine(
      format.timestamp({
      format: 'YYYY-MM-DD HH:mm:ss'
      }),
      format.json()
    ),
    transports: [
      new transports.File({ filename: '/var/log/myLog.log' })
    ]
});

const statsd = new StatsD({
    port: 8125,
    errorHandler: (error) => {
      logger.info("StatsD error: ", error);
    }
});

statsd.increment(`api_call.${req.method}.${req.path}`);
logger.info("log content"); Lambda函数

lambda函数由sns调用,负责从用户提交的url链接下载文件然后上传到gcp的bucket中,邮件告知用户下载状态,并将邮件发送状态存储到DynamoDB。
AWS & Pulumi

AWS资源是通过Pulumi摆设的,Pulumi 是一种开源的基础设施即代码(Infrastructure as Code, IaC)工具。
Pulumi官网AWS Package Api Documentation:AWS Classic Package | Pulumi Registry
配置AWS和Pulumi


[*]创建AWS账号,启用MFA(多因素认证,增强账号安全性)。
[*]下载AWS CLI和Pulumi并安装。
[*]创建IAM user并创建访问密钥,在本地终端配置AWS profile,如果不指定profile名字将会配置到名为default的profile。
aws configure
aws configure --profile profile_name 使用Pulumi创建AWS资源

主要用到的库:
const pulumi = require('@pulumi/pulumi');
const aws = require('@pulumi/aws'); Pulumi配置文件:
Pulumi.stackName.yaml
config:
aws:profile: dev
aws:region: us-east-1
key: value const config = new pulumi.Config();
const value = config.require("key"); 媒介

每个创建的资源都用tags参数设置了名字,并且将将名字等设置放在配置文件里,为了节省篇幅代码中不再列出,例如
const vpc = new aws.ec2.Vpc(config.require('vpcName'), {
    cidrBlock: cidrBlock,
    tags: { Name: config.require("vpcName") }
}); VPC&Subnet

const vpc = new aws.ec2.Vpc("myVPC", {
    cidrBlock: cidrBlock,
});
const internetGateway = new aws.ec2.InternetGateway("myInternetGateway", {
    vpcId: vpc.id,
});
const publicRouteTable = new aws.ec2.RouteTable("myPublicRouteTable", {
    vpcId: vpc.id,
});
const privateRouteTable = new aws.ec2.RouteTable("myPrivateRouteTable", {
    vpcId: vpc.id,
});
//create subnet of my VPC
const createSubnet = (index, cidrBlock, availabilityZone, isPublic) => {
    const subnetName = isPublic? `public-subnet-${index}`:`private-subnet-${index}`;
    const associationName = isPublic ? `myPublicRouteTableAssociation-${index}` : `myPrivateRouteTableAssociation-${index}`;
    const routeTableId = isPublic ? publicRouteTable.id : privateRouteTable.id;
    const subnet = new aws.ec2.Subnet(subnetName, {
      vpcId: vpc.id,
      cidrBlock: cidrBlock,
      availabilityZone: availabilityZone,
      mapPublicIpOnLaunch: isPublic,
    });
    new aws.ec2.RouteTableAssociation(associationName, {
      subnetId: subnet.id,
      routeTableId: routeTableId,
    });
}
//get AZ
const availabilityZones = await aws.getAvailabilityZones({state: "available"});
//得到已经被存在的subnet使用中的CIDR
const existingSubnetCIDR = [];
const subnets = await aws.ec2.getSubnets();
for(let id of subnets.ids){   
    await aws.ec2.getSubnet({id: id}).then(r => existingSubnetCIDR.push(r.cidrBlock));
}
//用subnet-cidr-calculator包得到可用的CIDR
const probabal_subnets = await SubnetCIDRAdviser.calculate(cidrBlock.split('/') , cidrBlock.split('/'),existingSubnetCIDR);
//根据AZ数得到能创建的subnet数,最多使用三个AZ
const azLength = Math.min(3, availabilityZones.names.length);
//在每个az上创建一个public subnet和一个private subnet
for (let i = 0; i < azLength; i++) {
    await createSubnet(i+1,probabal_subnets.subnets.value,availabilityZones.names,true);
    await createSubnet(i+1,probabal_subnets.subnets.value,availabilityZones.names,false);
}   
//为public路由表创建路由,指向互联网网关
new aws.ec2.Route(config.require("routeName"), {
    routeTableId: publicRouteTable.id,
    destinationCidrBlock: config.require("destinationCidrBlock"),
    gatewayId: internetGateway.id,
}); 注:没有给private subnet配置NAT网关,这个项目中摆设在private subnet上的资源不须要访问互联网。
RDS

EC2

LoadBalancer

AutoScaling

Lambda

SNS

Domain

CloudWatch


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 【云计算】基于AWS和Node.js的作业提交体系