Copia de seguridad de Ghost desde App Service Backup

13 min de lectura
Copia de seguridad de Ghost desde App Service Backup

Tras poner a punto Ghost en Azure App Service funcionando a la perfección hay un tema capital a resolver si no queremos algún día caer en el más profundo de los lamentos: las copias de seguridad.

Azure App Service se ha convertido en los últimos años en uno de los servicios de hospedaje de aplicaciones más robustos completos e interoperables del mercado, posicionándolo como mi servicio PaaS favorito. Entre las múltiples utilidades del servicio encontramos la de copias de seguridad o backups.

Backup en Azure App Service

El funcionamiento de esta capacidad es muy sencillo: seleccionamos un contenedor dentro de una Storage Account donde se van a ubicar las copias de seguridad de los archivos del sitio, seleccionamos las bases de datos asociadas -si las hubiera- y por último planificamos una recurrencia en la copia. Hecho esto Azure ejecutará nuestra copia de seguridad según planificación o bien bajo demanda.

¿Dónde está la base de datos de Ghost?

La mayoría de sistemas de gestión de contenidos actuales tienen una arquitectura multicapa, con un frontend donde se ubica la capa de presentación y la lógica de negocio y un backend donde solemos encontrar los datos.

Fiel a su filosofía minimalista, Ghost tiene una arquitectura bastante sencilla:

  • Frontend. Ghost está desarrollado con NodeJS, por lo que cualquier servidor que se lleve bien con él podrá hospedar nuestra solución. Como es evidente, esto no es problema para IIS, Apache o Ngnix.
  • Backend. Aquí tenemos dos opciones muy interesantes. Ghost viene de serie preparado para funcionar con dis sistemas de bases de datos: SQLite y MySQL.

En el caso de SQLite, se trata de una base de datos autocontenida que no necesita de un servidor que la hospede, ya que la aplicación escribe directamente en disco las operaciones resultados de las consultas que se hacen. Es ideal para pequeñas aplicaciones, pruebas de desarollo y sitios web con un tráfico entre leve y moderado.

Sobre MySQL poco voy a contar que no conozcáis ya. Es la opción que viene siendo todo un clásico en el mundo opensource de la gestión de contenido. Necesita que pongamos un servidor a punto con el motor de base de datos o bien adquirirlo como servicio PaaS de algún proveedor.

Dado que estoy hospedando Ghost en Azure App Service, la opción de SQLite me resulta especialmente atractiva, dado que no espero que este blog supere el tráfico moderado que SQLite puede soportar[1], y puedo hacer copia de seguridad de todo el blog con una simple copia de archivos. ¡Es perfecto!

El destino de nuestras copias de seguridad va a ser una Storage Account. Esta situación es ideal para utilizar el nuevo Azure Blog Storage: Cool, cuyo coste es a día de escribir estas líneas de aproximadamente $0,01 GB/mes.

Configurando Azure App Service

No voy a explicar como se realiza un backup de nuestra aplicación en App Service porque es verdaderamente trivial y está muy bien explicado aquí.

Sin embargo, al iniciar el trabajo de backup, veremos como obtenemos un Partially succeeded. ¿Cómo es posible esto?

Partially succeeded

"Partially succeeded" es un mensaje de resultado de operación de backup de App Service, donde se nos indica que *no todos* los archivos se pudieron copiar debido a que algunos de ellos se encontraban bloqueados por otro proceso.

¿A qué se debe este Partially succeeded?

Cuando Ghost opera en modo SQLite este tiene su base de datos en un archivo que podemos encontrar en /content/data/ghost.db. Mientras nuestro blog esté en ejecución NodeJS reclama acceso exclusivo a dicho archivo con el fin de evitar la corrupción del mismo debido a una modificación externa.

Esto es lo que imposibilita que el backup tenga éxito. A día de hoy y mientras usemos SQLite, la única opción consiste en detener el proceso de Ghost, realizar la copia de seguridad y reanudarlo.

Esto elimina de un plumazo la posibilidad de las copias de seguridad programadas y nos obliga a una operación de copia de seguridad bastante manual. Realicé el siguiente proceso comprobando que -efectivamente- la copia de seguridad se completaba sin problemas:

  • Parar la aplicación web en App Service.
  • Iniciar manualmente la copia de seguridad configurada.
  • Esperar a que finalice.
  • Iniciar de nuevo la aplicación web.

