Miután bevezettük a G-Suite rendszert a cég életébe az egyik hozzáadott érték az lett, hogy a biztonsági mentéseket feltettük a felhőbe. Ezzel csökkentek a fenntartási költségeink, nincsenek tárhely problémáink valamint szakmai szempontból is gondoskodóbb kezekbe került.

Természetesen automatizált folyamatról van szó. Többnyire valamilyen Linux disztribúció alatt futó rendszereinket vagy szolgáltatásainkat kell menteni napi rendszerességgel.

A Ponte esetében általában kétféle mentést szokott jelenteni. Vagy valamilyen könyvtárat és annak tartalmát kell menteni vagy pedig adatbázis fájlt. Mindkét esetben érdemes azokat betömöríteni, hogy egy egységet képezzenek.

Biztonsági mentések előkészítése

Mielőtt feltöltésre kerülne az érintett tömörített állomány azt elő kell készíteni, mivel gondolni kell az adatok védelmére is. A korlátlan tárhely szép és jó de ugye azért az adatokat "csak úgy" nem adnánk oda. Nem is lenne szakmailag helytálló fél munkát végezni.

Ezért a következő lépéseket tesszük:

  • A biztonsági mentéseket tömörítjük. Erre a célra a Linux rendszereken elérhető tar.gz parancsot használjuk.
  • A fájlt feldaraboljuk 1 GB-os méretű darabokra. Többek között praktikus, mivel feltöltéskor a hálózati kapcsolat időközbeni elvesztése esetén biztosan nem kell elölről kezdeni. Valamint a titkosításra használt eszköz egyik limitációja, hogy ekkora mérettel tud dolgozni.
  • A darabok titkosítása OpenSSL segítségével.
  • A fájlok konvenció követő elnevezése. A fájlnévben is feltüntetjük a dátumot valamilyen formában. Az oka, hogy a vonatkozó meta információk torzulhatnak idővel a használt rendszerektől függően.

Feltöltés a Google Drive-ba

Körüljárva a témát hamar fény derült arra, hogy Linux alatt nem áll rendelkezésre a Google oldaláról jól használható parancssori kliens. Egy lehetőség maradt, hogy keressünk egy third-party megoldást, amelynél ellenőrizhető annak megbízható működése, megvalósítása. Végül a gdrive nevű megoldás vált be.

Menedzsment

A biztonsági mentések kezelésének lényege, hogy felügyeljük a folyamatot. Tudjunk róla ha nem történik meg vagy szabaduljunk meg a már felesleges mentésektől.

Az utóbbi elsőre ellentmondásosnak tűnhet az olvasó számára hiszen a korlátlan tárhely szabadságát dicsértem a cikk elején. Ugyanakkor ez igen szélsőségessé vállhat ebben az esetben. A G-Suite bevezetését követően nem foglalkoztunk ezzel és közel szűk 2 év után több, mint 40TB adat halmozódott fel. Összehasonlításként a G-Suite előtti időszakban 2TB állt rendelkezésre saját hardveren. A jóhoz könnyű hozzászokni.

A fair-use jegyében, valamint annak érdekében, hogy még csírájában kezelni tudjuk ezt az adatmennyiséget készítettünk egy Google Apps Scriptet, ami eltávolítja a szükségtelen mentéseket.

Elavult biztonsági mentések törlése

Ez a feladat új tapasztalatokat hozott számunkra, mivel szembesültünk a G.A.S. limitációival több alkalommal is. Ezek a script fejlesztése során jöttek elő, ami karra kényszerítettek minket, hogy folyamatosan módosítsuk. Az olvasó számára ez már fordítva kerülne tálalásra és elsőként a korlátokat mutatnánk be. Utána pedig, hogy a törlést milyen szabályok mentén végezzük.

Google Apps Script korlátok

syoc_-_backup_management_-_limits-1.jpg

syoc_-_backup_management_-_limits-2.jpg

Milyen mentéseket töröljünk?

