Serverless FrameworkでEC2のAMIバックアップを自動化


## TL;DR

* AWS Lambda を使うとサーバレスに AMI 作成を自動化できる
* Serverless Framework を使うと Lambda のコードを管理できる
* でもこのくらいのことは直で Lambda Function 作った方が早いと思います。 Serverless Framework を使ってみたかっただけです
* [ソース](https://github.com/boiyaa/serverless-ami)


## 手順

### 環境構築
* Node.js v4以上をインストール

* Serverless Frameworkをインストール

```shell-session
$ npm install -g serverless
```

* AWS IAMで、アクセス権限にAdministratorAccessを持つユーザを作り、アクセスキーIDとシークレットアクセスキーを用意

* プロバイダ証明書を設定

```shell-session
$ serverless config credentials --provider aws --key アクセスキーID --secret シークレットアクセスキー
```

### 実装
* serviceを作成

```shell-session
$ serverless create --template aws-nodejs --name ami
```

* 試しにデフォルトのfunctionをローカル実行してみる

```shell-session
$ serverless invoke local --function hello
{
    "statusCode": 200,
    "body": "{\"message\":\"Go Serverless v1.0! Your function executed successfully!\",\"input\":\"\"}"
}
```

* ES2015+対応用のwebpackプラグインとbabelと仲間たちをインストール

```shell-session
$ npm install babel-core babel-loader babel-preset-env serverless-webpack webpack --save-dev
```

* aws-sdkをインストール

```shell-session
$ npm install aws-sdk
```

* AMI作成して、期限切れAMI削除するfunctionを作る

```js:handler.js
import AWS from 'aws-sdk';

const AWS_REGION = 'ap-northeast-1'; // リージョン
const AMI_RETENTION_PERIOD = 7; // 保管日数

AWS.config.region = AWS_REGION;
const ec2 = new AWS.EC2();

/**
 * List EC2 instances
 * @return {Promise.<Array>} instances
 */
const listInstances = () => {
  console.log('listInstances');

  // describeInstances
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#describeInstances-property
  return ec2.describeInstances({
    Filters: [{ Name: 'tag:Backup', Values: ['yes'] }],
  }).promise()
  .then((data) => {
    const instances = data.Reservations.length === 0 ? data.Reservations : data.Reservations
    .map(reservation => reservation.Instances.map(instance => ({
      InstanceId: instance.InstanceId,
      Tags: instance.Tags,
    })))
    .reduce((previousValue, currentValue) => previousValue.concat(currentValue));

    return instances;
  });
};

/**
 * Create AMIs
 * @param {Array} instances
 * @returns {Promise.<Array>} AMIs
 */
const createImages = (instances) => {
  console.log('createImages target instances =', JSON.stringify(instances));

  return Promise.all(instances.map((instance) => {
    const name = instance.Tags.some((tag) => {
      if (tag.Key === 'Name') {
        return true;
      }
      return false;
    }) ? instance.Tags.find((tag) => {
      if (tag.Key === 'Name') {
        return true;
      }
      return false;
    }).Value : instance.InstanceId;

    // createImage
    // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#createImage-property
    return ec2.createImage({
      InstanceId: instance.InstanceId,
      Name: `${name} on ${new Date().toDateString()}`,
      NoReboot: true,
    }).promise();
  }));
};

/**
 * Create Tags
 * @param {array} images - AMIs
 * @returns {Promise.<Array>} null
 */
const createTags = (images) => {
  console.log('createTags target images =', JSON.stringify(images));

  // createTags
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#createTags-property
  return Promise.all(images.map(image => ec2.createTags({
    Resources: [image.ImageId],
    Tags: [{ Key: 'Delete', Value: 'yes' }],
  }).promise()));
};

/**
 * List expired AMIs
 * @return {Promise.<Array>} AMIs
 */
const listExpiredImages = () => {
  console.log('listExpiredImages');

  // describeImages
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#describeImages-property
  return ec2.describeImages({
    Owners: ['self'],
    Filters: [{ Name: 'tag:Delete', Values: ['yes'] }],
  }).promise()
  .then((data) => {
    const expiredImages = data.Images
    .filter((image) => {
      const creationDate = new Date(image.CreationDate);
      const expirationDate = new Date(Date.now() - (86400000 * AMI_RETENTION_PERIOD));

      if (creationDate < expirationDate) {
        return true;
      }
      return false;
    })
    .map(image => ({
      ImageId: image.ImageId,
      CreationDate: image.CreationDate,
      BlockDeviceMappings: image.BlockDeviceMappings.map(mapping => ({
        Ebs: { SnapshotId: mapping.Ebs.SnapshotId },
      })),
    }));

    return expiredImages;
  });
};

/**
 * Delete AMIs
 * @param {Array} images - AMIs
 * @returns {Promise.<Array>} block device mappings
 */
const deleteImages = (images) => {
  console.log('deleteImages target images =', JSON.stringify(images));

  // deregisterImage
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#describeImages-property
  return Promise.all(images.map(image => ec2.deregisterImage({
    ImageId: image.ImageId,
  }).promise()))
  .then(() => {
    const mappings = images.length === 0 ? images :
    images
    .map(image => image.BlockDeviceMappings)
    .reduce((previousValue, currentValue) => previousValue.concat(currentValue));

    return mappings;
  });
};

/**
 * Delete Snapshots
 * @param {Array} mappings - block device mappings
 * @returns {Promise.<Array>} null
 */
const deleteSnapshots = (mappings) => {
  console.log('deleteSnapshots target mappings =', JSON.stringify(mappings));

  // deleteSnapshot
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html#deleteSnapshot-property
  return Promise.all(mappings.map(mapping => ec2.deleteSnapshot({
    SnapshotId: mapping.Ebs.SnapshotId,
  }).promise()));
};

/**
 * Lambda function: create AMIs and delete expired AMIs
 */
const createAndDeleteAMI = () => {
  listInstances()
  .then(instances => createImages(instances))
  .then(images => createTags(images))
  .then(() => listExpiredImages())
  .then(images => deleteImages(images))
  .then(mappings => deleteSnapshots(mappings))
  .then(() => console.log('Done'))
  .catch(err => console.error(err));
};

export { createAndDeleteAMI };
```

* webpack設定

```js:webpack.config.js
module.exports = {
  entry: './handler.js',
  target: 'node',
  // AWS Lambda Available Libraries
  externals: { 'aws-sdk': 'aws-sdk' },
  module: {
    loaders: [{
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel',
    }],
  },
};
```

* Babel設定

```json:.babelrc
{
  "presets": [
    ["env", {
      /* AWS Lambda Execution Environment */
      "targets": { "node": "4.3.2" },
      "include": ["transform-es2015-destructuring"]
    }]
  ]
}
```
LambdaのNodeバージョンは4.3.2だが、targets設定するだけだとシンタックスエラーが出てしまった

* Serverless Framework設定  

```yml:serverless.yml
service: ami

plugins:
  - serverless-webpack

provider:
  name: aws
  runtime: nodejs4.3
  region: ap-northeast-1
  iamRoleStatements:
    - Effect: Allow
      Action:
        - ec2:DescribeInstances
        - ec2:CreateImage
        - ec2:CreateTags
        - ec2:DescribeImages
        - ec2:DeregisterImage
        - ec2:DeleteSnapshot
      Resource: "*"

functions:
  createAndDeleteAMI:
    handler: handler.createAndDeleteAMI
    events:
      - schedule: cron(0 0 * * ? *) # 毎日0時に実行
```
スケジュール構文は[こちら](http://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html)を参考に

### 確認・デプロイ
* ローカルで動作確認してみる

```shell-session
$ serverless webpack invoke --function createAndDeleteAMI
```

* デプロイ

```shell-session
$ serverless deploy
```

* Lambda 上で動作確認してみる

```shell-session
$ serverless invoke --function createAndDeleteAMI --log
```


## 参考

http://blog.powerupcloud.com/2016/10/15/serverless-automate-ami-creation-and-deletion-using-aws-lambda/
https://github.com/serverless/serverless
https://github.com/serverless/examples
https://github.com/americansystems/serverless-es6-jest
https://github.com/elastic-coders/serverless-webpack
https://babeljs.io/docs/plugins/preset-env/
https://github.com/apex/apex/issues/217#issuecomment-194247472
http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/EC2.html
https://serverless.com/framework/docs/providers/aws/guide/functions#permissions
https://serverless.com/framework/docs/providers/aws/events/schedule/