SlackからRailsアプリケーションをAWS ECS Fargateへデプロイする手順

# はじめに

本記事では、Ruby on Railsで構築したアプリケーションをSlack経由でデプロイする方法について紹介します。

従来の方法では、AWSマネジメントコンソールから直接CodeDeployを実行したり、インスタンスにSSHで接続してコマンドを実行する必要がありました。
これは、新しく参加するメンバーやQAエンジニアにとっては複雑で手間のかかる手順でした。

そこで、より簡単かつ効率的にデプロイを行えるよう、Slackを利用したデプロイ方法に構成を変更しました。

はじめに、完成した構成の概要を以下に図示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031807/3110530b-ccdb-4f1e-83d8-e7b3d7b38907.png)

左側から順に、以下のフローが取られます。
1. Slackからチャット形式でデプロイコマンドを投稿
1. AWS Lambdaに適用したSlack boltがデプロイコマンドを処理
1. Fargateのタスクを置換


以降の項目では、各レイヤーの構成について説明します。

# Railsアプリケーションの構成について
赤枠で示された部分の、Railsアプリケーションの構成について説明します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031807/b99d0d31-b377-a734-1670-6ffe24bf1b96.png)

このセクションでは、RailsアプリケーションがAWS Fargateにデプロイされる構成について説明します。
Fargateのタスク定義ではAmazon ECRを参照しており、Railsアプリケーションのイメージをpushしています。
Fargateではローリングアップデートを採用し、タスク定義を更新することでデプロイを実現しています。

Railsの基本的な使い方については割愛します。

# Slack boltの設定と開発

次に、Slackの設定について説明します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031807/8142b5a9-e42d-f973-5222-9174f1a205a0.png)

Slack Appの設定は以下のリンクを参照して行ってください。
- https://slack.dev/bolt-js/ja-jp/tutorial/getting-started
ここで作成したアプリの認証情報とトークンは、Boltで参照します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031807/1d4d1869-caa5-6808-aeb7-2089b4ddf6e8.png)


# Lambda, Boltのデプロイ処理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3031807/ebc5273e-be1a-fb17-c78a-aa42a5408d9a.png)


###  初回設定
下記を参考にLambdaにBoltを構築します。

https://slack.dev/bolt-js/ja-jp/deployments/aws-lambda

Lambdaの前面にはApi Gatewayを設置し、Slack Appとの疎通を確認してください。

### デプロイコマンドの処理
Slackから受け取ったデプロイコマンドを処理します。
次のようなテキストを受け取る想定です。

コマンド形式：
- `@release [command] [project] [target] [image-tag]`

コマンド例：
- `@release deploy hogehoge stage hogehoge-1234-branch-name`
- `@release deploy piyopiyo production v1.0`

:::note info
コマンド形式の補足です。
@ release ... Slackアプリ名
deploy ... コマンド種別
project ... 複数のプロジェクトが存在するため、プロジェクト名を明示的に指定
target ... 検証環境、本番環境を指定
image-tag ... Amazon ECRにpushしたイメージのタグ名を指定
:::

続いて、コード例を説明します。

### Slack　BoltとAWS SDKの初期設定

```javascript
const { App, AwsLambdaReceiver } = require('@slack/bolt');
const AWS = require('aws-sdk');

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET
});
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver
});
```

### Slackのリトライ処理の無視
Slackんｐリトライ処理を無視しています。
Slack apiは3秒以内にレスポンスを返さないとリトライする仕様になっていますが
Lambdaの場合は3秒以内にレスポンスを返すこと保証できないため、リトライ処理を明示的に無視しています。

```javascript
app.use(async ({ context, next, ack }) => {
  if (
    context.retryNum &&
    context.headers['X-Slack-Retry-Reason'] === 'http_timeout'
  ) {
    await ack?.();
    return;
  }

  await next();
});
```

### メンションイベントの処理
Slackからアプリがメンションされた際に動作する処理を記載します。

```javascript
app.event('app_mention', async ({ event, say }) => {
  try {
    const { channel, event_ts, text } = event;
    const message = await handleCommand(event, say);

    await say({
      channel,
      thread_ts: event_ts,
      text: "say :wave: \n> command: " + text + "\n> messages: " + message
    });
  } catch (error) {
    console.error(error);
  }
});
```

