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

打印 上一主题 下一主题

主题 209|帖子 209|积分 627

简介



  • 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

  1. const mariadb = require('mariadb');
  2. const mariaPool = await mariadb.createPool({
  3.         host: "DATABASE_HOST",
  4.         user: "DATABASE_USER",
  5.         password : "DATABASE_PASSWORD"
  6. });
  7. const conn = await mariaPool.getConnection();
  8. await conn.query(`CREATE DATABASE IF NOT EXISTS ${"DATABASE_NAME"}`);
  9. conn.end();
复制代码
使用Sequelize连接mariadb

  1. const Sequelize = require('sequelize');
  2. const mariadb = require('mariadb');
  3. const db = new Sequelize("DATABASE_NAME","DATABASE_USER","DATABASE_PASSWORD",{
  4.     host: "DATABASE_HOST",
  5.     port:3306,
  6.     dialect:"mariadb",
  7.     pool:{
  8.         max:5,
  9.         min:0,
  10.         idle:10000 
  11.     },
  12.     debug:true, 
  13. })
复制代码
注:创建 Sequelize 实例时,该操作自己并不立即建立数据库连接,只是配置 Sequelize 实例,准备与数据库连接的相干参数。

Tables布局


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

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

将存储在csv文件中的用户信息导入到数据库表,存储加密后的密码。
  1. const fs = require('fs');
  2. const parse = require('csv-parser');
  3. const bcrypt = require('bcrypt');
  4. const salt = bcrypt.genSaltSync(10);
  5. fs.createReadStream("CSV_FILE_PATH")
  6.     .pipe(parse())
  7.     .on('data', async (row) => {
  8.         try {
  9.             const hashPass = bcrypt.hashSync(row.password, salt);
  10.             const [user, created] = await User.findOrCreate({
  11.                 where:{
  12.                 first_name: row.first_name,
  13.                 last_name: row.last_name,
  14.                 email: row.email
  15.             },defaults:{
  16.                 password: hashPass
  17.             }})         
  18.         } catch (error) {
  19.             console.log(error);
  20.         }
  21.     })
复制代码
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

检查数据库连接
  1. try{
  2.     db.authenticate();      //sequelize.authenticate
  3.     res.status(200).send(); //200 OK
  4. } catch(e){
  5.     res.status(503).send(); //503 Service Unavailable
  6. }
复制代码
GET /v1/assignments

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

查看id对应的assignment
  1. try{
  2.     const userId = await getUser(basicAuth(req),res); //检查Authorization
  3.     if(userId == 0 || !checkContent(req,res)){        //如果Authorization没通过或者请求body格式不对
  4.         return;
  5.     }else{
  6.         const result = await Assignment.findOne({
  7.             where: {
  8.                 id:req.params.id                      //根据id查找assignment
  9.             }
  10.         });
  11.         if(result == null){     
  12.             res.status(404).send();                   //404 Not Found
  13.         }
  14.         else{
  15.             res.status(200).json(getResult(result));  //200 OK
  16.         }              
  17.     }        
  18. } catch(e){
  19.     res.status(503).send();                           //503 Service Unavailable
  20. }
复制代码
POST  /v1/assignment

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

向id对应的assignment举行提交
  1. try{
  2.     const userId = await getUser(basicAuth(req),res);          //检查Authorization
  3.     if(userId == 0){
  4.         return;
  5.     }else if(!req.body.submission_url){                        //检查body字段
  6.         logger.info(`No submission content.`);
  7.         res.status(400).send();                                //400 Bad Request
  8.     } else{
  9.         const result = await Assignment.findOne({              //找到id对应的assignment
  10.             where: {
  11.                 id:req.params.id
  12.             }
  13.         });
  14.         if(result == null){
  15.             res.status(404).send();                            //404 Not Found
  16.         }
  17.         else{
  18.             const now = new Date();
  19.             if(now > result.deadline){                         //提交时间晚于deadline
  20.                 res.status(403).send();                        //403 Forbidden
  21.             }else{
  22.                 const subs = await Submission.findAll({where: {//得到该user在该assignment上的所有提交
  23.                     assignment_id:req.params.id,
  24.                     user_id: userId,
  25.                 }});
  26.                 if(subs.length == result.num_of_attemps){      //如果达到最大可提交次数
  27.                     res.status(403).send();                    //403 Forbidden
  28.                 }else{
  29.                     Submission.create({                        //201 Created
  30.                         assignment_id: req.params.id,
  31.                         user_id: userId,
  32.                         submission_url: req.body.submission_url,
  33.                     }).then((created) => res.status(201).json(getResult(created)));
  34.                 }
  35.             }
  36.         }              
  37.     }        
  38. } catch(e){
  39.     res.status(503).send();                                    //503 Service Unavailable
  40. }
