Merge pull request #1489 from balena-io/codewithcheese/read-logs-from-journalctl

Change log source from docker to journalctl
This commit is contained in:
bulldozer-balena[bot] 2020-10-28 07:05:03 +00:00 committed by GitHub
commit 0c0211aa70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 208 additions and 77 deletions

View File

@ -24,6 +24,7 @@ import { Service } from './service';
import { serviceNetworksToDockerNetworks } from './utils'; import { serviceNetworksToDockerNetworks } from './utils';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
import logMonitor from '../logging/monitor';
interface ServiceManagerEvents { interface ServiceManagerEvents {
change: void; change: void;
@ -383,7 +384,7 @@ export function listenToEvents() {
parser.on('data', async (data: { status: string; id: string }) => { parser.on('data', async (data: { status: string; id: string }) => {
if (data != null) { if (data != null) {
const status = data.status; const status = data.status;
if (status === 'die' || status === 'start') { if (status === 'die' || status === 'start' || status === 'destroy') {
try { try {
let service: Service | null = null; let service: Service | null = null;
try { try {
@ -415,6 +416,8 @@ export function listenToEvents() {
serviceId, serviceId,
imageId, imageId,
}); });
} else if (status === 'destroy') {
await logMonitor.detach(data.id);
} }
} }
} catch (e) { } catch (e) {

View File

@ -6,10 +6,12 @@ import log from './supervisor-console';
export function spawnJournalctl(opts: { export function spawnJournalctl(opts: {
all: boolean; all: boolean;
follow: boolean; follow: boolean;
count?: number; count?: number | 'all';
unit?: string; unit?: string;
containerId?: string; containerId?: string;
format: string; format: string;
filterString?: string;
since?: number;
}): ChildProcess { }): ChildProcess {
const args = [ const args = [
// The directory we want to run the chroot from // The directory we want to run the chroot from
@ -34,9 +36,22 @@ export function spawnJournalctl(opts: {
args.push('-n'); args.push('-n');
args.push(opts.count.toString()); args.push(opts.count.toString());
} }
if (opts.since != null) {
args.push('-S');
args.push(
new Date(opts.since)
.toISOString()
.replace(/T/, ' ') // replace T with a space
.replace(/\..+/, ''), // delete the dot and everything after
);
}
args.push('-o'); args.push('-o');
args.push(opts.format); args.push(opts.format);
if (opts.filterString) {
args.push(opts.filterString);
}
log.debug('Spawning journald with: chroot ', args.join(' ')); log.debug('Spawning journald with: chroot ', args.join(' '));
const journald = spawn('chroot', args, { const journald = spawn('chroot', args, {

View File

@ -8,12 +8,11 @@ import { LogType } from './lib/log-types';
import { writeLock } from './lib/update-lock'; import { writeLock } from './lib/update-lock';
import { import {
BalenaLogBackend, BalenaLogBackend,
ContainerLogs,
LocalLogBackend, LocalLogBackend,
LogBackend, LogBackend,
LogMessage, LogMessage,
} from './logging'; } from './logging';
import LogMonitor from './logging/monitor'; import logMonitor from './logging/monitor';
import * as globalEventBus from './event-bus'; import * as globalEventBus from './event-bus';
import superConsole from './lib/supervisor-console'; import superConsole from './lib/supervisor-console';
@ -25,9 +24,6 @@ let backend: LogBackend | null = null;
let balenaBackend: BalenaLogBackend | null = null; let balenaBackend: BalenaLogBackend | null = null;
let localBackend: LocalLogBackend | null = null; let localBackend: LocalLogBackend | null = null;
const containerLogs: { [containerId: string]: ContainerLogs } = {};
const logMonitor = new LogMonitor();
export const initialized = (async () => { export const initialized = (async () => {
await config.initialized; await config.initialized;
const { const {
@ -149,34 +145,14 @@ export function attach(
): Bluebird<void> { ): Bluebird<void> {
// First detect if we already have an attached log stream // First detect if we already have an attached log stream
// for this container // for this container
if (containerId in containerLogs) { if (logMonitor.isAttached(containerId)) {
return Bluebird.resolve(); return Bluebird.resolve();
} }
return Bluebird.using(lock(containerId), async () => { return Bluebird.using(lock(containerId), async () => {
const logs = new ContainerLogs(containerId); logMonitor.attach(containerId, (message) => {
containerLogs[containerId] = logs; log({ ...serviceInfo, ...message });
logs.on('error', (err) => {
superConsole.error('Container log retrieval error', err);
delete containerLogs[containerId];
}); });
logs.on('log', async (logMessage) => {
log(_.merge({}, serviceInfo, logMessage));
// Take the timestamp and set it in the database as the last
// log sent for this
logMonitor.updateContainerSentTimestamp(
containerId,
logMessage.timestamp,
);
});
logs.on('closed', () => delete containerLogs[containerId]);
const lastSentTimestamp = await logMonitor.getContainerSentTimestamp(
containerId,
);
return logs.attach(lastSentTimestamp);
}); });
} }

View File

@ -1,79 +1,211 @@
import * as _ from 'lodash'; import * as JSONstream from 'JSONStream';
import { delay } from 'bluebird';
import * as db from '../db'; import * as db from '../db';
import { spawnJournalctl } from '../lib/journald';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
export type MonitorHook = ({
message,
isStdErr,
}: {
message: string;
isStdErr: boolean;
timestamp: number;
}) => Resolvable<void>;
// This is nowhere near the amount of fields provided by journald, but simply the ones
// that we are interested in
interface JournalRow {
CONTAINER_ID_FULL?: string;
CONTAINER_NAME?: string;
MESSAGE: string | number[];
PRIORITY: string;
__REALTIME_TIMESTAMP: string;
}
// Flush every 10 mins // Flush every 10 mins
const DB_FLUSH_INTERVAL = 10 * 60 * 1000; const DB_FLUSH_INTERVAL = 10 * 60 * 1000;
// Wait 5s when journalctl failed before trying to read the logs again
const JOURNALCTL_ERROR_RETRY_DELAY = 5000;
function messageFieldToString(entry: JournalRow['MESSAGE']): string | null {
if (Array.isArray(entry)) {
return String.fromCharCode(...entry);
} else if (typeof entry === 'string') {
return entry;
} else {
log.error(
`Unknown journald message field type: ${typeof entry}. Dropping log.`,
);
return null;
}
}
/** /**
* This class provides a wrapper around the database for * Streams logs from journalctl and calls container hooks when a record is received matching container id
* saving the last timestamp of a container
*/ */
export class LogMonitor { class LogMonitor {
private timestamps: { [containerId: string]: number } = {}; private containers: {
private writeRequired: { [containerId: string]: boolean } = {}; [containerId: string]: {
hook: MonitorHook;
follow: boolean;
timestamp: number;
writeRequired: boolean;
};
} = {};
public constructor() { public constructor() {
setInterval(() => this.flushDb(), DB_FLUSH_INTERVAL); setInterval(() => this.flushDb(), DB_FLUSH_INTERVAL);
} }
public updateContainerSentTimestamp( public start() {
this.streamLogsFromJournal(
{
all: true,
follow: true,
format: 'json',
filterString: '_SYSTEMD_UNIT=balena.service',
},
(row: JournalRow) => {
if (row.CONTAINER_ID_FULL && this.containers[row.CONTAINER_ID_FULL]) {
this.handleRow(row);
}
},
async (data: Buffer) => {
log.error(
'Non-empty stderr stream from journalctl log fetching: ',
data.toString(),
);
await delay(JOURNALCTL_ERROR_RETRY_DELAY);
this.start();
},
);
}
public isAttached(containerId: string): boolean {
return containerId in this.containers;
}
public async attach(containerId: string, hook: MonitorHook) {
if (!this.containers[containerId]) {
this.containers[containerId] = {
hook,
follow: false,
timestamp: Date.now(),
writeRequired: false,
};
this.containers[
containerId
].timestamp = await this.getContainerSentTimestamp(containerId);
this.backfill(containerId, this.containers[containerId].timestamp);
}
}
public async detach(containerId: string) {
delete this.containers[containerId];
await db.models('containerLogs').delete().where({ containerId });
}
private streamLogsFromJournal(
options: Parameters<typeof spawnJournalctl>[0],
onRow: (row: JournalRow) => void,
onError: (data: Buffer) => void,
onClose?: () => void,
): ReturnType<typeof spawnJournalctl> {
const journalctl = spawnJournalctl(options);
journalctl.stdout?.pipe(JSONstream.parse(true).on('data', onRow));
journalctl.stderr?.on('data', onError);
if (onClose) {
journalctl.on('close', onClose);
}
return journalctl;
}
/**
* stream logs from lastSentTimestamp until now so logs are not missed if the container started before supervisor
*/
private backfill(containerId: string, lastSentTimestamp: number) {
this.streamLogsFromJournal(
{
all: true,
follow: false,
format: 'json',
filterString: `CONTAINER_ID_FULL=${containerId}`,
since: lastSentTimestamp + 1, // increment to exclude last sent log
},
(row: JournalRow) => this.handleRow(row),
async (data: Buffer) => {
log.error('journalctl backfill error: ', data.toString());
},
() => {
this.containers[containerId].follow = true;
},
);
}
private handleRow(row: JournalRow) {
if (row.CONTAINER_ID_FULL && row.CONTAINER_NAME !== 'resin_supervisor') {
const containerId = row.CONTAINER_ID_FULL;
const message = messageFieldToString(row.MESSAGE);
const isStdErr = row.PRIORITY === '3';
const timestamp = Math.floor(Number(row.__REALTIME_TIMESTAMP) / 1000); // microseconds to milliseconds
if (message != null && this.containers[containerId]) {
this.updateContainerSentTimestamp(containerId, timestamp);
this.containers[containerId].hook({ message, isStdErr, timestamp });
}
}
}
private updateContainerSentTimestamp(
containerId: string, containerId: string,
timestamp: number, timestamp: number,
): void { ): void {
this.timestamps[containerId] = timestamp; this.containers[containerId].timestamp = timestamp;
this.writeRequired[containerId] = true; this.containers[containerId].writeRequired = true;
} }
public async getContainerSentTimestamp(containerId: string): Promise<number> { private async getContainerSentTimestamp(
// If this is the first time we are requesting the containerId: string,
// timestamp for this container, request it from the db ): Promise<number> {
if (this.timestamps[containerId] == null) { try {
// Set the timestamp to 0 before interacting with the const row = await db
// db to avoid multiple db actions at once .models('containerLogs')
this.timestamps[containerId] = 0; .select('lastSentTimestamp')
try { .where({ containerId })
const timestampObj = await db .first();
.models('containerLogs')
.select('lastSentTimestamp')
.where({ containerId });
if (timestampObj == null || _.isEmpty(timestampObj)) { if (!row) {
// Create a row in the db so there's something to const now = Date.now();
// update await db
await db .models('containerLogs')
.models('containerLogs') .insert({ containerId, lastSentTimestamp: now });
.insert({ containerId, lastSentTimestamp: 0 }); return now;
} else { } else {
this.timestamps[containerId] = timestampObj[0].lastSentTimestamp; return row.lastSentTimestamp;
}
} catch (e) {
log.error(
'There was an error retrieving the container log timestamps:',
e,
);
} }
} catch (e) {
log.error(
'There was an error retrieving the container log timestamps:',
e,
);
return Date.now();
} }
return this.timestamps[containerId] || 0;
} }
private async flushDb() { private async flushDb() {
log.debug('Attempting container log timestamp flush...'); log.debug('Attempting container log timestamp flush...');
const containerIds = Object.getOwnPropertyNames(this.timestamps);
try { try {
for (const containerId of containerIds) { for (const containerId of Object.keys(this.containers)) {
// Avoid writing to the db if we don't need to // Avoid writing to the db if we don't need to
if (!this.writeRequired[containerId]) { if (!this.containers[containerId].writeRequired) {
continue; continue;
} }
await db.models('containerLogs').where({ containerId }).update({
await db lastSentTimestamp: this.containers[containerId].timestamp,
.models('containerLogs') });
.where({ containerId }) this.containers[containerId].writeRequired = false;
.update({ lastSentTimestamp: this.timestamps[containerId] });
this.writeRequired[containerId] = false;
} }
} catch (e) { } catch (e) {
log.error('There was an error storing the container log timestamps:', e); log.error('There was an error storing the container log timestamps:', e);
@ -82,4 +214,6 @@ export class LogMonitor {
} }
} }
export default LogMonitor; const logMonitor = new LogMonitor();
export default logMonitor;

View File

@ -14,6 +14,7 @@ import version = require('./lib/supervisor-version');
import * as avahi from './lib/avahi'; import * as avahi from './lib/avahi';
import * as firewall from './lib/firewall'; import * as firewall from './lib/firewall';
import logMonitor from './logging/monitor';
const startupConfigFields: config.ConfigKey[] = [ const startupConfigFields: config.ConfigKey[] = [
'uuid', 'uuid',
@ -75,6 +76,8 @@ export class Supervisor {
deviceState.on('shutdown', () => this.api.stop()); deviceState.on('shutdown', () => this.api.stop());
await apiBinder.start(); await apiBinder.start();
logMonitor.start();
} }
} }