セキュアにEC2からRDSにデータを登録してみる

2023/07/06に公開

はじめに

今回はプライベートサブネットにEC2を起動して、別のプライベートサブネットにあるRDSにデータを登録してみます。
さらに今回は以下のサービスも使いながらセキュアなデータ登録を行ってみたいと思います。

  • EC2 Instance Connect(プライベートサブネットのEC2にセキュアに接続)
  • SSM Parameter Store(RDSの接続情報をセキュアに管理)
  • KMS(RDSのデータ暗号化)

全体像はこんな感じです。

構成図を結構細かく書いています。
私はこうした方が後からコード化する際に悩む時間が減って時短になると思っています。(個人の感想です)

インフラの構築にはterraformを使っています。
コードはgithubに上げているので詳しくみたい方はどうぞ。
https://github.com/Sou-y-g/CT-Subject/tree/main/week6/ct6-1

terraform全体の構成

今回使用したterrafomのディレクトリ構造は以下のようになっています。
シンプルなmodule構造です。

.
├── main.tf
├── module
│   ├── IAM
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   ├── RDS
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   ├── ec2
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── network
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── outputs.tf
├── provider.tf
├── terraform.tfstate
└── variables.tf

では、ルートディレクトリのコードを見てみます。
コードが長くなるのでトグル形式にしています、気になるところだけ開いてみて下さい。

ルートディレクトリ
provider.tf
terraform {
  required_version = "1.5.2"
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 5.6.2"
    }
  }
}

provider "aws" {
  region = var.region
}
mani.tf
module "network" {
  source = "./module/network"
  #rootディレクトリの変数を使用
  tag           = var.tag
  az-1a         = var.az-1a
  az-1c         = var.az-1c
  vpc_cidr      = var.vpc_cidr
  public_cidr   = var.public_cidr
  private1_cidr = var.private1_cidr
  private2_cidr = var.private2_cidr
  private3_cidr = var.private3_cidr
  all_cidr      = var.all
}

module "ec2" {
  source = "./module/ec2"

  #別のmoduleから変数を取得する場合は、module.module_name.{取得する変数}
  vpc_id           = module.network.vpc_id
  private1_id      = module.network.private1_id
  ssm_profile_name = module.IAM.ssm_profile_name
  tag              = var.tag
  az-1a            = var.az-1a
  vpc_cidr         = var.vpc_cidr
  public_cidr      = var.public_cidr
  private1_cidr    = var.private1_cidr
  all_cidr         = var.all
}

module "IAM" {
  source = "./module/IAM"
}

module "RDS" {
  source = "./module/RDS"

  az-1a         = var.az-1a
  az-1c         = var.az-1c
  vpc_id        = module.network.vpc_id
  tag           = var.tag
  private1_id   = module.network.private1_id
  private2_id   = module.network.private2_id
  private3_id   = module.network.private3_id
  private1_cidr = var.private1_cidr
  all_cidr      = var.all
}
variables.tf
variable "region" {
  description = "The region where to deploy the infrastructure"
  type        = string
  default     = "ap-northeast-1"
}

variable "tag" {
  description = "Prefix for the tags"
  type        = string
  default     = "ct6-1"
}

variable "az-1a" {
  description = "The availability zones1a to use"
  type        = string
  default     = "ap-northeast-1a"
}

variable "az-1c" {
  description = "The availability zones1c to use"
  type        = string
  default     = "ap-northeast-1c"
}

variable "vpc_cidr" {
  description = "The CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_cidr" {
  description = "The CIDR block for the public subnet"
  type        = string
  default     = "10.0.0.0/24"
}

variable "private1_cidr" {
  description = "The CIDR block for the private1 subnet"
  type        = string
  default     = "10.0.1.0/24"
}

variable "private2_cidr" {
  description = "The CIDR block for the private2 subnet"
  type        = string
  default     = "10.0.2.0/24"
}

variable "private3_cidr" {
  description = "The CIDR block for the private3 subnet"
  type        = string
  default     = "10.0.3.0/24"
}

variable "all" {
  description = "The CIDR block for all"
  type        = string
  default     = "0.0.0.0/0"
}

今回はEC2 Instance Connect Endpoint(EIC)をterraformで書きたかったので、providerは最新のバージョンを使用しました。

では、ここから各リソース毎のコードを見ていきます。

Networkの作成

今回のネットワークの構成は以下の通りです。

  • NatGatewayを配置するパブリックサブネット
  • EC2を配置するプライベートサブネット1
  • RDSを配置するプライベートサブネット2
  • RDSの仕様で必要なプライベートサブネット3(実際は使いません)

その他に、Network部分でNatGatewayとNatGatewayにアタッチするEIPも作成します。
まずはこの図のように作っていきます。

では、コードを書いていきます。

network/main.tf
network/main.tf
######################################################################
# NetWork
######################################################################
# VPC作成
resource "aws_vpc" "vpc" {
  cidr_block = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support = true

  tags = {
    Name = "${var.tag}-vpc"
  }
}

# InternetGateway作成
resource "aws_internet_gateway" "ig" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.tag}-ig"
  }
}