复制代码
PUT /v1/assignment/:id

修改assignment
  1. try {
  2.     const userId = await getUser(basicAuth(req),res);  //检查Authorization
  3.     if(userId == 0 || !checkContent(req,res)){         //如果Authorization没通过或者请求body格式不对
  4.         return;
  5.     }else{
  6.         Assignment.findOne({                           //根据id查找要修改的assignment
  7.             where: {
  8.                 id: req.params.id,
  9.             }
  10.         }).then((result) => {
  11.             if(result == null){         
  12.                 res.status(404).send();                //404 Not Found
  13.             }
  14.             else if(result.user_id == userId){         //如果是请求用户创建的assignment,有修改权限
  15.                 Assignment.update({
  16.                     user_id: userId,
  17.                     name: req.body.name,
  18.                     points: req.body.points,
  19.                     num_of_attemps: req.body.num_of_attemps,
  20.                     deadline: req.body.deadline
  21.                 },
  22.                 {
  23.                     where: {
  24.                         id: req.params.id
  25.                     }
  26.                 }).then(ok => res.status(204).send()); //204 No Content
  27.             }
  28.             else{
  29.                 res.status(403).send();                //403 Forbidden   
  30.             }
  31.         });
  32.     }
  33. } catch (e) {
  34.     res.status(503).send();                            //503 Service Unavailable
  35. }
复制代码
DELETE /v1/assignment/:id

删除该id对应的assignment
  1. try {
  2.     const userId = await getUser(basicAuth(req),res);  //检查Authorization
  3.     if(userId == 0 || !checkContent(req,res)){         //如果Authorization没通过或者请求body格式不对
  4.         return;
  5.     }else{
  6.         await Assignment.findOne({                     //根据id找到要删除的assignment
  7.             where: {
  8.                 id:req.params.id
  9.             }
  10.         }).then((result) => {
  11.             if(result == null){
  12.                 res.status(404).send();                //404 Not Found
  13.             }
  14.             else if(result.userId == userId){          //assignment是请求用户创建的,有删除权限
  15.                 Assignment.destroy(
  16.                 {
  17.                     where: {id: req.params.id}   
  18.                 }).then(ok =>res.status(204).send());  //204 No Content
  19.             }
  20.             else{
  21.                 res.status(403).send();                //403 Forbidden
  22.             }
  23.         });
  24.     }
  25. } catch (e) {
  26.     res.status(503).send();                            //503 Service Unavailable
  27. }
复制代码
其他哀求



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

使用mocha举行api测试
  1. const request = require('supertest');
  2. const app = require('./app'); // my webapp in app.js
  3. const chai = require('chai');
  4. const expect = chai.expect;
  5. const describe = require('mocha').describe;
  6. describe('GET /healthz', () => {
  7.     it('should return a 200 status code if the MySQL connection is healthy', (done) => {
  8.       request(app)
  9.         .get('/healthz')
  10.         .expect(200)
  11.         .end((err, res) => {
  12.           if (err) return done(err);
  13.           done();
  14.         });
  15.     });
  16. });
复制代码
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如果有格式错误会直接报错。
  1. packer init ami.pkr.hcl
  2. packer fmt -check ami.pkr.hcl
  3. packer validate ami.pkr.hcl
  4. packer build ami.pkr.hcl
复制代码
GitHub Workflow

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

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

使用mailgun配置应用程序邮件服务,将注册的domain添加到mailgun,然后将须要添加的记录在Hosted Zone中创建,通过verify之后即可使用mailgun的api。
  1. const formData = require('form-data');
  2. const Mailgun = require('mailgun.js');
  3. const mailgun = new Mailgun(formData);
  4. const mg = mailgun.client({username: 'api', key: "MY_API_KEY"});
  5. mg.messages.create('sandbox-123.mailgun.org', {
  6.         from: "Excited User <mailgun@MY_DOMAIN>",
  7.         to: [email],
  8.         subject: "Hello",
  9.         text: "Testing some Mailgun awesomeness!",
  10.         html: "<h1>Testing some Mailgun awesomeness!</h1>"
  11.     })
  12.     .then(msg => console.log(msg)) // logs response data
  13.     .catch(err => console.log(err)); // logs any error