Sinceramente, no estaba muy por la labor de hacer un trabajo tan absurdo, además de dejarle a mi cabeza el libre albedrío de acordarse que hay que hacer una copia de seguridad de todo. Este era el camino más rápido a una tragedia para la cual la última copia data de 4 meses. ¿Qué podía hacer?

Al rescate: Azure Automation y Azure Resource Manager API

¿Una tarea manual tediosa y repetitiva? ¡Esto es un trabajo para Azure Automation! Se trata del sistema de orquestado y automatización de Azure ideal para tanto para hacer procesos complejos auditables como para evitar la intervención humana en tareas repetitivas.

Lo primero es lo primero: PowerShell

Antes de siquiera crear el recurso de Azure Automation, lo primero es crear el script que va a hacer el trabajo por nosotros. Evidentemente aquí estamos hablando de PowerShell. Así que recordemos lo que tenemos que hacer:

  • Parar la aplicación web.
  • Iniciar trabajo de backup.
  • Iniciar la aplicación web.

Pero hay un importante escollo que superar: a día de hoy, los cmdlets de PowerShell de Azure App Service no soportan iniciar un trabajo de backup bajo demanda, lo que imposibilita el segundo paso. Sin embargo, la API REST de administración de Azure Resource Manager sí que lo permite.

El proceso para llevarlo a cabo está muy bien documentado aquí. Sin embargo, cuando hacemos la llamada a la API nos encontraremos un estupendo Authentication failed.

¿No sería un problema que hubiese funcionado? ¡No hemos realizado ningún proceso de autenticación o validación para operar sobre nuestra suscripción! Yo no dormiría muy tranquilo si mediante la API cualquiera pudiera tocar mi suscripción.

La situación es clara: ¡hay que autenticarse!

Azure Resource Manager REST API: autenticación

La autenticación en la Azure Resource Manager REST API se realiza mediante un token en la cabecera de nuestra petición HTTP. La única forma a día de hoy de obtener este token es mediante la Azure Active Directory Autentication Library (ADAL). Tendremos que descargarla mediante NuGET (yo he usado la versión 3.x).

Esta librería necesita que creemos un Service Principal, que no es ni mas ni menos que una aplicación de Azure Active Directory que nosotros mismos creamos y que tiene permisos para gestionar nuestras suscripción de Azure.

Crearlo puede parecer engorroso, pero hecho con detenimiento es bastante sencillo. Los pasos se encuentran aquí: https://azure.microsoft.com/en-us/documentation/samples/active-directory-powershell-rest-api-authentication/

Mediante el script que nos aparece en la web obtenemos el authentication header que podemos inyectar a nuestras peticiones HTTP de forma que realicemos las operaciones que necesitamos.

El código PowerShell de automatización

El siguiente código se vale del acceso que hemos provisto para llevar a cabo la operación de copia de seguridad de acuerdo al flujo que hemos definido. Contando que tienes ADAL en la ubicación adecuada y has creado adecuadamente el ServicePrincipal, deberías poder ejecutarlo desde tu máquina local una vez cumplimentadas las variables de la cabecera:

<#
.SYNOPSIS
API driven Azure App Service webapp Backup.
.DESCRIPTION
File locking web apps (like Ghost blog) can be problematic when we want to use the integrated
Azure Backup features, as it won't be able to backup locked files. This is a Azure Automation
ready script that does the following:
    - Stop the web application in Azure App Service.
    - Initiates a backup operation.
    - Start the web application as soon as the operation is complete
    - Clean old backup files

It has the following requirements:
    - Access Azure Resource Manager through the Service Principal method
    - Operates through Azure Management REST API, so ADAL 3.x is required
    - Uses SAS URL for accessing the destination storage account
.NOTES
AUTHOR: Carlos Milán Figueredo
WARNING: Using this scripts implies DOWNTIME in your webapp. Use with caution and schedule your operation accordingly.
DISCLAMER: This code is provide AS IS, without warranty of any kind. I 
.LINK
http://calnus.com
#>

$subscriptionId = "<Enter your Subscription ID here>"
$tenantId = "<Enter your Tenant ID here>"
$webappName = "<Enter your webapp name here>"
$resourceGroupName = "<Enter your Resource Group Name here>"
$storageAccountName = "<Enter your Storage Account Name here>"
$storageAccountKey = "<Enter your Storage Account key here>"
$blobContainerName = "<Enter your blob container name here>"
$restUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$webappName"
$apiVersion = "?api-version=2016-03-01"
$clientId = "<Enter your Azure AD application Client ID here>"
$clientKey = "<Enter your Azure AD application Client Key here>"

