SQLiteからRDS for PostgreSQLへの移行をpgloaderで検証してみた
はじめに
SQLiteで動いているアプリケーションのデータベースを、Amazon RDS for PostgreSQL に移行できるか検証する機会がありました。
移行ツールとして選んだのは pgloader です。SQLiteのスキーマとデータをまとめてPostgreSQLへ流し込んでくれるツールです。
結論から言うと、サンプルデータの移行は成功しました!
約25,000行を0.730秒、ロードエラー0件で移行できています。
ただし、スキーマの「制約」については、今回の環境では NOT NULL が反映されませんでした。
この記事では、環境構築から移行・検証までの手順を一通り追いつつ、途中でハマった箇所もご紹介します。同じことをやろうとしている方の参考になれば幸いです。
この記事で分かること
- pgloaderを使ったSQLite → RDS for PostgreSQL 移行の実際の手順
- RDSに対してpgloaderを実行するときのSSL設定のハマりどころ
- pgloaderが「移行してくれたもの」と「してくれなかったもの」の切り分け
- 移行後にやるべき制約の貼り直し作業
注意点
この記事の結果は、今回利用したpgloader 3.6.7~develと、後述するサンプルSQLite DBの組み合わせで観測されたものです。pgloaderの全バージョン・全スキーマで同じ結果になるとは限らないため、実際に移行される際はご自身の環境で必ず確認してください。
検証環境
今回使用した検証環境作成用のCloudFormationテンプレートやサンプルDB作成用のスクリプト、移行検証用のスクリプトは下記にアップしてますので、もしお手元でも試してみたい方いたら是非ご活用ください!
myan-aws/README.md at sqlite-rds-verify · MiyamaYuki-CM/myan-aws · GitHub
今回の検証環境について説明します。
| 項目 | 内容 |
|---|---|
| 移行元 | SQLite 3(サンプルデータ 24,928行 / 1.8MB) |
| 移行先 | Amazon RDS for PostgreSQL 16.14(db.t3.micro、シングルAZ、プライベートサブネット) |
| 実行ホスト | Amazon Linux 2023(t3.micro、パブリックサブネット、Session Managerで接続) |
| pgloader | 3.6.7~devel(Dockerイメージ dimitri/pgloader:latest) |
構成はシンプルに、EC2を作業ホストとしてRDSへ接続する形にしています。
[EC2 (Session Managerで接続, Docker/pgloader/sqlite3)] --5432--> [RDS for PostgreSQL]
EC2にはSSHポートを開けず、SSM Session Managerで接続します。踏み台やSSH鍵の管理が不要になるので、使い捨ての検証環境ではこの方式が楽なので。
なお、Amazon LinuxはDNF標準リポジトリにpgloaderが無いため、pgloaderメンテナが提供しているDockerイメージを利用しています。
Step 0. 検証環境デプロイ
CloudShell等で以下を実行します。
「アクション」からファイルのアップロードが可能です。
今回使用するCloudFormationのテンプレートファイルをアップロードしておきましょう。

事前に専用のフォルダを作っておくことをおすすめします。

作成した cfn フォルダにテンプレートファイルを配置することができました。

以下をCloudShellで実行します。
aws cloudformation deploy \
--template-file cfn/verify-environment.yaml \
--stack-name sqlite-pg-verify \
--parameter-overrides DBMasterPassword='<強固なパスワードを指定>' \
--capabilities CAPABILITY_NAMED_IAM

CloudFormationのスタックの作成に成功したら、Outputsから RDSEndpoint と EC2InstanceId、そして TransferBucketName を控えます。

Step 1. EC2へ接続
作成したEC2へSSMで接続します。CloudShellで以下のコマンドを実行します。
<EC2InstanceId> は先ほど確認した値に置き換えてください。
aws ssm start-session --target <EC2InstanceId>
接続できました。

