Pages

Monday, March 23, 2020

CapRover deployment controlled database migration

I started using CapRover recently for running first party and third party web services. CapRover lets me set up apps with HTTPS enabled, certificates automatically issued by Let's Encrypt, and I can scale instances up or down any time, on a single machine or even across multiple machines.

As of version 1.6.1 I have not found any built-in functionality that supports more complex deployments, e.g. when you have multiple app instances and you need to migrate a database before deploying a new version of the app.

I thought about writing a pre-deploy script but I spent a few days exploring other solutions first, including trying other PaaS like Dokku, but after all I like the convenience of CapRover. Then I came over a comment in one of their issues on GitHub which gave me enough hints to get started on my own solution.

What my script does:

  1. Sets the instance count of the app to 0 to avoid the old version of the app from accessing the database during or after the migration.
  2. Runs database migration by appending --migrate-database to the entrypoint specified in my Dockerfile. The app understands this. For your information, the app is an ASP.NET Core Web API app that uses Entity Framework Core. The new Docker container is also automatically removed.
  3. Passes in all of the environment variables specified for the app and uses the same network so that the database server can be reached at srv-captain--*.

Next I'll have to think about how to back up the database before migration. This can however work well enough for rapid deployment to a test/staging environment.

var preDeployFunction = async function (captainAppObj, dockerUpdateObject) {
    const DockerApi = require("./built/docker/DockerApi");
    const api = new DockerApi.default();

    const setServiceInstances = async (service, count) => {
        const inspection = await service.inspect();
        const updateObject = { ...inspection.Spec, Mode: { Replicated: { Replicas: count } }, version: inspection.Version.Index };
        await service.update(updateObject);
    };

    const run = async args => {
        const imageName = dockerUpdateObject.TaskTemplate.ContainerSpec.Image;
        const env = captainAppObj.envVars.map(kv => kv.key + "=" + kv.value);
        const config = { Env: env, HostConfig: { AutoRemove: true, NetworkMode: captainAppObj.networks[0] } };

        const {output} = await api.dockerode.run(imageName, args, process.stdout, config);

        if (output.StatusCode !== 0) {
            throw new Error(`Failed to run image ${imageName} with args ${args} (status code ${output.StatusCode}).`);
        }
    };

    const service = api.dockerode.getService(dockerUpdateObject.Name);
    await setServiceInstances(service, 0);
    await run(["--migrate-database"]);
    dockerUpdateObject.version = (await service.inspect()).Version.Index;

    return dockerUpdateObject;
};