Az első tisztázandó kérdés, hogy minek kell megmaradnia és mi az, amit törölhetünk. A mi esetünkben a fájlok megtartásának szabálya a következők legalább egyikének teljesülése:

  • minden olyan fájl, ami 14 napon belül jött létre megtartjuk
    a 2 héten belüli napi mentés alkalmas adatvesztés helyreállítására
  • minden olyan fájl, ami az adott hónapban az első mentés szintén megtartjuk
    nem feltétlen szükséges, de sokszor jó ha régi állapotokról is van valamilyen mentés. Ezt esetleg a GDPR igények miatt nem feltétlen érdemes mindenhol alkalmazni
  • minden olyan fájl, ami az adott rendszerből a legutolsó mentés
    elsőre azt gondolnánk, hogy a 14 napon belüli magába foglalja, de ez nem igaz. Ugyanis ha egy rendszer mentését leállítjuk a hónap közepén vagy nem vesszük észre, hogy valamilyen hiba miatt nem történik meg, akkor bizony 14 napon túlra csúszhat az utolsó mentés.

Szembetűnő lehet, hogy a szabályok az időre vonatkoznak. Így már érthetőbb lehet, hogy miért fontos annak minél pontosabb megőrzése. Ez indokolta többek között, hogy a fájlnévben is jelen legyen. Lényegében a fájlnévben lévő idő a kliens által ismert időt jelenti, míg a Google Drive idő a szerver által ismert létrehozási vagy módosítási időt.

Időzóna eltérésre nem árt figyelni! A példánk esetében nem áll fenn ez a probléma, ugyanis ha napokban gondolkozunk, akkor ennek nincs hatása.

Megvalósítás

A limitációk és a megtartási szabályok ismeretében nézzük meg hogyan tudjuk ütemezni a folyamatot, ami feltérképezi a törlendő fájlokat, majd azokat törli. Végén pedig report készítés történik.

Ütemezés

A G.A.S. korlátainak egyike, hogy egy script csak 6 percig futhat maximum. Ez azért probléma, mert minél több mappát és minél több fájlt kell listáztatni és vizsgálni annál tovább fut a script.

Ezt úgy tudjuk megkerülni, hogy a mentéseket projektenként mappába szervezzük és a script futtatását időzítjük, ahol egyszerre csak egy adott mappát néz át. Ekkor egyetlen mappára van 6 perc.

Ha netán abban is sok fájl van, akkor az igazából csak kezdetben jelent majd problémát, amikor még sok régi fájl van fent. Később a rendszeres, például heti futtatások miatt ez nem fog problémát jelenti. A lényeg, hogy a folyamat folytatható legyen.

A rekurzív bejárás sajnos nagyon lassú a Drive scriptelésekor. Így a mentések szervezésénél érdemes az almappákat elkerülni projekteken belül.

Viszont sajnos van egy másik korlátunk is. Egy időben egyszerre 20 darab időzítés alapú trigger lehet. Ezt úgy kerüljük meg, hogy a script mappánkénti vizsgálatát úgy időzítsük, hogy azok láncba legyenek fűzve.

syoc_-_backup_management_-_trigger_chain.png

Időzített lánc lépései:

1. Definiáljuk a folyamat kezdését. Például határozzunk úgy, hogy hetente egy alkalommal, minden szombaton elindul a takarítási folyamat. Ezt be tudjuk állítani kézzel is a script fájlunkban. Menjünk az Edit > Current project's triggers menüpontra.

syoc_-_backup_management_-_start_trigger.jpg

Itt nevezzük meg a futtatni kívánt metódus nevét és állítsuk be az időzítési szabályt.

syoc_-_backup_management_-_start_trigger-2.jpg

2. A továbbiakban az időzített lánc miatt futási időben fogunk létrehozni időzítés alapú triggereket. A startTriggerChain() metódusban érdemes bevezetnünk egy aktuális munkamenet azonosítót, hogy a lánc során tudjuk, hogy az melyik (mikori) indítás részét képezi. Legyen ez mondjuk egy sessionDate érték, melyet az indítás ideje alapján generálunk. Ezen kívül meghívjuk a timingTrigger() metódust.

function startTriggerChain(){
  var sessionDate = formatDate(new Date());
  timingTrigger(sessionDate, FOLDER_IDS, 5 * 1000); // 5 seconds
}

3. A timingTrigger() végzi el a következő feladat felidőzítését és adja át a kívánt paramétereket.