Step 2. サンプルSQLiteデータの生成
今回は移行元の実データが手元に無いため、検証用のサンプルデータを生成して使います。
使用するファイルをEC2へ配置する
サンプルデータ生成スクリプト等をS3経由でEC2へ渡します。作成されたバケットに scripts/ フォルダを作成し、以下3ファイルをアップロードしてください。
| ファイル | 役割 |
|---|---|
generate_sample_sqlite.py |
サンプルSQLite DBを生成する |
migrate.load |
pgloaderに渡す移行定義ファイル |
compare_migration.py |
移行前後のデータを突合する |
その後、先ほど接続したEC2上で以下コマンドを実行してください。
BUCKET=<TransferBucketName>
sudo -u ec2-user aws s3 cp s3://$BUCKET/scripts/generate_sample_sqlite.py /home/ec2-user/scripts/generate_sample_sqlite.py
sudo -u ec2-user aws s3 cp s3://$BUCKET/scripts/compare_migration.py /home/ec2-user/scripts/compare_migration.py
sudo -u ec2-user aws s3 cp s3://$BUCKET/scripts/migrate.load /home/ec2-user/data/migrate.load
サンプルデータを生成する
sudo -u ec2-user python3 /home/ec2-user/scripts/generate_sample_sqlite.py --rows 5000 --out /home/ec2-user/data/sample.db

以下生成されるスキーマの一例です。
いくつかテーブルを作成しています。
CREATE TABLE vendors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL -- 日時をISO8601の文字列で保持
);
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_id INTEGER NOT NULL, -- あえてREFERENCESを書いていない
estimate_no TEXT NOT NULL,
estimate_date TEXT NOT NULL, -- ISO8601文字列 (例: '2026-07-15')
total_amount REAL NOT NULL, -- 金額をREALで保持
ocr_confidence REAL,
is_reviewed INTEGER NOT NULL DEFAULT 0, -- 真偽値をINTEGER 0/1で表現
created_at_epoch INTEGER NOT NULL, -- 日時をUNIXエポック秒で保持
thumbnail BLOB
);
-- 他 estimate_items / expense_categories / expense_entries
観点としてはこのあたりです。
INTEGER PRIMARY KEY AUTOINCREMENT- 日時を TEXT(ISO8601)で持つ列と INTEGER(UNIXエポック)で持つ列の両方
- 真偽値を INTEGER 0/1 で表現
- 外部キー定義なし(移行後にFKを追加するシナリオを検証したいので、意図的に
REFERENCESを書いていません) - 日本語テキスト(全角・半角混在)
- BLOB列
外部キーについて少し補足しておきます。SQLiteの PRAGMA foreign_keys は「定義済みの外部キー制約を実行時に強制するかどうか」を制御する設定で、スキーマに外部キーが定義されているかどうかとは別の話です。強制がOFFでも、REFERENCES で定義した外部キーは PRAGMA foreign_key_list から取得できますし、pgloaderもこのPRAGMAを使って外部キーを検出します。今回FKが0件になるのは、単純に生成スキーマへ REFERENCES を書いていないからです。詳細はSQLite公式ドキュメントをご確認ください。
なお、本サンプルでは型変換の挙動を確認する目的で金額を REAL で保持していますが、実システムの金額型として REAL を推奨する意図はありません。金額は最小通貨単位の整数、または小数桁を定義した numeric 相当で管理するのが一般的です。
もう1点、生成スクリプトの実装上の注意です。エポック秒を作るところで、タイムゾーン情報を持たない datetime に対して timestamp() を呼んでいます。
int(est_date.timestamp())
datetime.timestamp() はnaiveなdatetimeをローカルタイムとして解釈するため、実行環境のタイムゾーン次第で値が変わります。UTC固定で扱いたい場合は、生成時点でタイムゾーンを持たせておくのが安全です。
from datetime import datetime, timedelta, timezone
base_date = datetime(2026, 1, 1, tzinfo=timezone.utc)
生成されたDBを覗いてみる
sqlite3 コマンドで中身を確認できます。
sudo -u ec2-user sqlite3 /home/ec2-user/data/sample.db
.tables -- テーブル一覧
.schema -- 全テーブルのDDL
SELECT COUNT(*) FROM estimates; -- 行数確認
Step 3. pgloaderで移行実行
移行定義ファイル(migrate.load)
pgloaderは、こういったコマンドファイルを渡して実行します。
LOAD DATABASE
FROM sqlite:///data/sample.db
INTO postgresql://<ユーザー名>:<パスワード>@<RDSエンドポイント>:5432/testdb?sslmode=prefer
WITH include drop, create tables, create indexes, reset sequences,
downcase identifiers
SET work_mem to '16MB',
maintenance_work_mem to '512MB'
-- SQLiteのREAL(金額列)をPostgreSQLのnumericへ変換する
CAST type real to numeric using float-to-string
;
?sslmode=prefer がポイントです。ここに至るまでに何度もつまずきました笑(後述)。
WITH 句と SET 句についても2点補足しておきます。
include dropは、対象テーブルをCASCADE付きで削除します。移行対象外のオブジェクトまで巻き込む可能性があるので、検証用の空DBでのみ使うようにしてください(詳細はpgloader公式ドキュメントをご確認ください)work_mem/maintenance_work_memの値は、インスタンスのメモリ量と並列度に応じて調整してください。この記事の値をそのまま本番環境へ持ち込むことは推奨しません
実行
sudo -u ec2-user docker run --rm -v /home/ec2-user/data:/data dimitri/pgloader:latest \
pgloader /data/migrate.load
なお、今回は使い捨ての検証環境なので、migrate.load の接続URIにパスワードを直接書いています。このファイルには平文でパスワードが残るので、検証が終わったらファイルごと削除しておきましょう。本番相当の環境で実行する場合は、pgloaderがサポートしている PGPASSFILE / .pgpass など、平文を残さない方法を検討してください。
コマンドが正常に完了し、移行に成功しました!