######################################################################
# Public Subnet
######################################################################
# PublicSubnet作成
resource "aws_subnet" "public" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.public_cidr
  availability_zone = var.az-1a
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.tag}-public-subnet"
  }
}

# public route_table
resource "aws_route_table" "public_rt" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = var.all_cidr
    gateway_id = aws_internet_gateway.ig.id
  }

  tags = {
    Name = "${var.tag}-public-rt"
  }
}

resource "aws_route_table_association" "public_rt" {
  subnet_id = aws_subnet.public.id
  route_table_id = aws_route_table.public_rt.id
}

######################################################################
# Private Subnet
######################################################################
# private1 (for ec2)
resource "aws_subnet" "private1" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.private1_cidr
  availability_zone = var.az-1a
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.tag}-private1-subnet"
  }
}

# private1 route_table
resource "aws_route_table" "private1_rt" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = var.all_cidr
    gateway_id = aws_nat_gateway.ng.id
  }

  tags = {
    Name = "${var.tag}-private1-rt"
  }

}

resource "aws_route_table_association" "routetable1" {
  route_table_id = aws_route_table.private1_rt.id
  subnet_id = aws_subnet.private1.id
}

# private2 (for RDS)
resource "aws_subnet" "private2" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.private2_cidr
  availability_zone = var.az-1a
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.tag}-private2-subnet"
  }
}

# private3 (for RDS MultiAZ)
resource "aws_subnet" "private3" {
  vpc_id = aws_vpc.vpc.id
  cidr_block = var.private3_cidr
  availability_zone = var.az-1c
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.tag}-private3-subnet"
  }
}

######################################################################
# NatGateway
######################################################################
# EIP作成
resource "aws_eip" "eip" {
  domain = "vpc"

  tags = {
    Name = "${var.tag}-eip"
  }
}

# NatGateway作成
resource "aws_nat_gateway" "ng" {
  allocation_id = aws_eip.eip.id
  subnet_id = aws_subnet.public.id

  tags = {
    Name = "${var.tag}-ng"
  }
}

変数とアウトプットはこのようになっています。

network/variables.tf, outputs.tf
network/variables.tf
variable "tag" {}
variable "az-1a" {}
variable "az-1c" {}
variable "vpc_cidr" {}
variable "public_cidr" {}
variable "private1_cidr" {}
variable "private2_cidr" {}
variable "private3_cidr" {}
variable "all_cidr" {}
network/outputs.tf
output "vpc_id" {
  value = aws_vpc.vpc.id
}

output "private1_id" {
  value = aws_subnet.private1.id
}

output "private2_id" {
  value = aws_subnet.private2.id
}

output "private3_id" {
  value = aws_subnet.private3.id
}

図の通りに必要なサブネットを作成しています。

詰まったところ

初歩的なことろですが、
EC2とRDSの通信をするのに、サブネットのルートテーブルはどうしたらいいんだっけ?となりました。

これはVPC内の通信なので、DNSで名前解決をしてくれます。
基本的な部分ですが、たまによく分からなくなります。

今回、プライベートのEC2からParameterStoreやboto3のインストールなどのVPC外へ通信する必要がありました。
これを忘れていたので、NatGatewayとEIPを後から作りました。

ParameterStoreのエンドポイントはリージョン毎に作成されているので、
プライベートサブネットのEC2から接続するにはインターネットを通る必要があったんですね。

EC2の作成

次にEC2を作成していきます。
EC2以外にEIC用のエンドポイントと、それぞれにアタッチするセキュリティグループを作成します。

作成するのはこの図の部分です。

ではコードを見ていきましょう。

ec2/mani.tf
ec2/mani.tf
######################################################################################
# Security Group
######################################################################################
## get my ip
data "http" "ifconfig" {
  url = "http://ipv4.icanhazip.com/"
}

variable "allowed-myip" {
  default = null
}

