简介
- 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})定义的表。
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文件中的用户信息导入到数据库表,存储加密后的密码。
- 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 [user, created] = 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: [email],
- 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('/')[0] , cidrBlock.split('/')[1],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[2*i + 1].value,availabilityZones.names[i],true);
- await createSubnet(i+1,probabal_subnets.subnets[2*i + 2].value,availabilityZones.names[i],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企服之家,中国第一个企服评测及商务社交产业平台。 |