table name errors rows bytes total time
----------------------- --------- --------- --------- --------------
Create tables 0 10 0.030s
----------------------- --------- --------- --------- --------------
vendors 0 8 0.3 kB 0.130s
estimates 0 5000 635.1 kB 0.310s
estimate_items 0 14915 940.5 kB 0.430s
expense_entries 0 5000 235.7 kB 0.300s
expense_categories 0 5 0.1 kB 0.040s
----------------------- --------- --------- --------- --------------
Create Indexes 0 6 0.030s
Reset Sequences 0 5 0.100s
Primary Keys 0 5 0.000s
Create Foreign Keys 0 0 0.000s
----------------------- --------- --------- --------- --------------
Total import time ✓ 24928 1.8 MB 0.730s
24,928行・1.8MBで0.730秒。小規模データなら移行そのものは一瞬です。
【ハマりどころ】ここに至るまでに3回失敗した話
上のコマンドはあっさり成功しているように見えますが、実際には3回ほどエラーで止まっています。同じところで詰まる方がいそうなので、順番に載せておきます。
pgloaderのエラーは初見だとかなり面食らいましたが、先頭の ^ が指している位置を見ると大体の原因の見当をつけることができました!
① WITH 句のオプション記法が違う
KABOOM!
ESRAP-PARSE-ERROR: At
WITH include drop, create tables, create indexes, reset sequences,
data only = false,
^ (Line 18, Column 15, Position 795)
data only = false と書いていたのが原因でした。
pgloaderの WITH 句のオプションには2種類あります。
- 値を取るオプション:
batch rows = 10000、workers = 8のように= 値を書く - フラグとして有無で指定するオプション:
include drop、create tables、data only、schema onlyなど
data only は後者のフラグなので = false とは書けません。そもそも「スキーマもデータも移行する」のがデフォルトなので、この行自体が不要でした。素直に削除します。
② SSL指定なしだと接続を拒否される
KABOOM!
DB-CONNECTION-ERROR: Failed to connect to pgsql at "<masked>.rds.amazonaws.com"
(port 5432) as user "<masked>": Database error 28000:
no pg_hba.conf entry for host "<masked>", user "<masked>", database "<masked>", no encryption
RDS for PostgreSQL は、バージョン15以降デフォルトパラメータグループの rds.force_ssl が 1 になっており、暗号化されていない接続を拒否します。今回のPostgreSQL 16も当てはまります。メッセージ末尾の no encryption がそれを示しています。
接続URLに ?sslmode=... を付ければよさそうです。……が、ここで次の落とし穴が待っていました。
③ sslmode=require にすると今度は証明書検証で落ちる
素直に ?sslmode=require を付けて再実行したところ、今度はこうなりました。
KABOOM!
DB-CONNECTION-ERROR: Failed to connect to pgsql at "<masked>.rds.amazonaws.com"
(port 5432) as user "<masked>": SSL verify error: 19 X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN
ERROR Connecting to PostgreSQL <masked>: SSL verify error: 19 X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN
LOG You may try --no-ssl-cert-verification
SSL接続自体は始まったものの、証明書チェーンの検証で失敗しています。pgloaderのDockerイメージ内のOpenSSLが、AWSのRDS CA証明書を信頼できていない状態です。
親切なことに、エラーログ自身が You may try --no-ssl-cert-verification と案内してくれています。オプションもちゃんと存在します。
$ docker run --rm dimitri/pgloader:latest pgloader --help | grep ssl
--no-ssl-cert-verification boolean Instruct OpenSSL to bypass verifying certificates.
素直に従ってみます。
# ファイルパスの後ろに置いた場合 → 引数として認識されず無視される
pgloader /data/migrate.load --no-ssl-cert-verification # ✗ 同じエラー
# ではファイルパスの前に置けば…?
pgloader --no-ssl-cert-verification /data/migrate.load # ✗ やはり同じエラー
効きませんでした。 オプションの位置を直しても結果は変わらず、ログはまた You may try --no-ssl-cert-verification と案内してきます。堂々巡りです。
解決: sslmode=prefer を使う
CLIフラグでの回避は諦めて、接続URL側の sslmode を変えてみたところ、あっさり通りました。
LOAD DATABASE
FROM sqlite:///data/sample.db
- INTO postgresql://user:pass@<endpoint>:5432/testdb?sslmode=require
+ INTO postgresql://user:pass@<endpoint>:5432/testdb?sslmode=prefer
ここで観測したことを整理しておきます。
今回利用したDockerイメージのpgloader 3.6.7~develでは、sslmode=require を指定すると証明書チェーンの検証エラーになり、sslmode=prefer では接続できました。
この挙動が、libpq(psql 等)の sslmode と完全に同じ意味だとは限りません。pgloaderのバージョンやビルドに依存する可能性があるため、あくまで「今回の実行環境で観測した挙動」として扱ってください。pgloaderのドキュメントでサポートされていると書かれている sslmode は disable / allow / prefer / require の4種類ですが、証明書検証の詳細な意味までは明記されていません(pgloader公式ドキュメント)。
libpq側の挙動についても補足しておきます。sslmode=require はSSL接続を必須にしますが、root CAファイルが存在する場合は後方互換性のために verify-ca 相当の証明書検証を行うことがあります。証明書を検証したいなら、この挙動に頼らず明示的に verify-ca / verify-full を指定することが公式ドキュメントで推奨されています。
そして prefer については、こう理解しておくのが正確です。
preferは、まずSSL接続を試し、失敗した場合には非SSL接続へフォールバックし得るモードです。今回はRDS側のrds.force_ssl=1によって非SSL接続が拒否されるため、結果として通信はSSLになります。ただし、接続先の真正性は検証されません。
「結果としてSSLになっている」だけなので、実際にSSLで繋がっているかは接続後に確認しておくと安心です。
SELECT ssl, version, cipher
FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
本番データの移行では、prefer ではなくRDSのCA証明書バンドルを使った正規の検証を行うことを推奨します(後述)。
Step 4. 移行結果を検証する
無事に移行できたので、本当に正しく移行できているかを確認していきます。
以降の psql は、SSLを明示するため ?sslmode=require を付けて実行します。本番では verify-full とCA証明書の指定を推奨します。
4-1. テーブルとシーケンスの確認
まずは何が作られたかを見てみます。
sudo -u ec2-user docker run --rm -it --network host postgres:16 \
psql "postgresql://<user>:<pass>@<RDSEndpoint>:5432/testdb?sslmode=require" -c "\d+"
テーブル5つ+シーケンス5つが作成されています。