locals {
  current-ip   = chomp(data.http.ifconfig.body)
  allowed-myip = (var.allowed-myip == null) ? "${local.current-ip}/32" : var.allowed-myip
}

####################################################################
# EIC security group
####################################################################
# EIC security group
resource "aws_security_group" "sg_eic" {
  name = "for EIC"
  vpc_id = var.vpc_id

  egress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr ,local.allowed-myip]
  }

  tags = {
    Name = "${var.tag}-eic"
  }
}

# EIC => EC2 security group
resource "aws_security_group" "eic_to_ec2" {
  name = "EIC to EC2"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [local.allowed-myip]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [var.all_cidr]
  }

  tags = {
    Name = "${var.tag}-sg_eic_to_ec2"
  }
}

######################################################################################
# EC2 Instance Connect Endpoint
######################################################################################
# EIC 作成
resource "aws_ec2_instance_connect_endpoint" "eic" {
  subnet_id = var.private1_id
  security_group_ids = [aws_security_group.sg_eic.id]

  tags = {
    Name = "${var.tag}-eic"
  }
}

######################################################################################
# EC2
######################################################################################
# get AMI
data "aws_ssm_parameter" "amazonlinux_2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64" # x86_64
}

# EC2
resource "aws_instance" "ec2"{
  ami                         = data.aws_ssm_parameter.amazonlinux_2023.value
  instance_type               = "t2.micro"
  availability_zone           = var.az-1a
  vpc_security_group_ids      = [aws_security_group.eic_to_ec2.id]
  subnet_id                   = var.private1_id
  associate_public_ip_address = false
  key_name                    = var.key_name
  iam_instance_profile = var.ssm_profile_name
  tags = {
    Name = "${var.tag}-ec2"
  }
}

変数はこのようになっています。
ec2は特にoutputはありません。

ec2/variables.tf
ec2/variables.tf
variable "key_name" {
  description = "EC2 key"
  default = "ct_key"
}

variable "vpc_id" {}
variable "private1_id" {}
variable "tag" {}
variable "az-1a" {}
variable "ssm_profile_name" {}
variable "vpc_cidr" {}
variable "public_cidr" {}
variable "private1_cidr" {}
variable "all_cidr" {}

今回、初めてEICをterraformで作成したので、applyする時にドキドキしましたが問題なく作成できました。

ロールの作成

次に、IAM関連を作成していきます。
ロールはEC2がParameterStoreを参照できるように設定していきます。
必要な権限は"ssm:GetParameter"です。

それとAssumeRoleできるように信頼ポリシーも設定しておきます。
この図のように作っていきます。