function timingTrigger(sessionDate, bucketList, delay){
  var trigger = ScriptApp
    .newTrigger("doTrigger")
    .timeBased()
    .after(delay)
    .create();
  
  setupTriggerArguments(trigger.getUniqueId(), {
      sessionDate: sessionDate,
      bucketList : bucketList
    });
}

A paraméterekre egy kicsit ki kell térnünk, mert némi magyarázatot igényel. A Google Apps Script világában egy időzített feladat csak a PropertiesService osztályon keresztül tudja megkapni a szükséges adatokat. Oka, hogy az időzített alapú futtatáshoz ezeket háttértáron is el kell tudni tárolni, mivel nem maradhat a memóriában addig, amíg sorra nem kerül. Ugye ez akár hetekkel, hónapokkal később is lehet. Ráadásul az sem garantált, hogy percre pontosan fog lefutni.

PropertiesService minden paramétert az időzítések egyedi azonosítójához (triggerUID) társítja, amit létrehozáskor kapunk. Ennek könnyebb kezelése céljából hozzuk létre az alábbi függvényeket.

// Basic Trigger functions -------------------------------------
function removeTriggerByUid(triggerUid){
  if (!ScriptApp.getProjectTriggers().some(function (trigger) {
    if (trigger.getUniqueId() === triggerUid) {
      removeTrigger(trigger);
      return true;
    }
    return false;
  })) {
    console.error("Could not find trigger with id '%s'", triggerUid);
  }
}

function removeTrigger(trigger){
  ScriptApp.deleteTrigger(trigger);
  deleteTriggerArguments(trigger.getUniqueId());
}

function setupTriggerArguments(triggerUid, args){
  PropertiesService.getScriptProperties().setProperty(triggerUid, JSON.stringify(args));
}

function extractTriggerArguments(triggerUid) {
  return JSON.parse(PropertiesService.getScriptProperties().getProperty(triggerUid));
}

function deleteTriggerArguments(triggerUid) {
  PropertiesService.getScriptProperties().deleteProperty(triggerUid);
}

 4. A doTrigger() implementációja felel azért, hogy az időzített lánc ténylegesen lánc legyen:

  • A PropertiesService-ból kiveszi a rá vonatkozó paramétereket.
  • Eltávolítja az időzítések közül önmagát, hogy a triggerek száma ne halmozódjon hiszen a limitációk egyike, hogy egy időben felhasználónként maximum 20 darab lehet beállítva.
  • Kiveszi a feladatlistából az első név-id párost.
  • Újraidőzíti önmagát 6 perccel későbbre, immáron a szűkített listával, ahogy az a szemléltető ábrán is fel lett tüntetve. 
function doTrigger(event){
  // Get args & remove timing and args
  var args = extractTriggerArguments(event.triggerUid);
  var sessionDate = args.sessionDate;
  var bucketList = args.bucketList;
  removeTriggerByUid(event.triggerUid);
  
  // Find & definite next trigger in the chain or finally send report
  var bucketListKeys = Object.keys(bucketList);
  if(!!bucketListKeys && bucketListKeys.length > 0){
    
    // Save current job params
    var target = bucketListKeys[0];
    var id = bucketList[target];
    
    // Next job args & timing now
    delete bucketList[target];
    timingTrigger(sessionDate, bucketList, MIN_TASK_TIME);
    
    // Do the job using params
    startCleanup(sessionDate, target, id);
    
  } else {
    // Send report
    sendReport();
  }  
}

Végezetül pedig meghívjuk a startCleanup() metódust, hogy az eredetileg kitűzött feladatot végrehajtsuk. Ekkora már ismerjük a szükséges paramétereket, tudjuk, hogy melyik könyvtárat kell átvizsgálnunk. Ez pedig a feltérképezést és törlést jelenti.

 Ha pedig nincs már vizsgálandó mappa, akkor készíthetünk egy jelentést, ami összegzi az eredményt.

Feltérképezés