SQLiteの INTEGER PRIMARY KEY AUTOINCREMENT が、PostgreSQLのシーケンス(*_id_seq)に変換されているのが分かります。
ただし、シーケンスになったからといって意味が完全に同じになるわけではない点は押さえておきたいところです。SQLiteの AUTOINCREMENT は、削除済みのものも含めて同一テーブルで一度使われたROWIDを再利用しないことを保証します(SQLite公式ドキュメント)。一方PostgreSQLのシーケンスは、setval や再作成、バックアップ・リストア、手動での値変更によって値の再利用が起こり得ます。採番は継続できますが、保証のレベルは同一ではありません。
4-2. 列の型を確認する
ここからが本題です。SQLiteのスキーマがPostgreSQLでどう解釈されたのかを見ていきます。
sudo -u ec2-user docker run --rm -it --network host postgres:16 \
psql "postgresql://<user>:<pass>@<RDSEndpoint>:5432/testdb?sslmode=require" \
-c "SELECT table_name, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position;"

出力を見て、まず目に飛び込んできたのが is_nullable 列でした。
⚠️ 最重要: 今回の環境ではNOT NULL制約が引き継がれなかった
移行元のSQLiteでは多くの列に NOT NULL を定義していたのですが、PostgreSQL側では主キー id 以外がすべて is_nullable = YESになっていました。
| テーブル | SQLiteで NOT NULL だった列 |
移行後 |
|---|---|---|
| vendors | name, created_at | すべてNULL許容に |
| estimates | vendor_id, estimate_no, estimate_date, total_amount, is_reviewed, created_at_epoch | 同上 |
| estimate_items | estimate_id, item_name, quantity, unit_price, amount | 同上 |
| expense_categories | code, name | 同上 |
| expense_entries | estimate_id, category_id, amount, is_posted | 同上 |
DEFAULT値はきちんと保持されていました。(is_reviewed に '0'::bigint)。
制約だけが引き継がれない結果となりました。
ここで注意したいのは、これを「pgloaderの仕様」として一般化はできないという点です。pgloaderのSQLite実装は PRAGMA table_info で列情報を取得しており、その結果にはNOT NULL情報も含まれています。pgloader自身もNOT NULLを含む一部制約をサポートすると説明しています。つまり今回の結果は、利用した 3.6.7~devel 固有の問題なのか、そのDockerイメージのビルド固有の挙動なのか、SQLite側メタデータとの組み合わせによるものなのか、この検証だけでは切り分けられていません。
今回利用したpgloader 3.6.7~develとサンプルDBの組み合わせでは、主キー以外のNOT NULL制約が移行先へ反映されなかった、という事実として受け取っていただき、移行後は必ず information_schema.columns でご自身の環境の結果を確認してください。
いずれにせよ、これを見逃したまま本番移行すると、アプリケーションが「NOT NULLだから値は必ず入っているはず」と前提していた列に、NULLが入り得る状態になってしまいます。移行後に ALTER TABLE ... ALTER COLUMN ... SET NOT NULL で貼り直す必要がありました。(Step 5 参照)。
4-3. 制約の確認
型の次は制約です。
sudo -u ec2-user docker run --rm -it --network host postgres:16 \
psql "postgresql://<user>:<pass>@<RDSEndpoint>:5432/testdb?sslmode=require" \
-c "SELECT conrelid::regclass AS table_name, conname, contype, pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE connamespace = 'public'::regnamespace
ORDER BY conrelid::regclass::text, conname;"
contype は p=主キー / u=UNIQUE / f=外部キー / c=CHECK です。

