RDS で MySQL を1台立てて、自宅の iMac の Python からつなぎ、テーブルを作って読み書きして、消すところまでやりました。AWS 側の部品は全部 Terraform で作ります。はじめてのTerraform の続きです。
1. この記事でやること
先に、やってみて分かったことを2つ書きます。
| 分かったこと | |
|---|---|
| 1 | RDS は、別々のアベイラビリティゾーン1のサブネット2を2つ以上渡さないと作れない。本体は1台なのに、置き場所の候補は2か所以上要る |
| 2 | パスワードは Terraform のファイルに書かず、環境変数で渡せる。それでも terraform.tfstate の中には平文で入る |
やった順番は、Terraform に部品を3つ書き足す → apply → Python からつなぐ → テーブルを作って読み書き → destroy です。かかった時間とお金の数字は、途中に挟むと話が切れるので、9章にまとめて置きました。
前提を先に書いておきます。
- Terraform と AWS CLI のインストール・AWS の認証・リージョン(東京)の設定は、前回までの記事で済ませてあります
- 作業フォルダも前回と同じ
~/Documents/aws-terraform-studyで、terraform init済みです。新しいフォルダで始める場合は、provider の設定とterraform initが先に要ります - 専門用語の説明は巻末にまとめました。本文の脚注番号を押すと説明へ飛び、説明の末尾の記号で元の場所へ戻れます
2. RDS とは何か
RDS(Relational Database Service)は、AWS がデータベースサーバーを1台貸すサービスです。EC2 と違って OS のユーザーはもらえず、見えるのはデータベースの口(MySQL なら 3306 番ポート)だけです。
EC2 に自分で MySQL を入れる形と比べると、仕事の分担がこう変わります。
| 仕事 | EC2 に自分で入れる | RDS |
|---|---|---|
| OS の管理・パッチ | 自分 | AWS 側が持つ |
| MySQL のインストール | 自分 | 作成時に選ぶだけ |
| バックアップの仕組み | 自分で組む | 設定1つ(今回は切った) |
| SQL を打つ・テーブル設計 | 自分 | 自分 |
今回選んだものは次の4つです。
| 項目 | 選んだもの |
|---|---|
| エンジン | MySQL 8.0 |
| インスタンスクラス(サーバーの大きさ) | db.t4g.micro(いちばん小さい) |
| ディスク | gp23 の 20GB(これより小さくはできない) |
| リージョン | 東京(ap-northeast-1) |
3. Terraform に書き足した3つの部品
EC2 の回で作った main.tf に、RDS 用の部品を3つ足します。セキュリティグループ、DB サブネットグループ、RDS 本体です。
3-1. セキュリティグループ — 3306 番を自宅からだけ
resource "aws_security_group" "rds" {
name = "aws-study-tf-rds-sg"
description = "mysql from my home only"
ingress {
description = "mysql"
from_port = 3306
to_port = 3306
protocol = "tcp"
cidr_blocks = [var.my_ip]
}
egress {
description = "all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
EC2 用のセキュリティグループ(22番だけ開けたもの)がすでにあるので、そこに 3306 番を足せば行数は減ります。分けたのは、同じグループに足すと EC2 を立てたときに 3306 番も一緒に開くからです。穴の一覧は用途ごとに別の部品にしておくと、あとで消すときも1つずつ消せます。
var.my_ip には自宅のグローバル IP が入ります(渡し方は4章で。この記事では例として 203.0.113.45 を使います)。
3-2. DB サブネットグループ — EC2 との一番の違い
RDS には、EC2 のときには無かった部品が1つ要ります。「どのサブネットに置いてよいか」の一覧、DB サブネットグループです。
# 既定の VPC を AWS に聞く。作るのではなく読むだけ
data "aws_vpc" "default" {
default = true
}
# その VPC の中にある、AZ ごとの既定サブネットを AWS に聞く。東京は 1a/1c/1d の3つ
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
filter {
name = "default-for-az"
values = ["true"]
}
}
# RDS を置いてよい場所の一覧。RDS は 2つ以上の AZ を渡さないと作れない
resource "aws_db_subnet_group" "study" {
name = "aws-study-tf-db-subnet-group"
description = "subnets for the study RDS"
subnet_ids = data.aws_subnets.default.ids
}
RDS 本体は1台しか作らないのに、別々のアベイラビリティゾーン(AZ)のサブネットを2つ以上渡さないと、作成そのものを断られます。EC2 は「このサブネットに置く」と1か所指せば済んでいました。
理屈はこうです。RDS は故障や保守のときに別の AZ へ動かせる作りになっていて、その引っ越し先を最初から申告しておく決まりになっています。実際に配置されるのは1か所だけです。
もう1つ、サブネットの ID(subnet-0a1b2c3d… のような値)を直接書かず、既定の VPC4 から data で聞く形にしました。data は「作る」ではなく「読む」だけの書き方です。ID はアカウントごとに違うので、直接書くとそのままでは他の人の環境で動きません。
3-3. RDS 本体
# RDS 本体。MySQL 8.0 を1台。⛔ ここから先は apply すると課金が始まる
resource "aws_db_instance" "study" {
identifier = "aws-study-tf-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t4g.micro"
# ディスクの大きさ。20GB より小さくはできない
allocated_storage = 20
storage_type = "gp2"
# 中に最初から作っておくデータベースの名前
db_name = "studydb"
username = "admin"
password = var.db_password
# 3-1 で作ったセキュリティグループと、3-2 で作ったサブネットの一覧を指す
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.study.name
# iMac から直接つなぐので、インターネット側に口を出す
publicly_accessible = true
# 学習用なので節約する。バックアップを取らない・AZ をまたいで二重化しない
backup_retention_period = 0
multi_az = false
# destroy のとき「最後のバックアップを取りますか」で止まらないようにする
skip_final_snapshot = true
apply_immediately = true
}
行ごとの意味です。
-
db_name = "studydb"— RDS というサーバーの中に、最初から1つ作っておくデータベース(テーブルの入れ物)の名前です。サーバー本体も「データベース」と呼ぶので言葉が二重になりますが、6章でつなぐ先はこの studydb です -
engine_version = "8.0"— 細かい版(8.0.何とか)まで書かず、系列だけ指定しました。どの版が選ばれたかは5章で分かります -
publicly_accessible = true— 自宅から直接つなぐための設定です。仕事のデータベースなら false にして、同じ VPC の中のサーバーからだけつなぐ形にします。今回は学習用で、3306 番は自宅の IP からしか開いていないので、この形にしました -
backup_retention_period = 0とmulti_az = false— 自動バックアップと2台構成を切りました。どちらも切るとそのぶん安くなります -
skip_final_snapshot = true— これを書かないと、destroy が「最後のスナップショット5の名前を指定してください」というエラーで止まります。止まって調べている間も課金は続くので、学習用では最初から true にしておきます
あわせて、apply が終わったときにつなぎ先が画面に出るよう、outputs.tf に出力を足しておきます。5章と6章で使います。
# RDS のつなぎ先。6章でここへ pymysql からつなぐ
output "db_endpoint" {
description = "RDS のエンドポイント(ホスト名:ポート)"
value = aws_db_instance.study.endpoint
}
4. パスワードをファイルに残さない
RDS には管理者パスワードが要ります。password = "..." と直接書けば動きますが、そのファイルを git に入れた瞬間、履歴にパスワードが残ります。今回はこう書きました。
# RDS の管理者パスワード。⛔ default を書かない(書くとファイルに残る)
# 渡し方:ターミナルで export TF_VAR_db_password='…' してから terraform を打つ
variable "db_password" {
description = "RDS の master パスワード(8文字以上・/ ' \" @ と空白は使えない)"
type = string
sensitive = true
}
ポイントは2つです。
-
defaultを書かない。 Terraform は、値の無い変数をTF_VAR_変数名という環境変数から探します。ファイルには「パスワードという変数がある」ことしか残りません -
sensitive = trueを付ける。 plan や apply の画面に値が出ず、(sensitive value)と表示されます
パスワードの実物は、ホームディレクトリ直下のファイルに置きました。git の管理外で、読めるのは自分だけです。
echo '(パスワード)' > ~/.aws-study-db-password
chmod 600 ~/.aws-study-db-password
export TF_VAR_db_password="$(cat ~/.aws-study-db-password)"
3-1 の var.my_ip(自宅のグローバル IP)も同じ形です。variables.tf にはこう書いてあります。
# ssh と MySQL を許す IP。⛔ default を書かない(書くとファイルに IP が残る)
# 渡し方:export TF_VAR_my_ip="$(curl -s https://checkip.amazonaws.com)/32"
variable "my_ip" {
description = "自宅のグローバル IP を /32 で(例: 203.0.113.45/32)"
type = string
sensitive = true
}
こちらは値を控えておく必要すらなく、毎回その場で調べて渡します。回線が変わっても書き直しが要りません。値には末尾の /326 まで含めます。
export TF_VAR_my_ip="$(curl -s https://checkip.amazonaws.com)/32"
ただし、1か所だけ残ります。terraform.tfstate の中には平文のまま入ります。tfstate は Terraform が「いま何を作ってあるか」を控えるファイルで、パスワードもそのまま書かれます。ここは仕組み上避けられないので、tfstate を必ず .gitignore に入れます。
なお、パスワードに使えない文字があります(/ ' " @ と空白)。variables.tf の description に書いておくと、次に打つときに思い出せます。
5. apply する — 今回は3つだけ作る
terraform plan を打つと、こう出ました。
Plan: 10 to add, 0 to change, 0 to destroy.
足したのは3つなのに、10個作ろうとしています。理由は、前回の学習の最後に terraform destroy で全部消していたからです。Terraform は main.tf と tfstate(いま有るものの控え)を見比べて差分を作るので、控えが空だと EC2 も S3 も「まだ無いもの」として全部作りに行きます。
今回 EC2 は使わないので、-target で RDS の3つに絞りました。
terraform apply \
-target=aws_security_group.rds \
-target=aws_db_subnet_group.study \
-target=aws_db_instance.study
これで Plan: 3 to add になります。代わりに、警告が2本出ます。
Warning: Resource targeting is in effect
Warning: Applied changes may be incomplete
意味は「一部だけ作ったので、main.tf に書いてある残りは置き去りです」です。-target は普段使いの道具ではない、と Terraform 自身が言っています。今回は「EC2 を置き去りにする」のが目的なので、この警告は想定どおりです。なお、この記事の部品だけを新しい main.tf に書いた場合は最初から 3 to add になり、-target は要りません。
apply の画面では、こういう行が延々と流れます。
aws_db_instance.study: Still creating... [01m40s elapsed]
aws_db_instance.study: Still creating... [01m50s elapsed]
RDS の作成は EC2 とは桁が違って、数分かかります(実測は9章)。終わると、つなぎ先が表示されます。
db_endpoint = "aws-study-tf-db.xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306"
この db_endpoint の値がエンドポイント7で、6章のつなぎ先になります。
版は engine_version = "8.0" とだけ書きましたが、選ばれたのは 8.0.46 でした。6章でつないで SELECT VERSION() を打つと確かめられます。
6. Python からつなぐ
Mac 側の準備は pymysql(Python から MySQL につなぐ部品)を1つ入れるだけです。ここで今回最初のエラーが出ました。
pip3 install pymysql
error: externally-managed-environment
Homebrew で入れた Python は、pip での直接の追加を断る作りになっています(PEP 668 という決まりです)。以前 brew install awscli が Python ごと入れ替えて、関係ないスクリプトが動かなくなったことがあったので(はじめてのAWS CLI の回)、この断りはそういう事故の予防だと考えて、venv(プロジェクト専用の Python 置き場)を作る形にしました。
cd ~/Documents/aws-terraform-study
python3 -m venv venv
venv/bin/pip install pymysql
venv はこのフォルダの中で完結し、Mac 本体の Python には何も足しません。以後、このプロジェクトでは python3 ではなく venv/bin/python3 を打ちます。
つなぐスクリプト(db_connect.py)です。冒頭の説明コメントだけ省いて載せます。パスワードは4章と同じファイルから読み、スクリプトには書きません。
import os
import pymysql
pw_file = os.path.expanduser("~/.aws-study-db-password")
conn = pymysql.connect(
host=os.environ["DB_HOST"],
port=3306,
user="admin",
password=open(pw_file).read().strip(),
database="studydb",
connect_timeout=10,
)
with conn.cursor() as cur:
cur.execute("SELECT VERSION()")
print("MySQL の版 :", cur.fetchone()[0])
cur.execute("SELECT DATABASE()")
print("いま見ている DB :", cur.fetchone()[0])
cur.execute("SELECT CURRENT_USER()")
print("名乗っている利用者:", cur.fetchone()[0])
cur.execute("SELECT NOW()")
print("RDS 側の現在時刻 :", cur.fetchone()[0])
conn.close()
print("つながった ✅")
つなぎ先は Terraform の出力から環境変数で渡します。
export DB_HOST="$(terraform output -raw db_endpoint | cut -d: -f1)"
venv/bin/python3 db_connect.py
cut -d: -f1 は、出力の末尾に付く :3306 を切り落としてホスト名だけにするためです。pymysql にはホスト名とポート番号を別々に渡します。
MySQL の版 : 8.0.46
いま見ている DB : studydb
名乗っている利用者: admin@%
RDS 側の現在時刻 : 2026-09-19 12:02:27
つながった ✅
admin@% の % は「どの場所からつないできても」を表す MySQL の書き方です。
打ったのは夜の21時02分なのに、RDS の時計は12時02分です。RDS の MySQL は、時刻の設定が最初は UTC(日本より9時間前)になっています。壊れているのではなく既定値です。時刻を入れる列を使うときは、どちらの時計で入るのかを先に確かめておくと混乱しません。
7. テーブルを作って、入れて、読む
ここからは SQL です。mysql コマンド(クライアント)は Mac に入れていません。brew install mysql-client は依存に Python を含んでいて、6章に書いた入れ替えと同じことが起きうるからです。SQL はすべて、6章と同じ形の Python スクリプトから cur.execute("CREATE TABLE ...") のように流します。計測結果を入れる小さなテーブルを1つ作りました。
7-1. CREATE TABLE — 書いていないものが3つ足される
CREATE TABLE IF NOT EXISTS bench_result (
id INT NOT NULL AUTO_INCREMENT,
measured_on DATE NOT NULL,
machine VARCHAR(50) NOT NULL,
item VARCHAR(100) NOT NULL,
elapsed_ms DECIMAL(10, 1) NOT NULL,
note VARCHAR(200),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
)
列の指定で使ったのは3つです。AUTO_INCREMENT は番号を自動で振る指定(この id を主キーにしました)、DECIMAL(10, 1) は小数1桁まで入る数値、DEFAULT CURRENT_TIMESTAMP は行を入れた時刻が自動で入る指定です。
作ったあとに SHOW CREATE TABLE で定義を読み返すと、書いた覚えの無いものが3つ付いていました。
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
ENGINE(テーブルの保存方式)、CHARSET(文字コード)、COLLATE(並べ替えの規則)です。どれも既定値が自動で入り、定義に明示された形で保存されます。裏を返すと、文字コードを utf8mb4 以外にしたいなら自分で書く必要がある、ということでもあります。
7-2. INSERT — commit を忘れると消える
次にデータを入れます。中身は、これまでの回で控えた計測メモ(ollama を入れるのにかかった時間など)5件です。
SQL = ("INSERT INTO bench_result (measured_on, machine, item, elapsed_ms, note) "
"VALUES (%s, %s, %s, %s, %s)")
with conn.cursor() as cur:
cur.executemany(SQL, ROWS) # ROWS は5件の計測メモのリスト
commit は「ここまでの変更を確定する」合図です。これをわざと書かずに接続を閉じる実験をしました。結果はこうです。
同じ接続の中からは5件見えているのに、つなぎ直すと0件です。見えていたのに、無かったことになります。
原因を調べると、MySQL サーバー側の設定(@@GLOBAL.autocommit)は 1(自動 commit する)でした。ところが自分の接続の設定(@@SESSION.autocommit)を見ると 0 です。確かめ方も SQL で、SELECT @@GLOBAL.autocommit, @@SESSION.autocommit を打つと 1 と 0 が並んで返ります。pymysql が、接続するときに自動 commit を切っています。サーバーの既定だけ調べて「MySQL は自動 commit だから書かなくていい」と覚えると、Python から使ったときにデータが消えます。conn.commit() を明示的に書くのが確実です。
commit し直して入れた5件の id は、1〜5 ではなく 6〜10 になっています。捨てられた5件のぶんも AUTO_INCREMENT の番号は進んだままで、戻りません。id の欠番は異常ではない、ということです。
7-3. SELECT — 読み返して件数を確かめる
読み返しのスクリプトは5つの SELECT を流します。出力の抜粋です(②は全件の一覧、③は machine ごとの集計、⑤は次に振られる id 番号で、ここでは省略します)。
① 全部で何件か : 5 件(入れたのは5件)
④ 時間のかかった順に上位3件(LIMIT)
1位 terraform apply(RDS 本体) 357000.0 ms
2位 ollama 本体を入れる 10000.0 ms
3位 llama3.2:3b を落とす 10000.0 ms
COUNT・ORDER BY・GROUP BY・LIMIT がひととおり動くことを確かめて、SQL の部は終わりです。
8. 消す
destroy には -target を付けません。全部消すときに絞る理由が無く、絞らないほうが消し残しも出ないからです。
terraform destroy
3-3 で skip_final_snapshot = true を書いておいたので、スナップショットの名前を聞かれずにそのまま消えました。
消えたことは、画面の Destroy complete だけでなく、AWS に聞き直して確かめます。
aws rds describe-db-instances --query 'DBInstances[].DBInstanceIdentifier' --output text
aws rds describe-db-snapshots --query 'DBSnapshots[].DBSnapshotIdentifier' --output text
2本とも何も表示されなければ、本体もスナップショットも残っていません。スナップショットまで見るのは、本体を消してもスナップショットには保存料がかかるからです(今回は backup_retention_period = 0 なので最初からありません)。
9. かかった時間とお金
本文で後回しにした数字を、ここに全部並べます。
時間
| 何に | 実測 |
|---|---|
| terraform apply(RDS 本体) | 5分57秒 |
| (比較)EC2 を apply したとき | 32秒 |
| terraform destroy(RDS 本体) | 1分51秒 |
作るのに6分、消すのに2分です。EC2 の感覚で待っていると「失敗したのでは」と不安になりますが、この長さが正常です。
お金
単価はこうです(AWS の料金表からの概算。為替と料金改定で変わります)。
| 何に | 概算 |
|---|---|
| db.t4g.micro | 1時間 約4.5円 |
| ディスク gp2 20GB | 1時間 約0.6円 |
| 合計 | 1時間 約5円 |
今回、apply の開始から destroy の完了までは 44分22秒で、約3.8円でした。この記事を同じ順でなぞって、SQL の練習をゆっくりやっても、1時間・5円ほどで収まる計算です。
気をつける点が2つあります。
-
-targetで絞らずに 10 to add のまま apply していたら、EC2(m7i-flex.large・1時間 約19円)も立って、概算で5倍かかっていました - 消し忘れて1か月立てたままにすると、1時間約5円 × 24時間 × 30日 = 月およそ3,600円です。学習用なら、使うたびに作って消すほうが安くつきます
10. つまずいたところ
| つまずき | 分かったこと | |
|---|---|---|
| 1 |
pip3 install が断られた |
Homebrew の Python は pip の直接追加を断る(PEP 668)。venv を作ればフォルダの中で完結する |
| 2 | plan が 10 to add だった | tfstate が空だと、Terraform は main.tf の全部を作りに行く。今回使うものだけ -target で絞れる |
| 3 | INSERT したデータが消えた | pymysql は接続時に自動 commit を切る。conn.commit() を明示的に書く |
| 4 | NOW() が9時間ずれていた | RDS の MySQL は既定が UTC。壊れているのではない |
EC2 のときと勝手が違ったのは、サブネットグループ(置き場所の候補を2つ以上)と、作成・削除に数分かかることの2つでした。逆に、Terraform の書き方そのものは EC2 と同じ型で通りました。resource を書いて plan で差分を見て apply する、の繰り返しです。
-
アベイラビリティゾーン(AZ)。同じリージョンの中で、建物単位に分かれたデータセンターのまとまり。東京リージョンには 1a・1c・1d の3つがあります。 ↩
-
サブネット。VPC の中をさらに区切った区画。既定の VPC には AZ ごとに1つずつあります。 ↩
-
gp2。EBS(AWS のディスク)の種類の1つで、標準的な SSD です。 ↩
-
VPC(Virtual Private Cloud)。AWS の中に作る、自分用のネットワークの区画。アカウントには「既定の VPC」が最初から1つ用意されています。 ↩
-
スナップショット。その時点のデータベースの中身をまるごと控えたもの。ここから元に戻せます。残しておくと保存料がかかります。 ↩
-
/32。IP アドレスの範囲を表す書き方(CIDR 記法)で、/32 は「この1つの IP だけ」という意味です。 ↩
-
エンドポイント。RDS につなぐためのホスト名。裏側の IP アドレスは AWS の都合で変わることがあるため、この名前のほうでつなぎます。 ↩