复制代码
AWS CloudWatch

使用cloudwatch监控webapp的log和自定义matrics统计api调用。
cloudwatch的配置可以在终端举行也可以直接写入cloudwatch-config.json文件,在终端配置也是生成文件,如果该配置文件不存在则无法运行cloud-watch-agent。
  1. const StatsD = require('hot-shots');
  2. const { createLogger, format, transports } = require('winston');
  3. const logger = createLogger({
  4.     format: format.combine(
  5.       format.timestamp({
  6.         format: 'YYYY-MM-DD HH:mm:ss'
  7.       }),
  8.       format.json()
  9.     ),
  10.     transports: [
  11.       new transports.File({ filename: '/var/log/myLog.log' })
  12.     ]
  13. });
  14. const statsd = new StatsD({
  15.     port: 8125,
  16.     errorHandler: (error) => {
  17.         logger.info("StatsD error: ", error);
  18.     }
  19. });
  20. statsd.increment(`api_call.${req.method}.${req.path}`);
  21. 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。
  1. aws configure
  2. aws configure --profile profile_name
复制代码
使用Pulumi创建AWS资源

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

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

  1. const vpc = new aws.ec2.Vpc("myVPC", {
  2.     cidrBlock: cidrBlock,
  3. });
  4. const internetGateway = new aws.ec2.InternetGateway("myInternetGateway", {
  5.     vpcId: vpc.id,
  6. });
  7. const publicRouteTable = new aws.ec2.RouteTable("myPublicRouteTable", {
  8.     vpcId: vpc.id,
  9. });
  10. const privateRouteTable = new aws.ec2.RouteTable("myPrivateRouteTable", {
  11.     vpcId: vpc.id,
  12. });
  13. //create subnet of my VPC
  14. const createSubnet = (index, cidrBlock, availabilityZone, isPublic) => {
  15.     const subnetName = isPublic? `public-subnet-${index}`:`private-subnet-${index}`;
  16.     const associationName = isPublic ? `myPublicRouteTableAssociation-${index}` : `myPrivateRouteTableAssociation-${index}`;
  17.     const routeTableId = isPublic ? publicRouteTable.id : privateRouteTable.id;
  18.     const subnet = new aws.ec2.Subnet(subnetName, {
  19.         vpcId: vpc.id,
  20.         cidrBlock: cidrBlock,
  21.         availabilityZone: availabilityZone,
  22.         mapPublicIpOnLaunch: isPublic,
  23.     });
  24.     new aws.ec2.RouteTableAssociation(associationName, {
  25.         subnetId: subnet.id,
  26.         routeTableId: routeTableId,
  27.     });
  28. }
  29. //get AZ
  30. const availabilityZones = await aws.getAvailabilityZones({state: "available"});
  31. //得到已经被存在的subnet使用中的CIDR
  32. const existingSubnetCIDR = [];
  33. const subnets = await aws.ec2.getSubnets();
  34. for(let id of subnets.ids){   
  35.     await aws.ec2.getSubnet({id: id}).then(r => existingSubnetCIDR.push(r.cidrBlock));
  36. }
  37. //用subnet-cidr-calculator包得到可用的CIDR
  38. const probabal_subnets = await SubnetCIDRAdviser.calculate(cidrBlock.split('/')[0] , cidrBlock.split('/')[1],existingSubnetCIDR);
  39. //根据AZ数得到能创建的subnet数,最多使用三个AZ
  40. const azLength = Math.min(3, availabilityZones.names.length);
  41. //在每个az上创建一个public subnet和一个private subnet
  42. for (let i = 0; i < azLength; i++) {
  43.     await createSubnet(i+1,probabal_subnets.subnets[2*i + 1].value,availabilityZones.names[i],true);
  44.     await createSubnet(i+1,probabal_subnets.subnets[2*i + 2].value,availabilityZones.names[i],false);
  45. }   
  46. //为public路由表创建路由,指向互联网网关
  47. new aws.ec2.Route(config.require("routeName"), {
  48.     routeTableId: publicRouteTable.id,
  49.     destinationCidrBlock: config.require("destinationCidrBlock"),
  50.     gatewayId: internetGateway.id,
  51. });
复制代码
注:没有给private subnet配置NAT网关,这个项目中摆设在private subnet上的资源不须要访问互联网。
RDS

EC2

LoadBalancer

AutoScaling

Lambda

SNS

Domain

CloudWatch


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

星球的眼睛

高级会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表