結果はこうなりました。
| 制約の種類 | 件数 |
|---|---|
PRIMARY KEY(p) |
5件 ✅ |
FOREIGN KEY(f) |
0件(移行元に定義していないので想定通り) |
UNIQUE(u) |
0件 |
4-4. 外部キーを張ってみる
FKが0件だと分かったので、移行後に手動で追加できるかを試します。今回のサンプルデータは整合性が取れた状態で生成しているので成功する想定ですが、**「移行後にFKを追加する手順が問題なく通ること」**を確認しておく意味があります。
sudo -u ec2-user docker run --rm -it --network host postgres:16 \
psql "postgresql://<user>:<pass>@<RDSEndpoint>:5432/testdb?sslmode=require" \
-c "ALTER TABLE estimates
ADD CONSTRAINT fk_estimates_vendor FOREIGN KEY (vendor_id) REFERENCES vendors(id);
ALTER TABLE estimate_items
ADD CONSTRAINT fk_items_estimate FOREIGN KEY (estimate_id) REFERENCES estimates(id);
ALTER TABLE expense_entries
ADD CONSTRAINT fk_entries_estimate FOREIGN KEY (estimate_id) REFERENCES estimates(id);
ALTER TABLE expense_entries
ADD CONSTRAINT fk_entries_category FOREIGN KEY (category_id) REFERENCES expense_categories(id);"

無事に4本とも追加できました。
4-5. データの突合
最後に、移行前後でデータが一致しているかを確認します。行数・数値列の合計・サンプル行の値を突き合わせるスクリプトを用意しました。
sudo -u ec2-user python3 /home/ec2-user/scripts/compare_migration.py \
--sqlite /home/ec2-user/data/sample.db \
--pg "postgresql://<user>:<pass>@<RDSEndpoint>:5432/testdb?sslmode=require"
ところが、初回はこんな結果になりました。