IAM/mani.tf
IAM/mani.tf
############################################################
# Roel作成
############################################################
# ssm-roleの作成
resource "aws_iam_role" "ssm-role" {
  name               = "ssm-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}

# ssm-roleの信頼ポリシー
data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

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

# ssm-roleのアイデンティティポリシー
data "aws_iam_policy_document" "ssm-role-policy" {
  statement {
    effect    = "Allow"
    actions   = ["ssm:GetParameter"]
    resources = ["*"]
  }
}

# ssm-policyの作成
resource "aws_iam_policy" "ssm-role-policy" {
  name        = "ssm-role-policy"
  description = "policy for ssm-role"
  policy      = data.aws_iam_policy_document.ssm-role-policy.json
}

# ssm-policy => ssm-role
resource "aws_iam_role_policy_attachment" "ssm-role-policy" {
  role       = aws_iam_role.ssm-role.name
  policy_arn = aws_iam_policy.ssm-role-policy.arn
}

# ssm-role => profile
resource "aws_iam_instance_profile" "ssm_profile" {
  name = "ssm-profile"
  role = aws_iam_role.ssm-role.name
}

変数とアウトプットはこのようになっています。

IAM/outputs.tf
IAM/outputs.tf
output "ssm_profile_name" {
  value = aws_iam_instance_profile.ssm_profile.name
}

EC2にプロファイルをアタッチするためにアウトプットとして出力しています。

SSMの操作で"GetParameter"に似た"GetParameters"というアクションがあります。
GetParametersは複数の値をまとめてParameterStoreから呼び出すことができます。

今回はデータベースのユーザー名を平文で、
パスワードを復号化して取得したいので"GetParameter"を使います。

KMSでCMKを作成

ここからは全てRDSのmain.tfに記述していきます。
KMSとParameterStoreはRDSの作成に使用するので、RDSのファイルにまとめています。

作成するのはこの部分です。

まずはRDSに登録するデータを暗号化するためのCMKを作成しています。

rds/mani.tf
rds/mani.tf
######################################################################################
# KMS
######################################################################################
#db encrypt key
resource "aws_kms_key" "db_encrypt_key" {
  description = "db_encrypt_key"
  enable_key_rotation = true
  is_enabled = true
  deletion_window_in_days = 30
}

#key判別のためailas設定
resource "aws_kms_alias" "db_encrypt_key" {
  name = "alias/db_encrypt_key"
  target_key_id = aws_kms_key.db_encrypt_key.key_id
}

詰まったところ

詰まったところは特にありませんでしたが、KMSは今まであまり使ったことのないサービスだったので料金が気になりました。
KMSでは、「暗号化リクエスト」と「鍵の保管」に費用が発生するようです。

今回の場合だと、「暗号化リクエスト料」と「CMKの保管料」が発生するようです。
ただし、暗号化リクエストは月に20,000リクエストまで無料なので、試す程度ならお金はかからないでしょう。
CMKは1本毎に月1ドルかかるようです。

ParameterStoreに認証情報を作成

次にParameterStoreにデータベースの認証情報を作成していきます。
パスワードを表示していますが、後から変更ができるので作成した後にaws cli等を使って変更して下さい。

rds/mani.tf
rds/mani.tf
######################################################
# Parameter Store
######################################################
resource "aws_ssm_parameter" "db_user" {
  description = "user for database connection"
  name = "/db/user"
  value = "root"
  type = "String"
}

resource "aws_ssm_parameter" "db_pw" {
  description = "password for database connection"
  name = "/db/password"
  value = "uninitialized" #←後から変更
  type = "SecureString"

  lifecycle {
    ignore_changes = [value]
  }
}

これでデータベースに接続するためのユーザー名とパスワードが作成できました。
ユーザー名は平文で、パスワードは暗号化しています。

lifecycleのignore_changesは、terraformでうっかりパスワードの更新をしてしまうのを防ぐために設定しています。

では、登録したパスワードをaws cliを使って変更してみます。

# aws ssm put-parameter --name '/db/password' --type SecureString --value '<new_string>' --overwrite

<new_string>の部分を新しいパスワードにして下さい。
これでパスワードが上書きされます。

注意点

ParameterStoreで設定したパスワードはapply時に平文でterraform.tfstateファイルにも反映されます。
これは、マネジメントコンソールから変更した場合でも、apply時に最新の値がtfstateに反映されてしまいます。

そのため、tfstateは必ず専用のS3バケット等を使って管理するようにしましょう。

RDSの作成

最後にRDSを作成していきます。
今回はRDS for Mysqlを選択します。

rds/mani.tf
rds/mani.tf
######################################################################################
# RDS
######################################################################################
#dbのパラメータ
resource "aws_db_parameter_group" "cnf" {
  name = "cnf"
  family = "mysql8.0"

  parameter {
    name = "character_set_database"
    value = "utf8mb4"
  }

  parameter {
    name = "character_set_server"
    value = "utf8mb4"
  }
}

#dbのサブネットグループ(subnet_idは異なるAZに最低2つ必要)
resource "aws_db_subnet_group" "db_subnet" {
  name = "db_subnet"
  subnet_ids = [var.private2_id, var.private3_id]
}

#dbインスタンス作成
resource "aws_db_instance" "db" {
  identifier = "ct-6-1db"
  engine = "mysql"
  engine_version = "8.0.32"
  instance_class = "db.t3.micro"
  allocated_storage = 20
  max_allocated_storage = 100
  storage_type = "gp2"
  #kmsを使ってデータの暗号化
  storage_encrypted = true
  kms_key_id = aws_kms_key.db_encrypt_key.arn
  #parameterstoreから値を取得
  username = aws_ssm_parameter.db_user.value
  password = aws_ssm_parameter.db_pw.value
  multi_az = false
  #VPC外からのアクセス
  publicly_accessible = false
  port = 3306
  #db作成時にスナップショットを作成しない
  skip_final_snapshot = true
  #セキュリティグループ
  vpc_security_group_ids = [aws_security_group.db_sg.id]
  #上で設定したdbのパラメータを反映
  parameter_group_name = aws_db_parameter_group.cnf.name
  #dbを配置するサブネット
  db_subnet_group_name = aws_db_subnet_group.db_subnet.name
}

#################################################################
# RDSのセキュリティグループ
#################################################################
resource "aws_security_group" "db_sg" {
  name = "db_security_group"
  vpc_id = var.vpc_id
  tags = {
    Name = "${var.tag}-db-sg"
  }
}

resource "aws_security_group_rule" "dbsg_in" {
    type        = "ingress"
    from_port   = 3306
    to_port     = 3306
    protocol    = "tcp"
    cidr_blocks = [var.private1_cidr]

    security_group_id = aws_security_group.db_sg.id
}

resource "aws_security_group_rule" "dbsg_out" {
    type        = "egress"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [var.all_cidr]

    security_group_id = aws_security_group.db_sg.id
}

変数はこのようになっています。

rds/variables.tf
rds/variables.tf
variable "vpc_id" {}
variable "tag" {}
variable "az-1a" {}
variable "az-1c" {}
variable "private1_id" {}
variable "private2_id" {}
variable "private3_id" {}
variable "private1_cidr" {}
variable "all_cidr" {}

RDSでは"aws_db_parameter_group"を使って、mysqlのmy.cnfファイルのような設定ができます。

今回はセキュアな接続、データ読み書きということで
ParameterStoreによるユーザー情報の管理と、KMSによるデータ暗号化を有効にしています。

セキュリティグループではEC2のサブネットからの通信のみを許可しています。

詰まったところ

最初、RDSのmain.tfのaws_db_subnet_groupにて、以下のようにRDSを作成する予定のsubnet2のみを指定していました。

RDS/main.tf
subnet_ids = [var.subnet2_id]

その状態でterraform applyを行うと以下のエラーが出力されました。

Error: creating RDS DB Subnet Group (db_subnet): DBSubnetGroupDoesNotCoverEnoughAZs: The DB subnet group doesn't meet Availability Zone (AZ) coverage requirement. Current AZ coverage: ap-northeast-1a. Add subnets to cover at least 2 AZs.

これは、RDSのMultiAZ機能を使わない場合でも、将来的に使う可能性が0ではないので
最低でも2つのsubnet_idを設定する必要があるようです。
ap-northeast-1cに謎のサブネットが存在するのはそのためです。

EC2からRDSへ接続

これで最初の全体像が整いました。
では、ParameterStoreから値を取得してEC2からRDSへ接続したいと思います。
今回は2つの接続方法で確認します。

  • aws cliを使う方法
  • python SDKを使ってアプリケーションから接続する方法

ここからはEC2へログインして操作していきます。

aws cliでParameterStoreの値を参照

では、RDSに接続するために、事前にEC2にmysqlをインストールします。
mysqlのインストールはこちらの記事を参考にインストールしました。
https://dev.classmethod.jp/articles/install-mysql-client-to-amazon-linux-2023/

それではaws cliで値が取得できることを確認していきます。
get-parametersを使ってParameterStoreから値を取得します。

$ aws ssm get-parameter --name /db/user --query "Parameter.Value" --output text
root
$ aws ssm get-parameter --name /db/pass --with-decryption --query "Parameter.Value" --output text
uninitialized   

ちゃんと出力されましたね。
では、この値を変数に代入してRDSへ接続できることを確認します。

$ db_user=$(aws ssm get-parameters --names /db/user --query "Parameters[*].Value" --output text)
$ db_pass=$(aws ssm get-parameters --names /db/pass --query "Parameters[*].Value" --with-decryption --output text)

値を代入したので、RDSへ接続します。
接続に必要なRDSのエンドポイントはawsのコンソール画面から確認しました。

$ mysql -h <rdsのエンドポイント> -P 3306 -u $db_user -p$db_pass
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 37
Server version: 8.0.32 Source distribution
...

$ mysql>

問題なく接続できました。
これでParameterStoreに保存されたデータを使ってRDSへ接続できることが確認できました。

疑問

ここでちょっと疑問に思ったことを書きます。
なぜParameterStoreから値を取得するのにはIAMの認証(AssumeRole)が必要なのに、RDSに接続するにはIAMの認証が必要ないのでしょうか?

それは、RDS上で動作しているデータベース(今回はmysql)に備わっているログインの機能が使えるからです。

調べてみると、IAMを使ってRDSを操作する「IAM認証機能」もあるようです。
これはデータベースのユーザー名、パスワードの代わりにIAMロールの一時認証を使ってRDSを操作できる機能です。

実際のところどっちがよく使われているのか気になります...
既存データベースからの移行であれば、そのままユーザー名とパスワードが使われていそうなイメージです。

アプリケーションを使って接続

次に、EC2内のアプリケーションでParameterStoreから値を取得して、RDSへ接続したいと思います。
今回はRDSにデータを登録するためにpython SDKのboto3(pythonでAWSリソースを操作するためのもの)を使って簡単なコードを書きます。

EC2(AmazonLinux2023)にはpythonがインストールされているようなのでpipをインストールします。
pipをインストールしたらboto3をインストールします。

$ sudo dnf install python3-pip
$ pip install mysql-connector-python boto3

プログラムからParameterStoreの値を参照

接続が確認できたのでプログラムからParameterStoreの値を参照できるか確認していきます。
私はあまりコードが得意ではないのでChat-GPTさんに生成してもらって、いい感じに使えるようにします。

create.py
create.py
import boto3
import mysql.connector

# boto3で使用するリージョンを設定
ssm = boto3.client('ssm', region_name='ap-northeast-1')

# パラメータストアから認証情報を取得
db_user = ssm.get_parameter(Name='/db/user')['Parameter']['Value']
db_pass = ssm.get_parameter(Name='/db/password', WithDecryption=True)['Parameter']['Value']

# RDSに接続
cnx = mysql.connector.connect(user=db_user, password=db_pass, host='<rdsのエンドポイント>')

cursor = cnx.cursor()

# データベースを作成
cursor.execute("CREATE DATABASE IF NOT EXISTS test")

# 作成したデータベースを使用
cursor.execute("USE test")

# テーブルを作成
cursor.execute("""
    CREATE TABLE employees (
        first_name VARCHAR(20),
        last_name VARCHAR(30)
    )
""")

# データを挿入
cursor.execute("""
    INSERT INTO employees (first_name, last_name)
    VALUES ('John', 'Doe')
""")

cnx.commit()
cnx.close()

このコードではParameterStoreから取得した値を使ってRDSに接続しています。
その後、testというデータベースのemployeesテーブルにJohn Doneという値を登録しています。

では、こちらのコードを動かします。

$ python3 create.py

うまくデータが登録できました。

では次に、データベースに登録されているデータを取得してみます。
このコードはtestデータベースのemployeesテーブルの値を出力するためのプログラムです。
このプログラムで"John Done"が表示できれば、RDSに接続できていることが分かります。

call.py
call.py
import boto3
import mysql.connector

# boto3で使用するリージョンを設定
ssm = boto3.client('ssm', region_name='ap-northeast-1')

# パラメータストアから認証情報を取得
db_user = ssm.get_parameter(Name='/db/user')['Parameter']['Value']
db_pass = ssm.get_parameter(Name='/db/password', WithDecryption=True)['Parameter']['Value']

# RDSに接続
cnx = mysql.connector.connect(user=db_user, password=db_pass, host='<rdsのエンドポイント>', database='test')

cursor = cnx.cursor()

# データを選択
cursor.execute("SELECT * FROM employees")

# 選択したデータを出力
for (first_name, last_name) in cursor:
    print(f"{first_name} {last_name}")

cnx.close()

ではこのプログラムを使ってデータを取得してみます。

$ python call.py
John Done

おお、出力されました!
これでParameterStoreから値を取得してRDSに接続できることが分かりました。

KMSでデータの暗号化

KMSで暗号化された状態のデータも確認したかったのですが、それはどうもできないようです。
とりあえずRDSのコンソール画面より、CMKによって暗号化されていることだけ確認しました。

まとめ

一連の通信をセキュアに行うことができました。
ParameterStoreから値を取得するのはコードを数行書くだけでよいので意外と簡単です。
値のローテーションや動的にパスワードを取得する必要がある場合はSeacrets Managerも検討するとよいかもしれません。

terraformを使って環境を構築する際、
全体の構成図を細かい部分まで事前に考えておくとで、コードを書く際に躓きにくくなるなと感じました。

例えば、ルートテーブルやセキュリティグループ、ロールの許可範囲など
あまり実際の構成図に書かない部分も最初の段階では図にしておくことで、余計な回り道が減るんじゃないかと感じました。

ちなみに、今回は構成図を詰めていなかったのでかなり躓きました。
次回から構築をする際は事前準備に時間をかけてみたいと思います。

参考資料
https://www.amazon.co.jp/実践Terraform-AWSにおけるシステム設計とベストプラクティス-技術の泉シリーズ(NextPublishing)-野村-友規/dp/4844378139
https://dev.classmethod.jp/articles/install-mysql-client-to-amazon-linux-2023/
https://dev.classmethod.jp/articles/note-about-terraform-ignore-changes/

GitHubで編集を提案

Discussion