<#
.SYNOPSIS
Authorization header needed to call the API: https://azure.microsoft.com/en-us/documentation/samples/active-directory-powershell-rest-api-authentication/
.DESCRIPTION
In order to authenticate REST API request, an authorization header must be retrieved from Azure AD. The current only way to get it is through ADAL,
that can be obtained from nuget.org. Mind the Add-Type -Path since it's already prepared for Azure Automation.
#>
Function New-AzureRestAuthorizationHeader 
{
    [CmdletBinding()] 
    Param 
    ( 
        [Parameter(Mandatory=$true)][String]$ClientId, 
        [Parameter(Mandatory=$true)][String]$ClientKey, 
        [Parameter(Mandatory=$true)][String]$TenantId 
    ) 

    # Import ADAL library to acquire access token 
    Add-Type -Path "C:\Modules\User\Microsoft.IdentityModel.Clients.ActiveDirectory\Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 
    Add-Type -Path "C:\Modules\User\Microsoft.IdentityModel.Clients.ActiveDirectory\Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 

    # Authorization & resource Url 
    $authUrl = "https://login.windows.net/$TenantId/" 
    $resource = "https://management.core.windows.net/" 
    # Create credential for client application
    $clientCred = [Microsoft.IdentityModel.Clients.ActiveDirectory.ClientCredential]::new($ClientId, $ClientKey) 
    # Create AuthenticationContext for acquiring token
    $authContext = [Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext]::new($authUrl, $false)
    # Acquire the authentication result 
    $authResult = $authContext.AcquireTokenAsync($resource, $clientCred).Result 
    # Compose the access token type and access token for authorization header 
    $authHeader = $authResult.AccessTokenType + " " + $authResult.AccessToken 
    # the final header hash table 
    return @{"Authorization"=$authHeader; "Content-Type"="application/json"} 
}

<#
.SYNOPSIS
Authorization header needed to call the API: https://azure.microsoft.com/en-us/documentation/samples/active-directory-powershell-rest-api-authentication/
#>
Function Get-WebApp
{
    $authHeader = New-AzureRestAuthorizationHeader -ClientId $clientId -ClientKey $clientKey -TenantId $tenantId
    return Invoke-RestMethod -Method GET -Header $authHeader -ContentType "application/json" -Uri ($restUri + $apiVersion)
}

<#
.SYNOPSIS
Starts a webapp via REST API and checks every 15 seconds if the start was successfully completed.
This is a sync call.
#>
Function Start-WebApp
{
    $authHeader = New-AzureRestAuthorizationHeader -ClientId $clientId -ClientKey $clientKey -TenantId $tenantId
    $result = Invoke-RestMethod -Method Post -ContentType "application/json" -Header $authHeader -Uri ($restUri + "/start" + $apiVersion)
    do {
        Start-Sleep -Seconds 15
        $app = Get-WebApp
    } while ($app.properties.state -ne "Running")

    return $result
}

<#
.SYNOPSIS
Stops a webapp via REST API and checks every 15 seconds if the stop was successfully completed.
This is a sync call.
#>
Function Stop-WebApp
{
    $authHeader = New-AzureRestAuthorizationHeader -ClientId $clientId -ClientKey $clientKey -TenantId $tenantId
    $result = Invoke-RestMethod -Method Post -ContentType "application/json" -Header $authHeader -Uri ($restUri + "/stop" + $apiVersion)
    do {
        Start-Sleep -Seconds 15
        $app = Get-WebApp
    } while ($app.properties.state -ne "Stopped")

    return $result
}

########## Backup script start #############

# First, let's stop the webapp so we can release the lock in the SQLite database
Write-Output "Stopping webapp..."
Stop-WebApp

# Storage context creation for accessing via SAS. It will be short-lived (1 day)
$context = New-AzureStorageContext -StorageAccountName $storageAccountName -StorageAccountKey $storageAccountKey
$token = New-AzureStorageContainerSASToken -Name $blobContainerName -Permission rwdl -Context $context -ExpiryTime (Get-Date).AddDays(1)
$sasUrl = $context.BlobEndPoint + $blobContainerName + $token