=== estimates ===
[OK] row count: sqlite=5000 postgres=5000
[OK] sum(total_amount): sqlite=6291685500.0 postgres=6291685500.0 diff=0.0
[NG] id=1 値の差分: {'thumbnail': (b'\xd8\x10\x0f/ow...', <memory at 0x7fc980fb5a00>)}
[NG] id=2 値の差分: {'thumbnail': (b'\x10\xa1\xcd\x89!l...', <memory at 0x7fc980fb5b80>)}
行数も金額の合計も一致しているのに、BLOB列だけが全行NGとなりました。
原因はスクリプト側のバグでした
差分の中身をよく見ると、左右でオブジェクトの型が違うことが分かります。
- SQLite側(
sqlite3モジュール):BLOBをbytesで返す - PostgreSQL側(
psycopg2):byteaをmemoryviewで返す
スクリプト内で str(値A) != str(値B) という素朴な比較をしていたため、
str() の結果 |
|
|---|---|
| SQLite側 | b'\xd8\x10\x0f/ow...' |
| PostgreSQL側 | <memory at 0x7fc980fb5a00> |
となり、バイナリの中身が完全に同一でも必ず不一致になるという状態でした。移行データ自体は正常でした。
比較の前に memoryview を bytes へ正規化するように修正します。
def normalize_value(value):
"""比較用に値を正規化する。
psycopg2はbytea列をmemoryviewで返すため、bytesへ揃えてから比較する。
"""
if isinstance(value, memoryview):
return bytes(value)
return value
# 比較箇所
mismatches = {
k: (s_row[k], p_row[k])
for k in common_keys
if str(normalize_value(s_row[k])) != str(normalize_value(p_row[k]))
}
修正後、比較した項目はすべてOKになりました。

この突合で確認できた範囲
ここは正確に書いておきたいところです。今回のスクリプトが比較しているのは、
- 全テーブルの行数
- 指定した数値列の合計
- 各テーブルの先頭5行(スクリプト内の
SAMPLE_ROWS = 5)の値
の3点です。したがって確認できたのは、厳密には次の範囲になります。
- 行数は全件一致(24,928行)
- 指定した金額列の集計値は許容差内(差分
0.0) - 各テーブルの先頭5行について、比較対象列が一致
- その先頭5行に含まれるBLOB・日本語テキスト・NULLが一致
つまり、「全レコード・全列が完全一致した」ことまでは証明できていません。行数と合計値が一致していても、たとえばこういう取り違えは検出できません。
移行元: 100, 200
移行先: 150, 150
この検証範囲では差分は検出されませんでした、というのが正確な言い方になります。本番移行では、主キー順の全件比較や、型ごとに正規化したレコードハッシュの比較を行うことを推奨します。
おまけ: 実レコードも目視で確認
気になったので、実レコードも見てみました。(特に意味はないです。)

Step 5. 移行後にやるべきこと: NOT NULL制約の貼り直し
Step 4-2 で判明した通り、今回の環境では NOT NULL 制約が落ちているので貼り直します。
ALTER TABLE vendors
ALTER COLUMN name SET NOT NULL,
ALTER COLUMN created_at SET NOT NULL;
ALTER TABLE estimates
ALTER COLUMN vendor_id SET NOT NULL,
ALTER COLUMN estimate_no SET NOT NULL,
ALTER COLUMN estimate_date SET NOT NULL,
ALTER COLUMN total_amount SET NOT NULL,
ALTER COLUMN is_reviewed SET NOT NULL,
ALTER COLUMN created_at_epoch SET NOT NULL;
ALTER TABLE estimate_items
ALTER COLUMN estimate_id SET NOT NULL,
ALTER COLUMN item_name SET NOT NULL,
ALTER COLUMN quantity SET NOT NULL,
ALTER COLUMN unit_price SET NOT NULL,
ALTER COLUMN amount SET NOT NULL;
ALTER TABLE expense_categories
ALTER COLUMN code SET NOT NULL,
ALTER COLUMN name SET NOT NULL;
ALTER TABLE expense_entries
ALTER COLUMN estimate_id SET NOT NULL,
ALTER COLUMN category_id SET NOT NULL,
ALTER COLUMN amount SET NOT NULL,
ALTER COLUMN is_posted SET NOT NULL;

