Amazon LightsailのWindowsインスタンスをLambdaで日次スナップショット作成する

Amazon Lightsailにはインスタンスの自動スナップショット機能がありますが、Windowsインスタンスには対応していません。AWS Lambda関数を使って日次スナップショットの作成を自動化してみました。なお、この方法で作成されるスナップショットには制約が発生するのでご注意ください。
2022.01.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、加藤です。LightsailのWindowsインスタンスは手動でのスナップショットには対応していますが、自動でのスナップショットには対応していません。また、手動でスナップショットを作成する際にはインスタンスを停止することが必要(APIアクセスでは可能)で直前にSysprepを行うことがドキュメントで指示されています。なので、自動スナップショットが必要な場合はLinuxインスタンスへの移行やインスタンスからステートフルな要素を切り出して自動スナップショットが不要な構成に変更する必要があります。とはいえ、プロジェクトに避ける時間上、これらの方法をすぐに行うのが難しかったりする場合があります。

この課題に対して、Lambda関数を作成しEventBridgeから日次トリガーすることで対処してみました。紹介しているコードはブログとして見やすいように単一ファイルにまとめており、またコードのテストは削除対象のスナップショット抽出に対する正常系ユニットテストしか行っていないためご注意ください。特に古いスナップショットが正常に削除されているかは必ずご確認ください。

Lightsail Windowsインスタンスからスナップショットを作成する為には直前にSysprepを実行するドキュメントで指示されています。しかし、日次バックアップの為にSysprepは出来ない為、行っていません。そのため作成されるスナップショットには以下のの問題が発生します。

作成されたスナップショットから作成したインスタンスはLightsailによってパスワードの設定が出来ないため、WebブラウザからRDPを実行するとパスワードを求められます。オリジナルインスタンスのパスワードを入力して接続してください。前述の通りSysprepを行わずにスナップショットを作成することは想定されていないので、このインスタンスは永続的に使用せずにデータのレスキューにのみ使用してください。

Lambda実行ロールの作成

Lambda関数からLightsailの操作を許可する為のIAMロールを作成します。IAMロールの画面を開き、ロールを作成をクリックします。

ユースケースにLambdaを選択し、次のステップに進みます。

Lambdaの実行ログ記録などを許可するために、LambdaExecuteとフィルタに入力し、AWSLambdaExecuteを選択して次のステップに進みます。

タグは割り当てずに次のステップに進みます。

ロール名をLambda-Lightsail-Create-Snapshotとして、ロールを作成します。

作成されたロールにはまだLightsailにアクセスするためのポリシーが設定されていません。フィルタに先ほど作成したロール名を入力し、ロール名をクリックします。

インラインポリシーの追加をクリックします。

JSONタブを選択して下記のポリシーを入力し、ポリシーの確認をクリックします。

{
 "Version": "2012-10-17",
 "Statement": [
  {
   "Effect": "Allow",
   "Action": ["lightsail:*"],
   "Resource": "*"
  }
 ]
}

ポリシー名を**Lightsail**としてポリシーを作成します。

Lambda関数の作成

Lambda関数の画面を開いて、関数の作成をクリックします。

下記のようにパラメータを設定して関数を作成します。アーキテクチャはどちらもで動くので費用の安いarm64を選んでいます。

項目
関数名 Lightsail-Create-Snapshot
ランタイム Node.js 14.x
アーキテクチャ arm64
実行ロール 既存のロールを使用する
既存のロール Lambda-Lightsail-Create-Snapshot(作成したロール)

作成した関数の設定を変更します。設定タブの一般設定を編集します。

タイムアウトを15分にします。インスタンスの停止を待つ処理で完了をポーリングしているので、最長のタイムアウトを設定しています。

設定タブの環境変数を編集します。

キー 値(例) 説明
INSTANCE_NAME CreateSnapshot-Test スナップショットを作成したいインスタンス名
RETENTION_MONTH 1 スナップショットを保持するヶ月数

