UNPKG

walter

Version:

Backup utilities for postgresql

543 lines (430 loc) 15.2 kB
var env = process.env; var fs = require('fs'); var pg = require('pg.js'); var path = require('path'); var knox = require('knox'); var moment = require('moment'); var es = require('event-stream'); var buffers = require('buffers'); var queries = require('./queries'); var optimist = require('optimist'); var format = require('util').format; var mkdirp = require('mkdirp').sync; var spawn = require('child_process').spawn; var winston = require('winston'); var sbuffers = require('stream-buffers'); var argv = optimist .usage('Push a base backup to s3.\nUsage: walter backup-push [options]') .describe('pg-dir', 'Postgres data directory (env: WALTER_PG_DIR)') .describe('pg-conn', 'Postgres connection string (env: WALTER_PG_CONN)') .describe('working-dir', 'Working directory for walter (env: WALTER_DIR)') .describe('prefix', 'Prefix used to store files on s3 (env: WALTER_PREFIX)') .describe('gpg-id', 'GPG ID to encrypt your backups (env: WALTER_GPG_ID)') .describe('aws-key', 'AWS Key (env: WALTER_AWS_KEY)') .describe('aws-secret', 'AWS Secret (env: WALTER_AWS_SECRET)') .describe('aws-region', 'AWS region for your bucket (env: WALTER_AWS_REGION)') .describe('s3-bucket', 'S3 Bucket Name (env: WALTER_S3_BUCKET)') .describe('s3-part-size', 'S3 part size for multi-part uploads (env: WALTER_S3_PART_SIZE') .describe('log-file-path', 'Winston file transport path (env: WALTER_LOG_FILE_PATH)') .describe('log-file-size', 'Winston file transport size in MB (env: WALTER_LOG_FILE_SIZE)') .describe('log-file-limit', 'Winston file transport rotate limit (env: WALTER_LOG_FILE_LIMIT)') .describe('labs-api-key', 'Walter Labs API Key (env: WALTER_LABS_API_KEY)') .describe('log-http-host', 'Winston HTTP transport host (env: WALTER_LOG_HTTP_HOST)') .describe('log-http-port', 'Winston HTTP transport port (env: WALTER_LOG_HTTP_PORT)') .describe('log-http-path', 'Winston HTTP transport path (env: WALTER_LOG_HTTP_PATH)') .describe('log-http-ssl', 'Winston HTTP transport ssl (env: WALTER_LOG_HTTP_SSL)') .describe('tar-bin', 'Path to tar binary') .describe('gpg-bin', 'Path to gpg binary') .describe('lzop-bin', 'Path to lzop binary') .describe('upload-chunk-size', 'Size of chunk for streaming upload') .describe('upload-chunk-frequency', 'Frequency between streaming upload chunks') .default('pg-dir', env.WALTER_PG_DIR) .default('pg-conn', env.WALTER_PG_CONN || 'postgres://postgres@localhost/postgres') .default('working-dir', env.WALTER_DIR || path.join(getUserHome(), '.walter')) .default('prefix', env.WALTER_PREFIX) .default('gpg-id', env.WALTER_GPG_ID) .default('aws-key', env.WALTER_AWS_KEY) .default('aws-secret', env.WALTER_AWS_SECRET) .default('s3-bucket', env.WALTER_S3_BUCKET) .default('s3-part-size', env.WALTER_S3_PART_SIZE || (5*1024*1024)) .default('log-file-path', env.WALTER_LOG_FILE_PATH) .default('log-file-size', env.WALTER_LOG_FILE_SIZE) .default('log-file-limit', env.WALTER_LOG_FILE_LIMIT) .default('labs-api-key', env.WALTER_LABS_API_KEY) .default('log-http-host', env.WALTER_LOG_HTTP_HOST) .default('log-http-port', env.WALTER_LOG_HTTP_PORT) .default('log-http-path', env.WALTER_LOG_HTTP_PATH) .default('log-http-ssl', env.WALTER_LOG_HTTP_SSL) .default('upload-chunk-size', 1048576) .default('upload-chunk-frequency', 1) .default('tar-bin', 'tar') .default('gpg-bin', 'gpg') .default('lzop-bin', 'lzop') .demand('pg-dir') .demand('prefix') .demand('aws-key') .demand('aws-secret') .demand('s3-bucket') .wrap(120) .argv; winston.remove(winston.transports.Console); if(argv['labs-api-key']) { argv['log-http-host'] = 'api.walter-labs.com'; argv['log-http-port'] = 443; argv['log-http-path'] = '/servers/' + argv['labs-api-key']; argv['log-http-ssl'] = true; } if(argv['log-http-host']) { var logHttpOpts = { host: argv['log-http-host'] }; if(argv['log-http-port']) logHttpOpts.port = argv['log-http-port']; if(argv['log-http-path']) logHttpOpts.path = argv['log-http-path']; if(argv['log-http-ssl'] === 'true' || argv['log-http-ssl'] === true) logHttpOpts.ssl = true; winston.add(winston.transports.Http, logHttpOpts); } if(argv['log-file-path']) { var logFileOpts = { filename: argv['log-file-path'] }; if(argv['log-file-size']) logFileOpts.maxsize = (argv['log-file-size']*1024*1024); if(argv['log-file-limit']) logFileOpts.maxFiles = argv['log-file-limit']; winston.add(winston.transports.File, logFileOpts); } var backup_running = false; var mpu_running = false; var db = new pg.Client(argv['pg-conn']); var label = 'backup_started_' + moment().utc().toJSON(); var s3 = createS3Client(); var started = new Date(); winston.info('base-backup-push: %s: started.', argv['prefix'], { type: 'base', action: 'started', prefix: argv['prefix'], started: started }); var rl = require('readline').createInterface({ input: process.stdin, output: process.stdout }); rl.setPrompt('', 0); rl.clearLine = function() { this.write(null, {ctrl: true, name: 'u'}); }; module.exports = function() { db.connect(function(err) { if(err) throw err; db.query(queries.start, [label], onStarted); }); }; function onStarted(err, data) { if(err) return handleError(err); var start_info = data.rows[0]; var base_path = argv['prefix'] + '/base/'; var base_file = start_info.offset + '_' + start_info.file_name; var manifest_file = base_file + '_manifest.json'; var working_path = path.resolve(argv['working-dir'], base_path); var working_file = path.resolve(working_path, base_file); var remote_path = base_path + base_file; backup_running = true; console.log('Initiated PostgreSQL base backup:', base_file); console.log('Initiating S3 MPU upload:', remote_path); initiateMPU(remote_path, function(err, mpu_id) { if(err) return handleError(err); console.log('Initiated S3 MPU upload:', mpu_id); console.log('Initiating streaming part uploads.'); mpu_running = true; mkdirp(working_path); var tar = spawnTar(); var lzop = spawnLzop(); var gpg = spawnGpg(); var ps = es.pause(); var chunker = createChunker(); var uploader = createUploader(remote_path, mpu_id, ps, 5); var writer = createArrayWriter(onUploadComplete); var pipeline = gpg ? [tar, lzop, gpg, ps, chunker, uploader, writer] : [tar, lzop, ps, chunker, uploader, writer]; var pipe = es.pipeline.apply(null, pipeline); pipe.on('error', handleError); function onUploadComplete(err, parts) { if(err) return handleError(err); rl.resume(); rl.close(); console.log('Succesfully uploaded', parts.length, 'parts'); completeMPU(remote_path, mpu_id, parts, function(err) { if(err) return handleError(err); console.log('Completed S3 MPU upload:', remote_path); mpu_running = false; db.query(queries.stop, onStopped); }); } }); function onStopped(err, result) { if(err) return handleError(err); console.log('Completed PostgreSQL base backup:', result.rows[0]); backup_running = false; var manifest = createManifest(result.rows[0]); var manifestSize = Buffer.byteLength(manifest); console.log('Uploading manifest to S3'); var putManifest = s3.put(base_path + manifest_file, { 'Content-Length': manifestSize, 'Content-Type': 'application/json' }); putManifest.on('response', manifestResponse); putManifest.end(manifest, 'utf8'); } function manifestResponse(response) { if((response.statusCode < 200 || response.statusCode > 299)) { var err = new Error('Failed manifest upload to S3'); return handleError(err); } console.log('Successfully uploaded manifest'); s3.headFile(remote_path, function(err, res) { if(err || (res.statusCode < 200 || res.statusCode > 299)) { if(!err) err = new Error('Failed to retrieve head for base backup after upload'); return handleError(err); } var filesize = parseInt(res.headers['content-length'], 10); winston.info('base-backup-push: %s: completed.', argv['prefix'], { type: 'base', action: 'completed', prefix: argv['prefix'], base_offset: start_info.offset, base_file_name: start_info.file_name, manifest: manifest_file, filename: base_file, started: started, size: filesize, completed: new Date() }, function() { db.end(); }); }); } } function initiateMPU(path, callback) { var req = s3.request('POST', path + '?uploads'); req.on('response', function(res, body) { var xml = ''; res.on('data', function(data) { xml += data; }); res.on('end', function() { var parsed = xml.match(/\<UploadId\>(.*)\<\/UploadId\>/); if((res.statusCode < 200 || res.statusCode > 299) || !parsed || parsed.length !== 2) { var err = new Error('Unable to initiate multi-part upload.'); return callback(err); } callback(null, parsed[1]); }); res.on('error', callback); }); req.end(); } function completeMPU(path, mpu_id, parts, callback) { parts.sort(sortByPartNumber); var xml = ''; var tpl = '<Part><PartNumber>%d</PartNumber><ETag>%s</ETag></Part>'; for(var i=0; i<parts.length; i++) xml += format(tpl, parts[i].number, parts[i].etag); var req = s3.request('POST', path + '?uploadId=' + mpu_id); req.on('response', function(res, body) { var response = ''; res.on('data', function(data) { response += data; }); res.on('end', function() { var err = null; if((res.statusCode < 200 || res.statusCode > 299)) err = new Error(response); callback(err); }); res.on('error', callback); }); req.write('<CompleteMultipartUpload>' + xml + '</CompleteMultipartUpload>'); req.end(); } function createChunker() { var bufs = buffers(); function onData(data) { bufs.push(data); if(bufs.length >= argv['s3-part-size']) { this.queue(bufs.toBuffer()); bufs = buffers(); } } function onEnd() { if(bufs.length > 0) { this.queue(bufs.toBuffer()); bufs = null; } this.queue(null); } return es.through(onData, onEnd); } function createUploader(remote_path, mpu_id, ps, concurrency) { var active = 0; var part = 0; var completed = 0; var completedBytes = 0; var status = 'Uploading parts, %d active uploads, %d completed (%s)'; var started = false; function updateStatus() { var mbs = (completedBytes/1024/1024).toFixed(2) + 'MB'; if(started) rl.clearLine(); if(!started) started = true; rl.write(format(status, active, completed, mbs)); } return es.map(function(data, callback) { active++; var attempts = 1; var number = ++part; var url = remote_path + '?partNumber=' + number + '&uploadId=' + mpu_id; var headers = {'Content-Length': data.length}; if(active === concurrency) ps.pause(); function handleResponse(err, res) { if(err || !res || ((res.statusCode < 200 || res.statusCode > 299))) { if(attempts >= 3) return callback(new Error('Failed uploading part.')); return retryPart(); } active--; completed++; completedBytes += data.length; updateStatus(); if(active < concurrency && ps.paused) ps.resume(); var etag = res.headers.etag.slice(1, res.headers.etag.length-1); return callback(null, {etag: etag, number: number}); } function retryPart() { attempts++; uploadPart(url, headers, data, handleResponse); } updateStatus(); uploadPart(url, headers, data, handleResponse); }); } function uploadPart(url, headers, data, callback) { var req = s3.request('PUT', url, headers); var sbuffer = new sbuffers.ReadableStreamBuffer({ frequency: argv['upload-chunk-frequency'], chunkSize: argv['upload-chunk-size'] }); sbuffer.put(data); req.on('error', callback); req.on('response', function(res) { return callback(null, res); }); sbuffer.pipe(req); } function createArrayWriter(callback) { return es.writeArray(callback); } function putBase(working_file, base_path, base_file, manifest_file) { var stat = fs.statSync(working_file); var rs = fs.createReadStream(working_file); var put = s3.put(base_path + base_file, { 'Content-Length': stat.size }); put.on('error', handleError); put.on('response', baseResponse); rs.pipe(put); function baseResponse(response) { if((response.statusCode < 200 || response.statusCode > 299)) { var err = new Error('Failed upload to S3'); return handleError(err); } db.query(queries.stop, onStopped); } function onStopped(err, result) { if(err) return handleError(err); backup_running = false; var manifest = createManifest(result.rows[0]); var manifestSize = Buffer.byteLength(manifest); var putManifest = s3.put(base_path + manifest_file, { 'Content-Length': manifestSize, 'Content-Type': 'application/json' }); putManifest.on('response', manifestResponse); putManifest.end(manifest, 'utf8'); } function manifestResponse(response) { if((response.statusCode < 200 || response.statusCode > 299)) { var err = new Error('Failed manifest upload to S3'); return handleError(err); } fs.unlinkSync(working_file); db.end(); } } function createManifest(stop_info) { return JSON.stringify({ 'wal_segment_name': stop_info.file_name, 'wal_segment_offset': stop_info.file_offset }); } function createS3Client() { var options = { key: argv['aws-key'], secret: argv['aws-secret'], bucket: argv['s3-bucket'] }; if(argv['aws-region']) options.region = argv['aws-region']; return new knox.createClient(options); } function spawnTar() { var args = [ '--exclude', '**/pg_xlog/*', '--exclude', '**/pg_log/*', '--exclude', '**/postmaster.pid', '--exclude', '**/postmaster.opts', '--exclude', '**/postgresql.conf', '-c', '-C', argv['pg-dir'], './' ]; return es.child(spawn(argv['tar-bin'], args)); } function spawnLzop() { return es.child(spawn(argv['lzop-bin'], ['-c'])); } function spawnGpg() { if(!argv['gpg-id']) return false; var args = ['-e', '-z', '0', '-r', argv['gpg-id'], '-']; return es.child(spawn(argv['gpg-bin'], args)); } function onConnect(err) { if(err) handleError(err); } function handleError(err) { winston.error('base-backup-push: %s: error occured.', argv['prefix'], { prefix: argv['prefix'], error: err.message, started: started, errored: new Date() }, function() { if(!backup_running) { db.end(); throw err; } db.query(queries.stop, function() { db.end(); throw err; }); }); } function getUserHome() { return process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; } function sortByPartNumber(a, b) { if (a.number < b.number) return -1; if (a.number > b.number) return 1; return 0; }