5テーブル・19列すべて成功しました。
Step 6. 後片付け
検証が終わったらスタックを削除します。RDSは起動しているだけで課金されるので、忘れないうちに実行しましょう。
ここで1つ注意点があります。テンプレートのS3バケットには DeletionPolicy: Delete を設定していますが、CloudFormationはオブジェクトが入っているS3バケットを自動的に空にはしてくれません。Step 2 で3ファイルをアップロードしているので、そのまま削除するとバケットの削除に失敗し、スタックが DELETE_FAILED になってしまいます。
先にバケットを空にしてから削除します。
aws s3 rm "s3://$BUCKET" --recursive
aws cloudformation delete-stack \
--stack-name sqlite-pg-verify
aws cloudformation wait stack-delete-complete \
--stack-name sqlite-pg-verify
本番移行に向けての注意点
検証環境だからこそ許容した部分がいくつかあるので、本番移行を見据えた注意点としてまとめておきます。
SSLは証明書検証まで行う
今回は使い捨ての検証環境だったため sslmode=prefer(証明書検証なし)で進めましたが、本番データの移行では証明書検証を行うことを推奨します。
prefer は通信こそ暗号化されるものの、接続先が本当に意図したRDSインスタンスであることを検証しません。本番移行では、RDSのCA証明書バンドルをダウンロードした上で、verify-full など証明書検証が有効になる構成で実行するのが望ましいです。
include drop を既存DBへ向けない
include drop は対象テーブルを CASCADE で削除するため、そのテーブルを参照している移行対象外のオブジェクトまで巻き込む可能性があります。本番の既存データベースへそのまま実行しないでください。専用の空DBまたは専用スキーマへロードするか、事前に作成したテーブルへ create no tables でロードする方が安全です。
Dockerイメージはdigestで固定する
latest は将来内容が変わり得ます。検証したイメージのdigestを控えて固定し、本番移行では検証時と同じイメージを使うようにしてください。
突合は「全件」で行う
Step 4-5 の通り、今回の突合は行数・集計・先頭5行の範囲です。本番では主キー順の全件比較や、正規化したレコードハッシュの突合まで行うことを推奨します。
パスワードを平文で残さない
今回は使い捨ての検証環境なので migrate.load へ直接パスワードを書きましたが、この方法だと平文がファイルに残りますし、ファイルの権限次第では他ユーザーからも読めてしまいます。DSNをコマンドライン引数として渡す場合は、シェル履歴やプロセス一覧にも出てしまいます。本番相当の環境では、pgloaderがサポートしている PGPASSFILE / .pgpass などを使って、平文を残さない渡し方を検討してください。
パラメータは環境に合わせる
work_mem / maintenance_work_mem は、インスタンスのメモリ量と並列度に応じて調整してください。この記事の値をそのまま持ち込まないようご注意ください。
まとめ
今回の環境では、サンプルデータをエラーなく移行できた
今回利用したpgloader 3.6.7~develとサンプルSQLite DBの組み合わせでは、24,928行をロードエラー0件でAmazon RDS for PostgreSQLへ移行できました。
ただしpgloaderのエラー0件は、「pgloaderがロードエラーを報告しなかった」ということであって、型の意味が同じであることも、全件・全列が同じであることも、アプリケーションのSQLが互換であることも保証しません。
本番移行では、移行先DDLを明示的に設計した上で、全件突合・制約検証・アプリケーションの回帰試験まで実施する必要があります。
この記事が同じような移行を検討されている方の参考になれば幸いです!
以上、みゃんでした!
参考
- pgloader Documentation
- Migrating a SQLite database to PostgreSQL — pgloader documentation
- PostgreSQL: Documentation: 16: 34.19. SSL Support
- PostgreSQL: Documentation: 16: 5.6. Modifying Tables
- SQLite Foreign Key Support
- SQLite Autoincrement
- SSL/TLS を使用して DB インスタンスへの接続を暗号化する - Amazon RDS
- PostgreSQL DB インスタンスでの SSL の使用 - Amazon RDS
- CloudFormation のトラブルシューティング - AWS CloudFormation
- AWS Systems Manager Session Manager







