TerraformでAWSのセキュリティグループを一括管理してみた

現在携わっているサービスのインフラ改修、結構手強いんですよね...

セキュリティグループ(SG)だけでも見直し始めたら、一つのリソースに似たようなSGが複数アタッチされていたり...想像以上に時間がかかりました。

サービスが稼働するためだけであれば元の状態でも問題はないのですが、ちゃんとセキュリティの面も考慮して管理していく必要があります。そこで、メンバーとも話し合ってインフラをコード化することに決めました。

私自身、元々はインフラエンジニアではないのですが、この機会にTerraformを導入してみることにしました。

# インフラコード化(IaC)の目的とその必要性

- 操作ミスによるリソース作成ミスの削減
- 今後のテスト環境構築における一貫性の確保
- バージョン管理による変更履歴の追跡と、問題発生時の迅速なロールバックの実現

# Terraformを選んだ理由とその利点

- 他のツール(例: CloudFormation)と比較した際の、Terraformのコード(HCL)の高い可読性
- 手動で作成した既存インフラのコード化が容易(参考：https://zenn.dev/yuta28/articles/iac-existing-infrastructure)
- 将来的にGCPリソース(BigQuery)を導入する可能性への対応能力

# 導入手順
Terraformのインストール、セキュリティグループの実装、適用などについて順番に解説します。

## Terraformのインストール
MacOSの場合はHomebrewからインストールできます。

```bash
$ brew install terraform
```

## 新規プロジェクトの作成
新規のTerraformプロジェクト(例：terraform-project)を作成します。
ディレクトリ構成は以下の記事を参考にしています。

https://dev.classmethod.jp/articles/terraform-bset-practice-jp/

### ディレクトリ構成
ディレクトリ構成は以下のようになっています。

```
-- terraform-project/
   -- environments/
      -- dev/
         -- backend.tf
         -- main.tf
      -- stg/
         -- backend.tf
         -- main.tf
      -- prod/
         -- backend.tf
         -- main.tf
   -- modules/
      -- <service-name>/
         -- main.tf
         -- variables.tf
         -- outputs.tf
         -- provider.tf
         -- README.md
      -- security_group/
         -- main.tf
         -- variables.tf
         -- outputs.tf
         -- provider.tf
         -- README.md
      -- ...other…
   -- docs/
      -- architecrture.drowio
      -- architecrture.png
```

1. **environments/**: 各環境(dev, stg, prod)の設定が格納される。各環境は異なる設定を持つことがある(dev環境では小さなサイズのEC2インスタンスを使用し、prod環境では大きなサイズのインスタンスを使用する場合など)。
2. **environments/{environment}/backend.tf**: `backend.tf`はTerraformのバックエンドの設定を含む。Terraformのステートファイル（`.tfstate`）をどこに保存するかを管理する。
3. **environments/{environment}/main.tf**: 各環境で使用するリソースやモジュールを定義する。具体的なリソースの設定ではなく、モジュールの呼び出しや設定の参照が主に行われる。
4. **modules/**: Terraformのモジュールが格納される。モジュールは再利用可能なTerraformコードのブロックで、同じコードを何度も書く代わりにモジュールを使用してコードを整理できる。
5. **modules/{service-name}/main.tf**: 特定のサービスに関連するTerraformリソースの設定が行われる。
6. **modules/{service-name}/variables.tf**: モジュールで使用する変数を定義する。
7. **modules/{service-name}/outputs.tf**: モジュールで出力する値を定義する。
8. **modules/{service-name}/provider.tf**: プロバイダーの設定が格納される。通常、プロバイダーのバージョン情報など、プロジェクト全体で共有するプロバイダーの設定を含む。
9. **modules/{service-name}/README.md**: モジュールの説明や使用方法を記載する。
10. **docs/**: アーキテクチャの構成図をdrowioとpng形式で管理する。

### セキュリティグループ(SG)のルール作成
SGの作成例として、プライベートサブネットにあるLambdaと、別のプライベートサブネットにあるRDS Proxyを考えます。

#### LambdaのSG
Lambdaは、RDS Proxy、インターフェースVPCエンドポイント、そしてインターネット(パブリックサブネットのNATゲートウェイを経由)へのアウトバウンドのトラフィックを持っています。これらの接続のために、まず`aws_security_group`を`lambda_sg`として定義し、次に必要な`aws_security_group_rule`を関連付けます。

重要な点として、`aws_security_group`でSGをアタッチするリソースの`vpc_id`を指定する必要があります。このVPCは既に手動で作成されており、その設定がGitで管理されています。したがって、`vpc_id`はハードコーディングせず、SSMパラメータストアから読み込みます。

今回のLambdaではインバウンドルールは不要なため、アウトバウンドルール(`type = "egress"`)のみを作成します。各`aws_security_group_rule`の`security_group_id`に`aws_security_group.lambda_sg.id`を設定することで、ルールをどのSGにアタッチするかを指定します。また、接続先が決まっている場合は、`source_security_group_id`に接続先のSGのIDを指定します。

以下が、これらの設定を反映したTerraformコードです。

```tf:./modules/security_group/main.tf
data "aws_ssm_parameter" "lambda_vpc_id" {
  name = "/vpc/id/lambda"
}

resource "aws_security_group" "lambda_sg" {
  name        = "lambda-sg"
  description = "Security group for Lambda"
  vpc_id      = data.aws_ssm_parameter.lambda_vpc_id.value
}


resource "aws_security_group_rule" "lambda_sg_egress_vpc" {
  type                     = "egress"
  from_port                = 0
  to_port                  = 0
  protocol                 = "-1"
  security_group_id        = aws_security_group.lambda_sg.id
  source_security_group_id = aws_security_group.vpc_endpoint_sg.id
}

resource "aws_security_group_rule" "lambda_sg_egress_rds_proxy" {
  type                     = "egress"
  from_port                = 0
  to_port                  = 0
  protocol                 = "-1"
  security_group_id        = aws_security_group.lambda_sg.id
  source_security_group_id = aws_security_group.rds_proxy_sg.id
}

resource "aws_security_group_rule" "lambda_sg_egress_https" {
  type              = "egress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  security_group_id = aws_security_group.lambda_sg.id
  cidr_blocks       = ["0.0.0.0/0"]
}
```

※セキュリティ上の理由から、実際のプロジェクトでは可能な限りアクセス制限を適用し、必要最低限のポートとプロトコルのみを開放するようにしましょう。

#### RDS ProxyのSG
RDS Proxyは、LambdaからのインバウンドトラフィックとRDSへのアウトバウンドトラフィックを持っています。

前述したLambdaの例と同様に、まずは`aws_security_group`を`rds_proxy_sg`として定義します。次に、必要な`aws_security_group_rule`を関連付けていきます。

このRDS Proxyは、Lambdaからの接続(インバウンド)とRDSへの接続(アウトバウンド)を許可するルールが必要です。インバウンドルール(`type = "ingress"`)はLambdaからの接続を許可し、アウトバウンドルール(`type = "egress"`)はRDSへの接続を許可します。

各`aws_security_group_rule`の`security_group_id`に`aws_security_group.rds_proxy_sg.id`を設定し、`source_security_group_id`に対応する接続元のSGのIDを指定します。

以下が、これらの設定を反映したTerraformコードです。

```tf:./modules/security_group/main.tf
resource "aws_security_group" "rds_proxy_sg" {
  name        = "rds-proxy-sg"
  description = "Security group for RDS Proxy"
  vpc_id      = data.aws_ssm_parameter.lambda_vpc_id.value
}

resource "aws_security_group_rule" "rds_proxy_sg_ingress" {
  type                     = "ingress"
  from_port                = 3306
  to_port                  = 3306
  protocol                 = "tcp"
  security_group_id        = aws_security_group.rds_proxy_sg.id
  source_security_group_id = aws_security_group.lambda_sg.id
}

resource "aws_security_group_rule" "rds_proxy_sg_egress" {
  type                     = "egress"
  from_port                = 0
  to_port                  = 0
  protocol                 = "-1"
  security_group_id        = aws_security_group.rds_proxy_sg.id
  source_security_group_id = aws_security_group.rds_sg.id
}
```

### リージョン情報の設定
SGを作成するリージョンをこちらのファイルで指定します。

```tf:./modules/security_group/provider.tf
provider "aws" {
  region = "ap-northeast-1"
}
```

### ステートファイルの保存先の設定
Terraformのステートファイル(`.tfstate`)は、インフラの現在の状態を保存する重要なファイルです。このファイルの保存先は`./environments/{environment}/backend.tf`で管理されます。通常、ステートファイルはリモートストレージに保存することが推奨され、リモートのバックエンドを使用することで、チームメンバー間でのステートの共有や、ステートファイルのロストや破損からのリカバリが容易になります。

以下はS3をバックエンドストレージとして利用する例です。この例では、ステートファイルを`terraform-state-1234`というS3バケットに保存します。S3バケットの名前は、全AWSアカウント間で一意である必要があります。また、バケットはあらかじめ作成しておく必要があります。

```tf:environments/{environment}/backend.tf
terraform {
  backend "s3" {
    bucket = "terraform-state-1234" // bucket名は全AWSアカウントで一意
    key    = "dev/terraform.tfstate"
    region = "ap-northeast-1"
  }
}
```

この設定により、`terraform apply`を実行すると、ステートファイルは自動的に指定したS3バケットに保存されます。また、`terraform init`を実行すると、Terraformは指定されたバックエンドからステートファイルを取得します。

### 各環境で使用するmodulesの定義
dev環境のセキュリティグループ(SG)を設定する際には、`./environments/dev/main.tf`で、作成したmoduleを読み込みます。

```tf:./environments/{environment}/main.tf
module "security_group" {
  source = "../../modules/security_group"
}
```

### 変数の読み込み(今回は不要)
今回、`lambda_vpc_id`をSSMパラメータストアから読み込んでいますが、`environments/{environment}/main.tf`から変数として値を取り込むことも可能です。

まず、`variables.tf`で変数を定義します。

```tf:./modules/security_group/variables.tf
variable "lambda_vpc_id" {
  description = "The ID of the lambda-vpc where the security group will be created"
  type        = string
}
```
次に、`./environments/{environment}/main.tf`に変数の値を指定します。

```tf:./environments/{environment}/main.tf
module "security_group" {
  source = "../../modules/security_group"

  lambda_vpc_id   = "vpc-123456"
}
```
これで、`var.lambda_vpc_id`として変数を読み込むことができます。

```tf:./modules/security_group/main.tf
resource "aws_security_group" "lambda_sg" {
  name        = "lambda-sg"
  description = "Security group for Lambda"
  vpc_id      = var.lambda_vpc_id
}
```

### 値の出力（今回は不要)
`./modules/security_group/main.tf`で作成したリソースのIDなどを出力することで、他のmoduleのリソースから値を参照できます。

以下は出力の例です。
```tf:outputs.tf
output "vpc_endpoint_sg_id" {
  value       = aws_security_group.vpc_endpoint_sg.id
  description = "The ID of vpc-endpoint-sg"
}
```

### 差分の確認と適用方法
Terraformでは、リソースの変更前後で何がどのように変わるのかを確認できます。この段階では、実際にリソースが作成または変更される前に、Terraformが提案する計画を確認します。

`./environments/{environment}`のディレクトリで、以下のコマンドを実行します。

```bash
$ terraform plan
```

このコマンドを実行すると、Terraformは現在の状態と定義された設定を比較し、適用すべき変更点を表示します。これにより、意図しない変更が行われることを防ぐことができます。

そして、確認した変更内容に問題がなければ、以下のコマンドを実行して変更を適用します。

```bash
$ terraform apply
```

このapplyコマンドにより、Terraformは計画通りの変更を実際のリソースに適用します。このプロセスは完全に自動化されており、手作業によるミスを大幅に削減することができます。

このように、Terraformは現在のインフラストラクチャの状態を正確に把握し、それを理想的な状態に近づけるための変更を提案、適用することで、インフラストラクチャの管理を容易にします。

# おわりに
セキュリティグループの作成過程で何箇所かルールの設定ミスをみつけたのですが、Terraformを活用していたおかげでその修正作業が非常に楽でした。
次のステップとして、PR作成時に`terraform plan`を自動実行するようなGitHub Actionsワークフローの導入を考えています。さらに、Gitへのプッシュ時にセキュリティチェックを行うワークフローも構築予定です。これらの自動化により、セキュリティの強化と作業の効率化をより進めていきたいと考えています。