mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-21 02:36:57 +08:00
fix: preserve container startup diagnostics
This commit is contained in:
+88
-12
@@ -1,28 +1,104 @@
|
|||||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { request } from 'undici';
|
||||||
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
|
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { promiseExec } from '../config/util';
|
|
||||||
|
function formatError(error: unknown): string {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailedError = error as Error & {
|
||||||
|
cause?: unknown;
|
||||||
|
code?: string;
|
||||||
|
errors?: unknown[];
|
||||||
|
};
|
||||||
|
const details = [error.message, detailedError.code];
|
||||||
|
if (Array.isArray(detailedError.errors)) {
|
||||||
|
details.push(...detailedError.errors.map(formatError));
|
||||||
|
} else if (detailedError.cause) {
|
||||||
|
details.push(formatError(detailedError.cause));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(details.filter(Boolean))].join(': ') || error.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecentSystemLog(lineLimit = 300): Promise<string> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(config.systemLogPath);
|
||||||
|
const latestLog = entries
|
||||||
|
.filter((entry) => entry.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.at(-1);
|
||||||
|
if (!latestLog) {
|
||||||
|
return `No system log found in ${config.systemLogPath}`;
|
||||||
|
}
|
||||||
|
const content = await fs.readFile(
|
||||||
|
path.join(config.systemLogPath, latestLog),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
return content.split('\n').slice(-lineLimit).join('\n').trim();
|
||||||
|
} catch (error) {
|
||||||
|
return `Unable to read system log from ${config.systemLogPath}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const check = async (
|
const check = async (
|
||||||
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
|
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
|
||||||
callback: sendUnaryData<HealthCheckResponse>,
|
callback: sendUnaryData<HealthCheckResponse>,
|
||||||
) => {
|
) => {
|
||||||
switch (call.request.service) {
|
switch (call.request.service) {
|
||||||
case 'cron':
|
case 'cron': {
|
||||||
const res = await promiseExec(
|
const healthUrl = `http://localhost:${config.port}${
|
||||||
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
|
config.baseUrl || ''
|
||||||
);
|
}/api/health`;
|
||||||
|
let failure = '';
|
||||||
if (res.includes('200')) {
|
try {
|
||||||
return callback(null, { status: 1 });
|
const response = await request(healthUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headersTimeout: 5000,
|
||||||
|
bodyTimeout: 5000,
|
||||||
|
});
|
||||||
|
const body = (await response.body.json()) as {
|
||||||
|
code?: number;
|
||||||
|
data?: { status?: string };
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
response.statusCode >= 200 &&
|
||||||
|
response.statusCode < 300 &&
|
||||||
|
body.code === 200 &&
|
||||||
|
body.data?.status === 'ok'
|
||||||
|
) {
|
||||||
|
return callback(null, { status: 1 });
|
||||||
|
}
|
||||||
|
failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`;
|
||||||
|
} catch (error) {
|
||||||
|
failure = formatError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qinglongErrLog = await promiseExec(
|
const systemLog = await getRecentSystemLog();
|
||||||
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
|
const containerHint =
|
||||||
);
|
process.env.QL_CONTAINER === 'true'
|
||||||
|
? 'PM2 file logging is disabled in containers. Check `docker logs <container>` for early startup errors.'
|
||||||
|
: 'Check `pm2 logs qinglong --lines 300` for early startup errors.';
|
||||||
return callback(
|
return callback(
|
||||||
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
|
new Error(
|
||||||
|
[
|
||||||
|
`HTTP health check failed: ${healthUrl}`,
|
||||||
|
failure,
|
||||||
|
containerHint,
|
||||||
|
`Recent system log (${config.systemLogPath}):`,
|
||||||
|
systemLog,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return callback(null, { status: 1 });
|
return callback(null, { status: 1 });
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ module.exports = {
|
|||||||
source_map_support: true,
|
source_map_support: true,
|
||||||
time: !isContainer,
|
time: !isContainer,
|
||||||
out_file: isContainer ? '/dev/null' : undefined,
|
out_file: isContainer ? '/dev/null' : undefined,
|
||||||
error_file: isContainer ? '/dev/null' : undefined,
|
// Do not persist PM2 logs in containers, but keep early startup errors
|
||||||
|
// visible through `docker logs` before Winston is initialized.
|
||||||
|
error_file: isContainer ? '/proc/1/fd/2' : undefined,
|
||||||
script: 'static/build/app.js',
|
script: 'static/build/app.js',
|
||||||
env: {
|
env: {
|
||||||
http_proxy: '',
|
http_proxy: '',
|
||||||
|
|||||||
+58
-18
@@ -23,37 +23,75 @@ copy_dep() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pm2_log() {
|
pm2_log() {
|
||||||
t '---> pm2日志'
|
t '---> 服务诊断信息'
|
||||||
local panelOut="/root/.pm2/logs/qinglong-out.log"
|
pm2 status || true
|
||||||
local panelError="/root/.pm2/logs/qinglong-error.log"
|
|
||||||
tail -n 300 "$panelOut"
|
local systemLogDir="$dir_data/syslog"
|
||||||
tail -n 300 "$panelError"
|
local latestSystemLog
|
||||||
|
latestSystemLog=$(find "$systemLogDir" -maxdepth 1 -type f -name '*.log' 2>/dev/null | sort | tail -n 1)
|
||||||
|
if [[ -n "$latestSystemLog" ]]; then
|
||||||
|
t '---> 最近的系统日志: %s' "$latestSystemLog"
|
||||||
|
tail -n 300 "$latestSystemLog"
|
||||||
|
else
|
||||||
|
t '---> 未找到系统日志: %s' "$systemLogDir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$QL_CONTAINER" == "true" ]]; then
|
||||||
|
t '---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pm2Home="${PM2_HOME:-$HOME/.pm2}"
|
||||||
|
local panelOut="$pm2Home/logs/qinglong-out.log"
|
||||||
|
local panelError="$pm2Home/logs/qinglong-error.log"
|
||||||
|
[[ -f "$panelOut" ]] && tail -n 300 "$panelOut"
|
||||||
|
[[ -f "$panelError" ]] && tail -n 300 "$panelError"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_ql() {
|
check_ql() {
|
||||||
local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}")
|
local basePath="${ql_base_url%/}"
|
||||||
|
local api
|
||||||
|
local attempt
|
||||||
|
for ((attempt = 1; attempt <= 10; attempt++)); do
|
||||||
|
api=$(curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/")
|
||||||
|
[[ $api =~ "<div id=\"root\"></div>" ]] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
t '\n=====> 检测面板'
|
t '\n=====> 检测面板'
|
||||||
echo -e "\n\n$api\n"
|
echo -e "\n\n$api\n"
|
||||||
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
|
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
|
||||||
t '=====> 面板服务启动正常\n'
|
t '=====> 面板服务启动正常\n'
|
||||||
|
else
|
||||||
|
t '=====> 面板服务启动异常,请检查上方诊断信息\n'
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check_pm2() {
|
check_pm2() {
|
||||||
pm2_log
|
|
||||||
local currentTimeStamp=$(date +%s)
|
local currentTimeStamp=$(date +%s)
|
||||||
local api=$(
|
local basePath="${ql_base_url%/}"
|
||||||
curl -s --noproxy "*" "http://localhost:${ql_port}/api/system?t=$currentTimeStamp" \
|
local api
|
||||||
-H 'Accept: */*' \
|
local attempt
|
||||||
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
|
for ((attempt = 1; attempt <= 10; attempt++)); do
|
||||||
-H "Referer: http://localhost:${ql_port}/crontab" \
|
api=$(
|
||||||
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
|
curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/api/health?t=$currentTimeStamp" \
|
||||||
--compressed
|
-H 'Accept: */*' \
|
||||||
)
|
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
|
||||||
|
-H "Referer: http://localhost:${ql_port}/crontab" \
|
||||||
|
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
|
||||||
|
--compressed
|
||||||
|
)
|
||||||
|
[[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
t '\n=====> 检测后台'
|
t '\n=====> 检测后台'
|
||||||
echo -e "\n\n$api\n"
|
echo -e "\n\n$api\n"
|
||||||
if [[ $api =~ "{\"code\"" ]]; then
|
if [[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]]; then
|
||||||
t '=====> 后台服务启动正常\n'
|
t '=====> 后台服务启动正常\n'
|
||||||
|
else
|
||||||
|
pm2_log
|
||||||
|
t '=====> 后台服务启动异常,请检查上方诊断信息\n'
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +101,12 @@ main() {
|
|||||||
|
|
||||||
reset_env
|
reset_env
|
||||||
copy_dep
|
copy_dep
|
||||||
check_ql
|
|
||||||
check_pm2
|
|
||||||
reload_pm2
|
reload_pm2
|
||||||
|
local checkStatus=0
|
||||||
|
check_ql || checkStatus=1
|
||||||
|
check_pm2 || checkStatus=1
|
||||||
t '\n=====> 检测结束\n'
|
t '\n=====> 检测结束\n'
|
||||||
|
return $checkStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
main
|
main
|
||||||
|
|||||||
@@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=(
|
|||||||
['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n'
|
['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n'
|
||||||
['---> 通知文件复制完成\n']='---> Notification files copied\n'
|
['---> 通知文件复制完成\n']='---> Notification files copied\n'
|
||||||
['---> pm2日志']='---> pm2 log'
|
['---> pm2日志']='---> pm2 log'
|
||||||
|
['---> 服务诊断信息']='---> Service diagnostics'
|
||||||
|
['---> 最近的系统日志: %s']='---> Recent system log: %s'
|
||||||
|
['---> 未找到系统日志: %s']='---> No system log found: %s'
|
||||||
|
['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> PM2 logs are not persisted in containers; run docker logs --tail 300 <container> for early startup errors'
|
||||||
['\n=====> 检测面板']='\n=====> Checking panel'
|
['\n=====> 检测面板']='\n=====> Checking panel'
|
||||||
['=====> 面板服务启动正常\n']='=====> Panel service running normally\n'
|
['=====> 面板服务启动正常\n']='=====> Panel service running normally\n'
|
||||||
|
['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> Panel failed to start; check the diagnostics above\n'
|
||||||
['\n=====> 检测后台']='\n=====> Checking backend'
|
['\n=====> 检测后台']='\n=====> Checking backend'
|
||||||
['=====> 后台服务启动正常\n']='=====> Backend service running normally\n'
|
['=====> 后台服务启动正常\n']='=====> Backend service running normally\n'
|
||||||
|
['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> Backend failed to start; check the diagnostics above\n'
|
||||||
['=====> 开始检测']='=====> Starting check'
|
['=====> 开始检测']='=====> Starting check'
|
||||||
['\n=====> 检测结束\n']='\n=====> Check complete\n'
|
['\n=====> 检测结束\n']='\n=====> Check complete\n'
|
||||||
# rmlog.sh
|
# rmlog.sh
|
||||||
|
|||||||
@@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=(
|
|||||||
['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n'
|
['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n'
|
||||||
['---> 通知文件复制完成\n']='---> 通知文件复制完成\n'
|
['---> 通知文件复制完成\n']='---> 通知文件复制完成\n'
|
||||||
['---> pm2日志']='---> pm2日志'
|
['---> pm2日志']='---> pm2日志'
|
||||||
|
['---> 服务诊断信息']='---> 服务诊断信息'
|
||||||
|
['---> 最近的系统日志: %s']='---> 最近的系统日志: %s'
|
||||||
|
['---> 未找到系统日志: %s']='---> 未找到系统日志: %s'
|
||||||
|
['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>'
|
||||||
['\n=====> 检测面板']='\n=====> 检测面板'
|
['\n=====> 检测面板']='\n=====> 检测面板'
|
||||||
['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n'
|
['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n'
|
||||||
|
['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> 面板服务启动异常,请检查上方诊断信息\n'
|
||||||
['\n=====> 检测后台']='\n=====> 检测后台'
|
['\n=====> 检测后台']='\n=====> 检测后台'
|
||||||
['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n'
|
['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n'
|
||||||
|
['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> 后台服务启动异常,请检查上方诊断信息\n'
|
||||||
['=====> 开始检测']='=====> 开始检测'
|
['=====> 开始检测']='=====> 开始检测'
|
||||||
['\n=====> 检测结束\n']='\n=====> 检测结束\n'
|
['\n=====> 检测结束\n']='\n=====> 检测结束\n'
|
||||||
# rmlog.sh
|
# rmlog.sh
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ function loadConfig(containerValue) {
|
|||||||
return require(configPath).apps[0];
|
return require(configPath).apps[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
test('container logging relies on pm2-runtime stdout without persistent copies', () => {
|
test('container logging discards stdout and forwards startup errors without persistent copies', () => {
|
||||||
const app = loadConfig('true');
|
const app = loadConfig('true');
|
||||||
assert.equal(app.out_file, '/dev/null');
|
assert.equal(app.out_file, '/dev/null');
|
||||||
assert.equal(app.error_file, '/dev/null');
|
assert.equal(app.error_file, '/proc/1/fd/2');
|
||||||
assert.equal(app.time, false);
|
assert.equal(app.time, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const http = require('node:http');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const config = require('../../back/config').default;
|
||||||
|
const { check } = require('../../back/schedule/health');
|
||||||
|
|
||||||
|
function runCheck(service = 'cron') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
check({ request: { service } }, (error, response) => {
|
||||||
|
resolve({ error, response });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('schedule health check uses the HTTP health endpoint and system logs', async (t) => {
|
||||||
|
const originalBaseUrl = config.baseUrl;
|
||||||
|
const originalPort = config.port;
|
||||||
|
const originalSystemLogPath = config.systemLogPath;
|
||||||
|
const originalContainer = process.env.QL_CONTAINER;
|
||||||
|
|
||||||
|
t.after(() => {
|
||||||
|
config.baseUrl = originalBaseUrl;
|
||||||
|
config.port = originalPort;
|
||||||
|
config.systemLogPath = originalSystemLogPath;
|
||||||
|
if (originalContainer === undefined) {
|
||||||
|
delete process.env.QL_CONTAINER;
|
||||||
|
} else {
|
||||||
|
process.env.QL_CONTAINER = originalContainer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test(
|
||||||
|
'returns serving for a healthy prefixed HTTP service',
|
||||||
|
async () => {
|
||||||
|
let requestedUrl = '';
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
requestedUrl = request.url;
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify({ code: 200, data: { status: 'ok' } }));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||||
|
|
||||||
|
config.baseUrl = '/ql';
|
||||||
|
config.port = server.address().port;
|
||||||
|
|
||||||
|
const result = await runCheck();
|
||||||
|
assert.equal(result.error, null);
|
||||||
|
assert.deepEqual(result.response, { status: 1 });
|
||||||
|
assert.equal(requestedUrl, '/ql/api/health');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.test(
|
||||||
|
'reports recent system logs when HTTP startup fails',
|
||||||
|
async () => {
|
||||||
|
const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-health-'));
|
||||||
|
t.after(() => fs.rm(logDir, { recursive: true, force: true }));
|
||||||
|
const lines = Array.from(
|
||||||
|
{ length: 305 },
|
||||||
|
(_, index) => `line-${index + 1}`,
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(logDir, '2026-08-16.log'), 'older-log');
|
||||||
|
await fs.writeFile(path.join(logDir, '2026-08-17.log'), lines.join('\n'));
|
||||||
|
config.systemLogPath = logDir;
|
||||||
|
config.port = 1;
|
||||||
|
process.env.QL_CONTAINER = 'true';
|
||||||
|
|
||||||
|
const result = await runCheck();
|
||||||
|
assert.equal(result.response, undefined);
|
||||||
|
assert.match(result.error.message, /ECONNREFUSED|connect/);
|
||||||
|
assert.match(
|
||||||
|
result.error.message,
|
||||||
|
/http:\/\/localhost:1\/ql\/api\/health/,
|
||||||
|
);
|
||||||
|
assert.match(result.error.message, /docker logs <container>/);
|
||||||
|
assert.match(result.error.message, /line-305/);
|
||||||
|
assert.doesNotMatch(result.error.message, /line-1\n/);
|
||||||
|
assert.doesNotMatch(result.error.message, /qinglong-error\.log/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user