多进程中的陷阱
问题背景
张工在一台8核的服务器上部署了一个Node.js 网站应用,在早期访问量不大的情况下,部署使用了单进程方式,网站运行一切正常。随着用户不断变多,为了满足不断变大的访问量,张工决定将这个单进程应用升级为多进程模式,以充分利用服务器上的多核CPU。完成升级后,有用户反馈说出现了一个奇怪的现象:当登录后,请求网页会间断性地提示:“请先登录”,一会儿可以正常访问授权资源,一会儿又无法正常访问授权资源。
问题原因
张工仔细观察了现象,他将原有代码进行简化,形成了如下核心代码:
import { RedisStore } from 'connect-redis'
import express from 'express'
import session from 'express-session'
import redisClient from '../models/redisClient.js'
const HOUR = 1000 * 60 * 60
const app = express()
const redisStore = new RedisStore({
client: redisClient,
prefix: 'myapp:'
})
app.use(
session({
store: redisStore,
key: 'node_session_id', // 设置会话cookie名, 默认是_id
secret: Math.random().toString(),
cookie: { maxAge: HOUR * 1, secure: false },
resave: false, // false 表示只有session内容有变化才会保存; true 表示每次请求都会保存
saveUninitialized: false,
rolling: false // false 表示session过期时间为固定的; true 表示session过期时间在访问时会持续更新
})
)
app.get('/login', (req, res) => {
if (req.url === '/favicon.ico') return
// 使用name和password去模拟查询数据库中的用户
const { name, password } = req.query
const user = { id: 1, name: 'admin', password: 'password' }
req.session.userId = user.id
const sess = req.session
const id = req.sessionID
console.log('login\n', sess, id)
res.header('Content-Type', 'text/html')
res.write('<p>userId: ' + sess.userId + '</p>')
res.end()
})
app.get('/books', (req, res) => {
if (req.url === '/favicon.ico') return
const sess = req.session
const id = req.sessionID
console.log('books\n', sess, id)
if (!sess.userId) {
res.write('<p>please login</p>')
res.end()
} else {
res.header('Content-Type', 'text/html')
res.write('<p>some books</p>')
res.end()
}
})
app.listen(8000)问题的根源出在一个不起眼的地方:secret属性使用了随机数来生成。这在单进程中没有问题,但在多进程环境下,用户访问页面时,服务器会随机使用不同的Node.js进程来响应用户的请求。如果验证session的那个进程和生成session的那个进程不一样,就会导致cookie认证失败,从而出现访问资源拒绝的情况。
我们来模拟一下这个实际过程。
为了方便说明,假设服务器只有2核,并只启用了2个进程,pid分别为1001和1002。1001进程生成的secret为'0.33428147393240304',1002进程生成的secret为'0.6452371741345881'。
- 第一次请求:用户请求/books,假设这次请求由pid为1001的进程处理。由于是第一次访问,浏览器的请求头中没有携带cookie,该进程生成一个session(假设id为abc),服务器返回please login的提示,redis不会执行任何操作。
- 第二次请求:用户请求/login,传入用户名和密码,假设这次请求由pid为1001的进程处理。请求头中依旧没有携带cookie,该进程生成一个新session(假设id为def),如果登录成功,会将用户的id赋值给session.userId。session进行了初始化,redis存入该session。响应头中出现
set-cookie属性,通知浏览器保存session id为def的cookie。 - 第三次请求:用户请求/books,假设这次请求还是由pid为1001的进程处理。请求头中携带session id为def的cookie,session中间件拿到这个session id ,结合值为'0.33428147393240304'的secret,使用
createHmac()生成新签名。将新签名和请求头中cookie签名部分进行比对,发现一致,允许访问/books的内容。 - 第四次请求:用户请求/books,假设这次请求由pid为1002的进程处理。请求头中携带session id为def的cookie,session中间件拿到这个session id,结合值为'0.6452371741345881'的secret,使用
createHmac()生成新签名。将新签名和请求头中cookie签名部分进行比对,发现不一致,认为这个cookie非法,将重新生成session(假设id为hij)。这个新session没有初始化,不会存入redis。响应头也不会设置set-cookie。由于新session没有userId,会被系统认为未登录,服务器返回please login。 - 第五次请求:用户请求/books,假设这次请求又由pid为1001的进程处理。请求头中会携带session id为def的cookie,session中间件拿到这个session id,结合值为'0.33428147393240304'的secret,使用
createHmac()生成签名,将新签名和请求头中cookie签名部分进行比对,发现一致,允许访问/books的内容。 - 第六次请求:用户请求/books,假设这次请求由pid为1002的进程处理。请求头中会携带session id为def的cookie,session中间件拿到这个session id,结合值为'0.6452371741345881'的secret,使用
createHmac()生成新的签名,将新签名和请求头中cookie签名部分进行比对,发现不一致,认为这个cookie非法,将重新生成session(假设id为xyz)。由于新session没有userId,会被系统认为未登录,服务器返回please login。
看到没有,这就是一会儿行,一会儿不行的现象。为了更直观的展示,下图附带一张上面过程的表格:

注意:上面为了方便说明,对session id使用了缩写。
签名规则
Node服务器通知浏览器存储的实际cookie格式类似如下:
s%3A5TyzBXUX9i9W4LNHPd3r1SvaVVZBqWlP.oWZ95k6DmOFtVBvGQ2JrSvVHYnRo3rnQuCUYTMg%2FeUU这个cookie值包含了几个部分:
- 首先是小写字母s开头;
- 其次是:(英文冒号,经过
URL编码后的值是%3A); - 再接着是经过URL编码后的session id值:5TyzBXUX9i9W4LNHPd3r1SvaVVZBqWlP;
- 再接着是.;
- 最后是用 session id 和 secret 得到的签名。签名的生成规则如下:
const sessionId = '5TyzBXUX9i9W4LNHPd3r1SvaVVZBqWlP'
const signature = createHmac('sha256', 'my secret')
.update(sessionId)
.digest('base64')
.replace(/=+$/, '')
const signatureCookie = encodeURIComponent('s:' + sessionId + '.' + signature)
明白了吧,正是因为由于不同进程的这个secret不一样,导致签名认证失败,才出现了这种多进程下的“抽风”现象。
解决方法
明白了原因,解决方法也就明朗了。只需要将这个secret统一下就好了:
app.use(
session({
store: redisStore,
key: 'node_session_id',
secret: 'my-super-long-and-random-secret-key',
cookie: { maxAge: HOUR * 1, secure: false },
resave: false,
saveUninitialized: false,
rolling: false
})
)建议在实际部署时,从环境变量中读取,这样不耦合在代码中,更安全也更方便维护。