# When initiating the webapp backup operation, at least a JSON with the destination
# Storage Account must be specified. We will use the SAS URL previously generated.
$post = @{ "properties" = @{ "storageAccountUrl" = $sasUrl } }
$body = ConvertTo-Json $post

Write-Output "SAS URL: $sasUrl"
Write-Output "Invoking: $restUri"

# We are ready to start the backup. After a successful call, a backup id will be retrieved,
# so we can follow up the operation progress.
$authHeader = New-AzureRestAuthorizationHeader -ClientId $clientId -ClientKey $clientKey -TenantId $tenantId
$result = Invoke-RestMethod -Method Post -ContentType "application/json" -Header $authHeader -Body $body -Uri ($restUri + "/backup" + $apiVersion)
$backupId = $result.properties.Id

<# We will check each 60 seconds the progress. The operation status is determined by the 
following codes:

0 – InProgress: The backup has been started but has not yet completed.
1 – Failed: The backup was unsuccessful.
2 – Succeeded: The backup completed successfully.
3 – TimedOut: The backup did not finish in time and was canceled.
4 – Created: The backup request is queued but has not been started.
5 – Skipped: The backup did not proceed due to a schedule triggering too many backups.
6 – PartiallySucceeded: The backup succeeded, but some files were not backed up because they could not be read. This usually happens because an exclusive lock was placed on the files.
7 – DeleteInProgress: The backup has been requested to be deleted, but has not yet been deleted.
8 – DeleteFailed: The backup could not be deleted. This might happen because the SAS URL that was used to create the backup has expired.
9 – Deleted: The backup was deleted successfully.

Initially, operation starts as 4, and it will follow with 0 as it is in progress.
When the operation status is not 0, 4 or 7, we can consider the operation completed.
#>
do {
    Write-Output "Backup: $($result.properties.name); Status: $($result.properties.status)"
    Start-Sleep -Seconds 60
    $authHeader = New-AzureRestAuthorizationHeader -ClientId $clientId -ClientKey $clientKey -TenantId $tenantId
    $result = Invoke-RestMethod -Method GET -Header $authHeader -ContentType "application/json" -Uri ($restUri + "/backups/$backupId" + $apiVersion)
} while (($result.properties.status -eq 0) -or ($result.properties.status -eq 4))

# With the operation completed, we can now start the app again
Write-Output "Starting webapp..."
Start-WebApp
Write-Output $result

# Now is time to finalize by cleaning old backups in order to save space
# Default retention is 180 days
Write-Output "Cleanning old backup files..."
$isOldDate = [DateTime]::UtcNow.AddDays(-180)
Get-AzureStorageBlob -Context $context -Container calnus | Where-Object { $_.LastModified.UtcDateTime -lt $isOldDate -and $_.BlobType -eq "BlockBlob" } | Remove-AzureStorageBlob
Write-Output "Successfully cleaned"

Como se puede ver, vamos dejar los ensamblados de la ADAL bajo la ruta "C:\Modules\User\", el motivo lo veremos más adelante en al llegar a Automation. Toma nota porque es muy importante.

¿Se ha ejecutado correctamente el script? Este es el check-list:

  • El script no ha mostrado ningún error y al final ha indicado que la operación se ha completado.
  • Nuestra app se encuentra en estado Running, habiendo pasado previamente por Stopped.
  • Consultando el histórico de backups en el portal debemos ver ahora un fantástico Succeeded.

Backup succeeded

¡Es el momento de llevárnoslo a Azure Automation!

Implementando todo en Azure Automation

Azure Automation ha evolucionado bastante desde los últimos años con novedades muy interesantes, tales como la ejecución directa de scripts de PowerShell sin Workflow Foundation de por medio o el diseño gráfico de workflows.

¿Nuevo con Azure Automation? Hay muchísima documentación y videos explicativos aquí. Al principio puede parecer engorroso de entender, pero te aseguro que compensa de largo. Lo considero una tecnología básica en temas de infraestructura de Azure.

Si tenemos creada nuestra cuenta de Azure Automation, veremos que tenemos diferentes elementos a configurar:

Elementos en Azure Automation

Para que funcione nuestro script de backup necesitamos lo siguiente:

  • Un runbook, que contendrá ni más ni menos que un script muy similar al que hemos visto anteriormente.
  • Diversos assets, que deberemos poner a punto antes de subir nuestro script. Concretamente necesitaremos 1 schedule, 1 módulo adicional a los ya existentes, 1 conexión y 3 variables.
