検証環境をシームレスに運用するための仕組みづくり

# はじめに

この記事はコインチェック株式会社（以下、コインチェック）のアドベントカレンダー15日目（シリーズ1）の記事です。 [コインチェック Advent Calendar 2024](https://qiita.com/advent-calendar/2024/coincheck)

こんにちは。コインチェックのアプリケーション基盤グループ所属の大窪です。

今回はアドベントカレンダーの流れに便乗して、直近行っている「検証環境を扱いやすくするための取り組み」についてご紹介したいと思います。

:::note info
当記事で取り上げている検証環境は社内や限られた接続先のみからの接続を可能としています。
本番環境とも切り離されており、顧客情報へのアクセスや本番リソースへの接続を行うことはありません。
:::

# 要約

* アプリケーションエンジニア用の検証環境を便利に使うための仕組みづくりをしました
* CircleCI をはじめとした外部サービスと連動させることで検証環境への自動デプロイや、chatops でのインタラクティブな自動化を進めています
* 既存の AWS アカウントに上記のような機能拡張を図っていく際のポイントをまとめています

# コインチェックの検証環境について

コインチェックでは各チーム/プロジェクトによる機能開発が日々 10 〜 20 並行して進んでおり、施策ごとの検証環境インスタンスはマニュアルに則った手作業（スクリプトの実行）で立ち上げています。

アプリケーション基盤グループでは、この秋より検証環境の検証品質向上を主な狙いとして

* トイルを削減することによる開発体験の向上
* 一貫した仕組み化を進めることによる冪等性や安定性の向上

などの取り組みを始めました。

そのうちの一つとして、CI との連動によって各 branch が動く環境の自動デプロイと、その構成についてご紹介します。

# 構成図

![AWS_検証環境_構成図-ページ2.drawio (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1101/d123b010-70cb-381d-f496-175f8a29f80d.png)

元々この開発・検証環境用アカウントはメインとなる VPC 上に RDS や検証環境インスタンスが立ち上がっており、今回の取り組みを始めた際にもその利用方法に則って運用されていました。

（前提として、現時点で Coincheck のサービスは EC2 インスタンスで運用しています。）

今回は新規に VPC を作成し、新 VPC から既存 VPC への片方向 peering を許可することで、大きく構成を変更することなく各種オペレーションが実行できるようにしています。

この取り組みの初期方針では新規 VPC 内に新しい基準で検証環境類をフルセットで用意しようと考えていましたが、結果として元の VPC にそれほど手を加えることなく運用フローの切り替えを進めることができています。

# 構成管理

構成管理には Terraform を利用し、ローカルから AWS アカウントへの構成の反映が一貫して簡便に行えるようにしています。なお Lambda のデプロイも Terraform に載せることで、反映手順をできる限りシンプルにしています。我々アプリケーション基盤グループはインフラを扱うことを主としたチームではないため、こういった環境構築とその後のメンテナンスについてもやり易さを考慮しておくことを意識しています。

以下は API Gateway と Lambda に関するコードの一部です。

```iam_role.tf
/*
 * API Gateway で使用する IAM ロールの定義
 */

resource "aws_iam_role" "api_gateway_role" {
  name               = "apigateway_role"
  assume_role_policy = data.aws_iam_policy_document.api_gateway_assume_role.json
}

resource "aws_iam_role_policy_attachment" "api_gateway_policy_logs" {
  role       = aws_iam_role.api_gateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
}

resource "aws_iam_role_policy_attachment" "api_gateway_policy_lambda" {
  role       = aws_iam_role.api_gateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaRole"
}

data "aws_iam_policy_document" "api_gateway_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"

    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "api_gateway_resource_policy" {
  statement {
    effect = "Allow"

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    actions   = ["execute-api:Invoke"]
    resources = ["${aws_api_gateway_rest_api.hub_api.execution_arn}/*"]

    // 該当する IP のみを許容するルール
    condition {
      test     = "IpAddress"
      variable = "aws:SourceIp"
      values = concat(
        ... (自社内 IP リスト群) ...,
        ... (外部連携対象の IP リスト群) ...,

        // CircleCI が公開している IP(https://circleci.com/docs/ja/ip-ranges/)
        var.cidr_blocks_external_circleci,
      )
    }
  }
}
```
```api_gateway.tf
/*
 * API Gateway "hub_api" の定義
 */

resource "aws_api_gateway_rest_api" "hub_api" {
  name        = "hub_api"
  description = "検証環境の実行用API"
}

resource "aws_api_gateway_rest_api_policy" "hub_api" {
  rest_api_id = aws_api_gateway_rest_api.hub_api.id
  policy      = data.aws_iam_policy_document.api_gateway_resource_policy.json
}

/*
 * API Gateway "hub_api" の path:/develop/invoke POST メソッド、およびその一連の定義
 */

resource "aws_api_gateway_resource" "hub_api_invoke" {
  rest_api_id = aws_api_gateway_rest_api.hub_api.id
  parent_id   = aws_api_gateway_rest_api.hub_api.root_resource_id
  path_part   = "invoke"
}

resource "aws_api_gateway_method" "hub_api_invoke" {
  rest_api_id      = aws_api_gateway_rest_api.hub_api.id
  resource_id      = aws_api_gateway_resource.hub_api_invoke.id
  http_method      = "POST"
  authorization    = "NONE"
  api_key_required = true
}

resource "aws_api_gateway_method_response" "hub_api_invoke" {
  rest_api_id = aws_api_gateway_rest_api.hub_api.id
  resource_id = aws_api_gateway_resource.hub_api_invoke.id
  http_method = aws_api_gateway_method.hub_api_invoke.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
  depends_on = [aws_api_gateway_method.hub_api_invoke]
}

resource "aws_api_gateway_integration" "hub_api_invoke" {
  rest_api_id             = aws_api_gateway_rest_api.hub_api.id
  resource_id             = aws_api_gateway_resource.hub_api_invoke.id
  http_method             = aws_api_gateway_method.hub_api_invoke.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.hub_api.invoke_arn
}

/*
 * API Gateway の "develop" ステージと、更新トリガー、ログの定義
 */

locals {
  // 関連ファイルが更新されていた際にデプロイされる
  trigger_file_paths = [
    "api_gateway.tf",
    "../../lambda/HubApi/index.js"
  ]

  trigger_files_sha1 = sha1(join("",
    [for file in local.trigger_file_paths : filesha1(file)]
  ))
}

resource "aws_api_gateway_deployment" "hub_api" {
  rest_api_id = aws_api_gateway_rest_api.hub_api.id
  depends_on  = [aws_api_gateway_integration.hub_api_invoke]

  triggers = {
    redeployment = local.trigger_files_sha1
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "hub_api_develop" {
  deployment_id = aws_api_gateway_deployment.hub_api.id
  rest_api_id   = aws_api_gateway_rest_api.hub_api.id
  stage_name    = "develop"

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.hub_api_accesslog_develop.arn
    format = jsonencode({
      caller         = "$context.identity.caller"
      httpMethod     = "$context.httpMethod"
      ip             = "$context.identity.sourceIp"
      protocol       = "$context.protocol"
      requestId      = "$context.requestId"
      requestTime    = "$context.requestTime"
      resourcePath   = "$context.resourcePath"
      responseLength = "$context.responseLength"
      status         = "$context.status"
      user           = "$context.identity.user"
    })
  }
}

resource "aws_api_gateway_method_settings" "hub_api" {
  rest_api_id = aws_api_gateway_rest_api.hub_api.id
  stage_name  = aws_api_gateway_stage.hub_api_develop.stage_name
  method_path = "*/*"

  settings {
    data_trace_enabled = true
    logging_level      = "INFO"
  }
}

resource "aws_cloudwatch_log_group" "hub_api_accesslog_develop" {
  name = "hub_api_accesslog_develop"
}

/*
 * API キーに必要なリソースの定義
 */

resource "aws_api_gateway_api_key" "hub_api" {
  name    = "hub_api_key"
  enabled = true
}

resource "aws_api_gateway_usage_plan" "hub_api" {
  name       = "hub_api_usage_plan"
  depends_on = [aws_api_gateway_deployment.hub_api]

  api_stages {
    api_id = aws_api_gateway_rest_api.hub_api.id
    stage  = aws_api_gateway_stage.hub_api_develop.stage_name
  }
}

resource "aws_api_gateway_usage_plan_key" "hub_api" {
  key_id        = aws_api_gateway_api_key.hub_api.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.hub_api.id
}
```
```HubApi/index.js
const { SSMClient, SendCommandCommand } = require("@aws-sdk/client-ssm");

const hubServerInstanceId = "i-********";

exports.lambda_handler = async (event, context) => {
  const client = new SSMClient({ region: process.env.AWS_REGION });

  const bodyParameters = JSON.parse(event.body);

  console.info("request body: ", bodyParameters);

  const action = bodyParameters.action || null;
  const parameters = bodyParameters.parameters || [];

  const isValidParam = (x) => /^[\w\-\/]+$/.test(x);

  if (!(/^[\w\-]+$/.test(action) && parameters.every(isValidParam))) {
    return {
      statusCode: 400,
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message: "invalid parameters",
      }),
    };
  }

  const commandArgs = parameters.map((x) => `"${x}"`).join(" ");
  const command = `/home/ec2-user/vnv-build-scripts/invoker/bin/invoker "${action}" ${commandArgs}`;

  console.info("command: ", command);

  const commandRunCommand = new SendCommandCommand({
    DocumentName: "AWS-RunShellScript",
    InstanceIds: [hubServerInstanceId],
    Parameters: {
      commands: [command],
    },
  });

  let runCommandId;

  await client
    .send(commandRunCommand)
    .then((data) => {
      console.info(`SSM Run Command Success: ${data}`);
      runCommandId = data.Command.CommandId;
    })
    .catch((error) => {
      console.error(`SSM Run Command Failed: ${error.message}`);
      throw error;
    });

  return {
    statusCode: 200,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      message: "OK",
      runCommandId: runCommandId,
    }),
  };
};
```
```lambda.tf
/*
 * Lambda Function "hub_api" に関する一連の定義
 */

data "archive_file" "lambda_zip_hub_api" {
  type        = "zip"
  source_dir  = "../../lambda/HubApi"
  output_path = "../../tmp/hub_api.zip"
}

resource "aws_lambda_function" "hub_api" {
  depends_on       = [aws_iam_role.lambda_role]
  filename         = data.archive_file.lambda_zip_hub_api.output_path
  function_name    = "hub_api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "index.lambda_handler"
  runtime          = "nodejs18.x"
  source_code_hash = data.archive_file.lambda_zip_hub_api.output_base64sha256
}

resource "aws_lambda_permission" "hub_api" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.hub_api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.hub_api.execution_arn}/*/${aws_api_gateway_method.hub_api_invoke.http_method}/${aws_api_gateway_resource.hub_api_invoke.path_part}"
}
```

# セキュリティルール・権限管理

新規 VPC のセキュリティグループでは SSH port による接続は行わず、基本的に SSM Session Manager による IAM ロール・インスタンスロールを用いた接続にすることで、認証認可のルール付けをまとめて行いやすくしています。

ちなみに現状では Hub サーバから各検証環境インスタンスにファイル転送を行いたいケースや出力を得たいケースがあるため、その場合は SSM Session manager による Proxy で SSH / SCP しています。

```.ssh/config
# SSH over Session Manager
host i-*
  ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
  User ec2-user
```

# CI との連携について

ここでは CircleCI との連携を例に挙げます。

CircleCI から API Gateway の連携については master や branch のテストが成功した際に HTTPS リクエストを行っています。
（以下コードブロックの設定。パラメータは `repository name`、`branch name`）

API Gateway は policy によって IP での制御をしており、それに加えて API Gateway の認証キーを CircleCI の Project Environments に登録して送信しています。

API Gateway から Lambda が実行され、必要最低限のバリデーションをした上で Hub サーバに実行命令を出しています。

```.circleci/config.yml
  deploy_development:
    resource_class: small
    circleci_ip_ranges: true
    docker:
      - image: cimg/base:2024.11
    steps:
      - run:
          name: Invoke deploy method
          command: |
            curl -X POST -H "Content-Type: application/json" \
              -H "x-api-key: ${HUB_API_KEY}" \
              -d "{ \"action\": \"deploy\", \"parameters\": [\"application\", \"${CIRCLE_BRANCH}\"] }" \
              ${HUB_API_URL}
```

# Hub サーバの役割

Hub サーバの役割は大きく２つです。

1. 既存 VPC で立ち上がっている検証環境インスタンスへのリモート実行
1. AWS の各種サービスを利用したオーケストレーションやプロビジョニングの実行
    * 既存の検証環境インスタンスの構築には CloudFormation のテンプレートが用いられており、現時点では CloudFormation の既存資産を活かすようにしています

上記の役割をパイプライン上で実行するための管制塔として構成しています。

# Hub サーバのプログラム構成

Hub サーバにはリポジトリを一つ clone し、それ以外のプログラムは極力設置や起動をしないようにしています。

```
vnv-build-scripts/invoker
├── bin
│   └── invoker
└── scripts
    ├── create_copied_db.sh
    ├── deploy.sh
    ├── manage
    │   ├── remove_instance.sh
    │   ├── start_instance.sh
    │   ├── stop_instance.sh
    │   └── setup_instance.sh
    └── manage.sh
```

`invoker/bin/invoker` のみを公開し、そのパラメータによって各 scripts/ 配下の処理を呼び出し、各処理内で必要に応じてリモート実行をするというシンプルな構成です。

機能を追加する際は scripts/ 配下に処理を足していく方針としており、ポリシーとして以下のように考えています。

* トランザクションスクリプトであること
* 目的・内容がシンプルであること

## 例：CI から branch をデプロイするために呼び出される処理

```deploy.sh
#!/bin/bash

# Run Command に記録を残すため出力量は最大にしている
set -veu -x

REPOSITORY_NAME=$1
BRANCH_NAME=$2

# 各検証環境インスタンスにも同様のリポジトリを設置し、その内部実行用スクリプトを起動している
EXECUTER_COMMAND="/home/ec2-user/vnv-build-scripts/executer/bin/executer"

if [ "${REPOSITORY_NAME}" = "application" ]; then

  # ラベルがついたインスタンスのみを対象とする
  MANAGED_TAG_NAME="VnvManaged"
  MANAGED_TAG_VALUE="true"

  EC2_LIST=(`aws ec2 describe-instances \
    --filters "Name=tag:${MANAGED_TAG_NAME},Values=${MANAGED_TAG_VALUE}" \
    --query "Reservations[*].Instances[*].InstanceId" --output text`)

  for instance_id in "${EC2_LIST[@]}"; do

    set +e
    GIT_BRANCH_OUTPUT=`ssh $instance_id "cd /var/www/application/current; git branch"`
    USING_BRANCH=`echo "${GIT_BRANCH_OUTPUT}" | grep '^\* ' | sed 's/^* //'`
    set -e

    if [ "${USING_BRANCH}" = "${BRANCH_NAME}" ]; then
      aws ssm send-command \
        --document-name "AWS-RunShellScript" \
        --instance-ids "${instance_id}" \
        --parameters commands=["${EXECUTER_COMMAND} deploy ${REPOSITORY_NAME} ${BRANCH_NAME}"]
    fi

  done

fi
```

# Chatbot の運用

（これは現時点で本運用がまだのため、概要のみとなります）

Slack bot の [Bolt](https://api.slack.com/bolt) を socket mode でデーモンとして起動し、前述した invoker と連動させることで、検証環境に対して Slack 上から簡単なコマンドでオーダーメイドな処理を実行することを想定しています。

ECS 化するのがよいとも考えていますが、運用時のエラーの trace を考慮するとしばらくの間は Hub サーバ上での起動になりそうな見込みです。

https://api.slack.com/bolt

# おわりに

ここまで、既存の AWS アカウントの運用自動化を進めていくにあたって、新規 VPC と Hub サーバを構成することで運用フローを一貫させるという取り組みについてご紹介しました。

当記事ではコードや設定の全てを引用することはできず、一部スニペットのみでのご説明となりましたが、何かの参考になりましたら幸いです。

AWS を使っていると各々の手元でやれてしまうことが多いためにオペレーションの一貫性を保つのが簡単ではないと感じており、改めて今回のようなシンプルな構成を構築しましたが、個人的には検証環境に関連した自動化周りの開発とリリースが行いやすくなりました。

今後も仕組み化を進めながら、開発者体験の向上と、検証品質の向上を狙った取り組みを進めていきたいと考えています。

それでは引き続きアドベントカレンダーをお楽しみください :smiley: 

https://qiita.com/advent-calendar/2024/coincheck