A feltérképezés arról szól, hogy megkeressük azokat a fájlokat, fájlcsoportokat, amelyeket a szabályok szerint törölnünk kell. Ha a mappastruktúra olyan, akkor esélyes, hogy rekurzív bejárást kell alkalmaznunk. Mivel egy adott mentést 1GB-os darabokra bontottunk, így az több fájlból is állhat, tehát fontos, hogy ezeket a későbbiekben együtt kezeljük. Ezért fogunk valamilyen csoportosítást alkalmazni.

1. Járjuk be a célkönyvtárat rekurzív módon a findBackupFiles() metódussal. Ha fájlokkal találkozzunk, akkor azok nevét teszteljük, hogy formailag megfelel-e az általunk alkalmazott dátum formátumnak. Ha igen gyűjtsük be, egyébként hagyjuk figyelmen kívül.

function findBackupFiles(root){
  var result = [];
  var folders = root.getFolders();
  while(folders.hasNext()) {
    var childFolder = folders.next();
    var subResult = findBackupFiles(childFolder);
    result = result.concat(subResult);
  }
  
  var files = root.getFiles();
  while(files.hasNext()){
    var childFile = files.next();
    if(FILE_DATE_FORMAT.test(childFile.getName()))
       result.push(childFile);
  }
  return result;
}

A dátum tesztelésére reguláris kifejezéssel tudunk hibatűrően felkészülni. Mi az alábbi formátumot alkalmaztuk, de mindenki alakítsa a saját formai követelményének megfelelően:

var FILE_DATE_FORMAT = new RegExp("(20[0-9]{0,2})[\-_]?([01]?[0-9])[\-_]?([0123]?[0-9])");

A fenti kód a 2000 év utániakat tekinti érvényes dátumnak és az alábbi formátumoknak egyaránt megfelel, amit a regex101.com-on megnézhetsz:

  • 2017-01-01
  • 2018-05-15-project1.backup.tar
  • projekt2-2018-05-15.tgz.part01.enc
  • projekt3-20170913.tar.gz
  • projekt3-2017_09_13.tar.gz

2. A megtalált fájlok listáján menjünk végig és végezzük el a csoportosítást dátum alapján. Ezt a groupBackupFiles() metódusban implementáljuk, ami bemeneti paraméterként az előzőleg kifejtett metódus eredményét várja.

function groupBackupFiles(arr){
  var result = {};
  if(!!arr && arr.length != 0){
    arr.map(function(f){
      var found = f.getName().match(FILE_DATE_FORMAT);
      if(!!found && found.length != 0){
        var timestamp = asTimestamp(found[0]);
        if(!result[timestamp])
          result[timestamp] = [];
        result[timestamp].push(f);
      }
    });
  }
  return result;
}

A dátumonkénti csoportosítás azért elegendő, mivel a script amikor fut csak egy adott könyvtárral foglalkozik. Az adott könyvtár pedig csak az adott projekt mentéseit tartalmazza.

3. Ez a lépés a legfontosabb és egyben a legnagyobb figyelmet igénylő rész. Ugyanis itt válogatjuk szét, itt döntünk arról, mely fájlok maradjanak meg és melyek törlődjenek.

function separateBackupFiles(groups){
  var result = { fresh : [], keep : [], remove : [] };
  
  var lastYear, lastMonth;
  var firstDayInMonthFounded = false;
  var now = new Date();
  var groupKeys = Object.keys(groups).sort();
  var groupKeyCount = groupKeys.length;
  groupKeys.map(function(time, index){
    var date = new Date(Number(time));
    var diff = daysBetween(date, now);
    
    if(diff < KEEP_WITHIN_DAYS){
      result.fresh.push(groups[time]);
      return;
    }
    
    Logger.log("index: " + index + ", Count: " + groupKeyCount);
    if(index == groupKeyCount - 1){ // Keep the last
      result.keep.push(groups[time]);
      return;
    }
    
    var year = date.getFullYear();
    var month = date.getMonth();
    var day = date.getDay();
    
    // reset when year or month changed
    if(lastYear != year || lastMonth != month){
      lastYear = year;
      lastMonth = month;
      firstDayInMonthFounded = false;
    }
  
    if(!firstDayInMonthFounded){ // Keep
      result.keep.push(groups[time]);
      firstDayInMonthFounded = true;
    }else{ // Remove
      result.remove.push(groups[time]);
    }
    
  });
  return result;
}