Poniendo a punto nuestros assets

Tipos de assets en Azure Automation

Modules

Empecemos por el más conflictivo, los módulos. No son ni más ni menos que las librerías y ensamblados a los que nuestro script va a tener acceso. Por defecto tenemos un set bastante bueno para operar con Azure, pero nos falta ADAL. Tenemos que subirla mediante la operación Add module.

La cuestión es que no se puede subir sin más, un módulo de Azure Automation debe cumplir las siguientes normas para que sea aceptado:

  • Debe estar empaquetado en ZIP, con compresión Deflate (la común).
  • Dentro del ZIP debe haber una carpeta, que debe llamarse exactamente igual que el ensamblado principal a subir al servicio.
  • Dentro de la carpeta ubicamos el o los ensamblados correspondientes.

En el caso presente, tiene esta pinta:

Microsoft.IdentityModel.Clients.ActiveDirectory.zip
|----- Microsoft.IdentityModel.Clients.ActiveDirectory
   |----- Microsoft.IdentityModel.Clients.ActiveDirectory.dll
   |----- Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll

Si está correctamente subido deberíamos poder ver algo similar a:
ADAL en Azure Automation

¿Ha ido bien? ¡Enhorabuena! Recuerda que los módulos se descomprimen en C:\Modules\User\

Connections y Variables

Son almacenes donde podemos almacenar credenciales de conexión a suscripciones de Azure y variables. Estos elementos son comunes y accesibles desde cualquier runbook de nuestra cuenta. Tienen dos finalidades muy importantes:

  • No tener datos sensibles de nuestras cuentas de acceso visibles a los ojos de cualquiera que vea nuestro script, lo cual sería absolutamente una mala práctica.
  • Poder modificar datos fácilmente sin recorrer todos los scripts que hacen uso de ellos.

Podéis utilizarlos como estiméis conveniente. Servidor ha almacenado una Connection con los datos de acceso a la suscripción (subscriptionId, tenantId, clientId) y tres variables, dos de ellas cifradas con las claves de acceso a la Storage Account y la app de Azure AD, y una variable más con versión de la API a utilizar.

Variables cifradas en Azure Automation

Schedule

Aquí agregaremos una programación para nuestra tarea. En mi caso se ejecuta todos los días a las 4:30 CET. Si alguien se siente con ganas, podrá ver que a esa hora y durante aproximadamente 5 minutos el blog está caído.

Subiendo nuestro script de PowerShell a Azure Automation

Con los assets ya creados, nada más sencillo que crear un nuevo runbook en blanco, especificando que queremos escribir código PowerShell, copiando y pegando nuestro script local al que le haremos unas modificaciones muy ligeras para retirar elementos de los assets, os pongo sólo la porción de código que cambiaría:

$conn = Get-AutomationConnection -Name '<Nombre de la conexión>'
$subscriptionId = $conn.subscriptionId
$tenantId = $conn.TenantId
$clientId = $conn.ApplicationId
$clientKey = Get-AutomationVariable -Name '<Nombre de la conexión>'
$webappName = "<Nombre de la webapp>"
$blobContainerName = "<Nombre del contenedor de la webapp>"
$resourceGroupName = "<Nombre del grupo de recursos>"
$storageAccountName = "<Nombre de la Storage Account>"
$storageAccountKey = Get-AutomationVariable -Name 'azcoolStorageAccountKey'
$restUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$webappName"
$apiVersion = "?api-version=" + $(Get-AutomationVariable -Name 'apiVersion')

¿Estamos listos? Presiona Test pane para ejecutar una prueba donde puedas ver la salida por pantalla. Realiza exactamente las mismas comprobaciones que hiciste cuando lo ejecutaste en local.

Si todo fue bien, haz clic en Publish para dejarlo listo para usar. Para terminar, vuelve a tu runbook y presiona en Schedule donde asignaremos la programación que creamos en los assets y... ¡nuestro script quedará listo para ejecutarse todas las noches.

La mejor forma de comprobar que todo fue bien es volver al histórico de backups y comprobar que tenemos una copia de seguridad hecha cerca de las 4:30 de la madrugada.

Backup a las 4:30 CET

¡Y eso es todo que no ha sido poco! No dudes en dejar un comentario si te ha resultado útil o si quieres aportar algo.

Happy Automation!


  1. Y si llega a superarse estaré encantado de buscar alternativas :) ↩︎