コードタブを開いて、index.jsを下記に書き換えて、Deployをクリックします。

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.handler = exports.listExpiredSnapshots = void 0;
const aws_sdk_1 = require("aws-sdk");
const lightsail = new aws_sdk_1.Lightsail();
const sleep = (msec) => {
    return new Promise(resolve => setTimeout(resolve, msec));
};
const getFormatedDate = (date) => date.getFullYear() +
    ('0' + (date.getMonth() + 1)).slice(-2) +
    ('0' + date.getDate()).slice(-2) +
    ('0' + date.getHours()).slice(-2) +
    ('0' + date.getMinutes()).slice(-2) +
    ('0' + date.getSeconds()).slice(-2) +
    date.getMilliseconds();
const listExpiredSnapshots = async ({ instanceName, retentionMonth, }) => {
    const allSnapshots = [];
    let pageToken;
    do {
        const { instanceSnapshots, nextPageToken } = await lightsail
            .getInstanceSnapshots({ pageToken })
            .promise();
        if (instanceSnapshots !== undefined) {
            allSnapshots.push(...instanceSnapshots);
        }
        pageToken = nextPageToken;
    } while (pageToken !== undefined);
    const now = new Date();
    const retention = now.setMonth(now.getMonth() - retentionMonth);
    const snapshots = allSnapshots
        .filter(({ fromInstanceName, createdAt, state }) => fromInstanceName === instanceName &&
        createdAt.getTime() < retention &&
        state === 'available')
        .sort((a, b) => {
        if (a.createdAt === undefined || b.createdAt === undefined) {
            return 0;
        }
        return a.createdAt.getTime() - b.createdAt.getTime();
    });
    return snapshots;
};
exports.listExpiredSnapshots = listExpiredSnapshots;
const createSnapshot = async (instanceName) => {
    console.debug(`create a snapshot of instance: ${instanceName}`);
    await lightsail
        .createInstanceSnapshot({
        instanceName,
        instanceSnapshotName: `${instanceName}-${getFormatedDate(new Date())}`,
    })
        .promise();
};
const stopInstance = async (instanceName) => {
    var _a, _b;
    console.debug(`stop instance: ${instanceName}`);
    await lightsail.stopInstance({ instanceName }).promise();
    let state;
    do {
        await sleep(5 * 1000);
        const resp = await lightsail.getInstanceState({ instanceName }).promise();
        state = (_b = (_a = resp.state) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : '';
    } while (state !== 'stopped');
};
const startInstance = async (instanceName) => {
    console.debug(`start instance: ${instanceName}`);
    await lightsail.startInstance({ instanceName }).promise();
};
const deleteSnapshots = async (snapshots) => {
    if (snapshots.length > 0) {
        console.debug(`delete these snapshots: ${snapshots.map(({ name, createdAt, arn }) => JSON.stringify({
            name,
            arn,
            createdAt,
        }))}`);
    }
    await Promise.all(snapshots.map(async ({ name }) => {
        if (name !== undefined) {
            return lightsail
                .deleteInstanceSnapshot({ instanceSnapshotName: name })
                .promise();
        }
        return;
    }));
};
const handler = async () => {
    var _a;
    const instanceName = process.env.INSTANCE_NAME;
    if (instanceName === undefined) {
        throw new Error('INSTANCE_NAME is not defined');
    }
    const retentionMonth = parseInt((_a = process.env.RETENTION_MONTH) !== null && _a !== void 0 ? _a : '');
    if (isNaN(retentionMonth) === true) {
        throw new Error('RETENTION_MONTH is not defined');
    }
    if (retentionMonth < 1) {
        throw new Error('RETENTION_MONTH must be greater than 1');
    }
    if (retentionMonth)
        await stopInstance(instanceName);
    await createSnapshot(instanceName);
    await startInstance(instanceName);
    const expiredSnapshots = await (0, exports.listExpiredSnapshots)({
        instanceName,
        retentionMonth,
    });
    await deleteSnapshots(expiredSnapshots);
};
exports.handler = handler;

上記はTypeScriptで書いたコードをトランスコンパイルしており、オリジナルは下記です。

index.ts
import {Lightsail} from 'aws-sdk';

const lightsail = new Lightsail();

const sleep = (msec: number) => {
  return new Promise(resolve => setTimeout(resolve, msec));
};

const getFormatedDate = (date: Date) =>
  date.getFullYear() +
  ('0' + (date.getMonth() + 1)).slice(-2) +
  ('0' + date.getDate()).slice(-2) +
  ('0' + date.getHours()).slice(-2) +
  ('0' + date.getMinutes()).slice(-2) +
  ('0' + date.getSeconds()).slice(-2) +
  date.getMilliseconds();

export const listExpiredSnapshots = async ({
  instanceName,
  retentionMonth,
}: {
  instanceName: string;
  retentionMonth: number;
}) => {
  const allSnapshots: Lightsail.InstanceSnapshot[] = [];
  let pageToken: string | undefined;

  do {
    const {instanceSnapshots, nextPageToken} = await lightsail
      .getInstanceSnapshots({pageToken})
      .promise();
    if (instanceSnapshots !== undefined) {
      allSnapshots.push(...instanceSnapshots);
    }
    pageToken = nextPageToken;
  } while (pageToken !== undefined);

  const now = new Date();
  const retention = now.setMonth(now.getMonth() - retentionMonth);

  const snapshots = allSnapshots
    .filter(
      ({fromInstanceName, createdAt, state}) =>
        fromInstanceName === instanceName &&
        createdAt!.getTime() < retention &&
        state === 'available'
    )
    .sort((a, b) => {
      if (a.createdAt === undefined || b.createdAt === undefined) {
        return 0;
      }
      return a.createdAt.getTime() - b.createdAt.getTime();
    });

  return snapshots;
};

const createSnapshot = async (instanceName: string) => {
  console.debug(`create a snapshot of instance: ${instanceName}`);
  await lightsail
    .createInstanceSnapshot({
      instanceName,
      instanceSnapshotName: `${instanceName}-${getFormatedDate(new Date())}`,
    })
    .promise();
};

const stopInstance = async (instanceName: string) => {
  console.debug(`stop instance: ${instanceName}`);
  await lightsail.stopInstance({instanceName}).promise();

  let state: string;
  do {
    await sleep(5 * 1000);
    const resp = await lightsail.getInstanceState({instanceName}).promise();
    state = resp.state?.name ?? '';
  } while (state !== 'stopped');
};

const startInstance = async (instanceName: string) => {
  console.debug(`start instance: ${instanceName}`);
  await lightsail.startInstance({instanceName}).promise();
};

const deleteSnapshots = async (snapshots: Lightsail.InstanceSnapshot[]) => {
  if (snapshots.length > 0) {
    console.debug(
      `delete these snapshots: ${snapshots.map(({name, createdAt, arn}) =>
        JSON.stringify({
          name,
          arn,
          createdAt,
        })
      )}`
    );
  }
  await Promise.all(
    snapshots.map(async ({name}) => {
      if (name !== undefined) {
        return lightsail
          .deleteInstanceSnapshot({instanceSnapshotName: name})
          .promise();
      }
      return;
    })
  );
};

export const handler = async () => {
  const instanceName = process.env.INSTANCE_NAME;
  if (instanceName === undefined) {
    throw new Error('INSTANCE_NAME is not defined');
  }
  const retentionMonth = parseInt(process.env.RETENTION_MONTH ?? '');
  if (isNaN(retentionMonth) === true) {
    throw new Error('RETENTION_MONTH is not defined');
  }
  if (retentionMonth < 1) {
    throw new Error('RETENTION_MONTH must be greater than 1');
  }

  if (retentionMonth) await stopInstance(instanceName);
  await createSnapshot(instanceName);
  await startInstance(instanceName);
  const expiredSnapshots = await listExpiredSnapshots({
    instanceName,
    retentionMonth,
  });
  await deleteSnapshots(expiredSnapshots);
};

スケジュール実行の設定

作成したLambda関数を定期実行することで、日次スナップショットを実現します。Lambda関数の設定タブのトリガーを開き、トリガーを追加をクリックします。

トリガーを下記のように設定します。実行タイミングはAM 04:00(JST)にしたいので、UTCに変換してcron式を書いています。

項目
トリガー Eventbridge
ルール 新規ルールの作成
ルール名 Lightsail-Create-Snapshot
ルールタイプ スケジュール式
スケジュール式 cron(0 19 * * ? *)

あとがき

作成されたスナップショットに制約が出るなど若干無理矢理感がありますが、Lightsail Windowsインスタンスでも日次スナップショットを実現できました。インスタンスの停止や作成されるスナップショットの制約に不都合がある場合は、OSやアーキテクチャの変更を検討頂くと良いと思います。

以上です!