4. Most, hogy a 3 metódusunk külön-külön elvégzi a dolgát, itt az ideje, hogy összefogjuk a folyamatot és megvalósítsuk a startCleanup() metódust.

// Main logic ---------------------------------------
function startCleanup(sessionDate, target, id) {
  if(!target || !id) return;
  
  // Collect & separate files
  var backupFolder = DriveApp.getFolderById(id);
  var backupFiles = findBackupFiles(backupFolder);
  var backupFilesByDate = groupBackupFiles(backupFiles);
  var backupFilesSeparated = separateBackupFiles(backupFilesByDate);
  
  // Do the job
  var results = [0, 0]; // count, size
  backupFilesSeparated.remove.map(function(arr){
    var r = moveToTrash(arr);
    results[0] += r[0];
    results[1] += r[1];
  });
  
  // Reporting
  // TODO: result contains every useful information about removed files
}

Fájlok törlése

Ha már megvan, hogy mely fájlokat kell törölni, akkor az már egy könnyebb lépés, hogy meg is történjen. Ugyanakkor a tényleges törlés helyett érdemes a kukába való áthelyezést megvalósítani.

function moveToTrash(arr){
  var result = [0, 0];  // count, size
  // there is nothing to do
  if(!arr || arr.length == 0) return result;
  // each file set trashed
  arr.map(function(f){
    f.setTrashed(true);
    result[1] += f.getSize();
  });
  result[0] = arr.length;
  // trashed parent folder too when it's empty
  var parentFolder = arr[0].getParents().next();
  if(!!parentFolder){
    var empty = !parentFolder.getFiles().hasNext() && !parentFolder.getFolders().hasNext();
    if(empty) parentFolder.setTrashed(true); // set trashed
  }
  return result;
}

A Gmail-nél megszokott automatikus kuka ürítés a Drive esetében nincs:
"The file will stay there until you empty your trash." 
https://support.google.com/drive/answer/2375102

Report készítése

Mivel jelen projekt arról szól, hogy automata, időzített működés keretében töröljük a régi biztonsági mentéseket, ezért nem árt, ha valami nyoma van és kapunk valamilyen visszajelzést. A startCleanup() metódus eredménye egy olyan tömb, amiből tudjunk mennyi tárhelyet szabadítottunk fel. Ezt például Spreadsheet-be is elmenthetjük, melynek megvalósítását már az olvasóra bíznám.

Azért a report küldésre legyen itt egy kód részlet:

function sendReport(){
  // Get summary results
  var summary = DEFAULT_LOGGER.calcSummary();
  var date = formatDate(new Date(summary[0]));
  var size = summary[2] / 1024 / 1024 / 1024;  
  
  // Read template & fill informations
  var bodyHTML = convertToHTML(REPORT_EMAIL_TEMPLATE_DOC_ID);
  var reportBodyHTML = bodyHTML.replace(/{{SESSION_DATE}}/g, date)
                               .replace(/{{COUNT}}/g, summary[1])
                               .replace(/{{SIZE}}/g, size.toFixed(2))
                               .replace(/{{WITHIN_DAYS}}/g, KEEP_WITHIN_DAYS)
                               .replace(/{{LOG_URL}}/g, DEFAULT_LOGGER.getUrl());
  
  // Send mail
  MailApp.sendEmail({
    to: REPORT_EMAIL_ADDRESSES.join(","),
    subject: "Backup Cleaner Report - " + date,
    htmlBody: reportBodyHTML,
    noReply: true
  });
}

Ezzel lényegében kész is vagyunk.


A fenti megvalósítás egy valós példát mutatott arra, hogy a Google Apps Script ismét jól tudott jönni egy olyan környezetben, ahol a teljesen különböző irodai és céges szolgáltatásokat programozhatjuk. Ezáltal még egy folyamatot sikerült automatizálni, ezzel pedig időt és pénzt megtakarítani. Ráadásul elébe mentünk egy későbbi olyan problémának, ahol hirtelen több 100 TB adattal szembesültünk volna. A script eredményeképpen a mentésünk helyigénye 10%-ra esett vissza.