### AWS Lambda ハンドラ
AWS Lambdaのエントリポイント部分です。
```javascript
exports.lambda_handler = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start();
  return handler(event, context, callback);
};
```

### コマンド処理
メンションを受け取ったあとのコマンドを処理します。
ここでは、コマンドの解析、認証、ECSとECRへの通信、タスク定義の更新などの処理を行います。
エラーハンドリング等の処理は省略しています。

```javascript
async function handleCommand(event, say) {
  console.log(event);
  const { channel, event_ts, text, user } = event;
  // @ release [command] [project] [target] [image-tag]
  // 例） @ release deploy projectA stage hogehoge-image
  const textWithoutMention = text.replace(/^<@(.+?)>/, '').trim();
  const [command, project, target, imageTag] = textWithoutMention.split(' ');

  let message = '';

  // チャンネルごとに実行できるユーザーとコマンドを指定したホワイトリスト
  const allowUsersAndCommands = {
    'AAAAAAAAAA': { // #projectA-stage
      users: [
        'XXXXXXXXX',   // @ userA
        'YYYYYYYYY',   // @ userB
        'ZZZZZZZZZ',   // @ userC
      ],
      commands: {
        'deploy': ['stage', 'stage2'],
      },
    },
    'BBBBBBBBBB': { // #projectA-production
      users: [
        'XXXXXXXXX',   // @ userA
      ],
      commands: {
        'deploy': ['production'],
      },
    }
  };

  const ecs = new AWS.ECS();
  const ecr = new AWS.ECR();

  switch (command) {
    case 'deploy':
      await say({
        channel,
        thread_ts: event_ts,
        text: 'デプロイを開始します🚀',
      });
      switch (project) {
        case 'projectA':
          switch (target) {
            case 'stage':
            case 'stage2':
              const taskDefinitionName = `projectA-${target}-ecs-task-def`;
              const clusterName = `projectA-${target}-ecs-cluster`;
              const serviceName = `projectA-${target}-ecs-service`;

              // ECSタスク定義を更新する
              const taskDefinition = await ecs.describeTaskDefinition({
                taskDefinition: taskDefinitionName,
              }).promise();
              
              const newTaskDefinition = taskDefinition.taskDefinition;
              const describeImages = await ecr.describeImages({
                repositoryName: 'aaaaaaaaaaaaaaa',
                registryId: 'zzzzzzzzzzzzzz',
              }).promise();
              const imageTags = describeImages.imageDetails.map(image => image.imageTags).flat();

              newTaskDefinition.containerDefinitions[0].image = `zzzzzzzzzzzzzz.dkr.ecr.ap-northeast-1.amazonaws.com/aaaaaaaaaaaaaaa:${imageTag}`;

              // newTaskDefinitionのうち、不要項目はバリデーションに引っかかるため削除する
              delete newTaskDefinition.taskDefinitionArn;
              delete newTaskDefinition.status;
              delete newTaskDefinition.revision;
              delete newTaskDefinition.requiresAttributes;
              delete newTaskDefinition.compatibilities;
              delete newTaskDefinition.registeredAt;
              delete newTaskDefinition.registeredBy;

              // タスク定義を登録する
              await ecs.registerTaskDefinition(newTaskDefinition).promise();

              // ECSサービスを更新する
              await ecs.updateService({
                cluster: clusterName,
                service: serviceName,
                taskDefinition: `arn:aws:ecs:ap-northeast-1:zzzzzzzzzzzzzz:task-definition/${taskDefinitionName}`
              }).promise();

              // ECSサービスが完全に更新されるまで待機する
              await ecs.waitFor('servicesStable', {
                cluster: clusterName,
                services: [serviceName],
              }).promise()
              message = 'デプロイが完了しました🎉';
              break;

~~~~省略~~~~~~
```

# まとめ

Slack boltを使ってチャット形式でデプロイする構成ができました。
新規で参加していただくメンバーやQAエンジニアの方でも手軽にデプロイできる環境になり、CD面での作業が高速化しました。
今後はdeployコマンド以外の拡張や、各プロジェクトに適用、展開を考えています。