はじめに #
private subnet にある RDS PostgreSQL に、ローカルから dump.sql を流したい場面がありました。
踏み台候補を比較した結論として ECS Task を採用しました。
- EC2: 停止忘れの課金と AMI パッチ管理が地味に痛い
- CloudShell VPC environment: クォータ最大 2/region と社内ネットワーク調整が釣り合わない
- ECS Task: タスク終了で勝手に止まる。コンテナイメージなので OS 管理も不要
そこで postgres:17-alpine を sleep infinity で立てて、ECS Exec で psql を起動して stdin に dump.sql を流せばよいと素直に考えました。これが落とし穴です。
最終的に踏み台は ECS Task のまま、stdin pipe は捨てて SSM Port Forwarding に置き換えました。本記事はその顛末と、なぜそれが筋がよいかの話です。
結論:
- ❌ ECS Exec の stdin pipe は信頼できません(session 中断かハング)
- ✅ SSM Port Forwarding で TCP tunnel を張って、ローカルの psql に
-f dump.sqlで渡すだけで完走します
ECS Exec に dump を流して撃沈する #
最初に試したコマンドはこれです。
aws ecs execute-command \
--cluster <cluster-name> --task <task-arn> --container <container> \
--interactive \
--command "sh -c 'PGPASSWORD=... psql -h <rds-endpoint> -U postgres <db-name>'" \
< dump.sql
なお PGPASSWORD 直書きは検証用です。実運用では Secrets Manager / SSM Parameter Store 経由が望ましいです。
期待した動きは「dump.sql が psql に流れて CREATE TABLE / COPY が走る」。
現実はこうでした。
Starting session with SessionId: ecs-execute-command-...
psql (17.9)
Type "help" for help.
<db-name>=# Cannot perform start session: EOF
psql のバナーが出た瞬間に EOF で session 終了です。CREATE TABLE 一個も走りません。
メッセージ文言は「Cannot perform start session」と書いていますが、Starting session with SessionId と psql バナーが出ている時点で session 開始自体は成功していることが分かります。落ちているのはその後の入力処理段階です。
なぜ EOF で落ちるのか #
これは sfujiwara 記事 でも語られている session-manager-plugin の挙動です。
mainline ソースを辿ると、ローカル stdin が EOF に達した瞬間に handleKeyboardInput() がエラーを上げて、ValidateInputAndStartSession() が Cannot perform start session: %v を出力して session を終了します(shellsession_unix.go と session.go の連携)。
ローカルで < dump.sql や ... | aws ecs ... を使えば、fd は file または pipe で、読み切ったら EOF を返します。plugin にとってローカル stdin の EOF は「session 続行不能」のシグナル、というデザインです。
ところが remote コマンド次第で見え方が変わります。
| remote 起動コマンド | 結果 |
|---|---|
cat(stdin を読み続けるだけ) |
ハングします(Ctrl-C で抜けるしかない) |
sh -c 'psql ...'(対話 shell として居座る) |
Cannot perform start session: EOF |
両方ともローカル stdin は EOF してるはずなのに結果が違うのは、plugin に session を終わらせる経路が複数あって、どちらが先勝ちかで見え方が変わるためです(remote プロセスが先に終了すれば os.Exit(0) 経路で抜けてエラー表示すら出ません)。psql のように対話で居座るプロセスでは「ローカル EOF → エラー表示」が先勝ち、cat のように EOF を待つだけのプロセスでは少なくとも私の観察では remote 側の stdin EOF として認識されず、cat が読み続けたまま session も閉じない状態になりました。
要は ECS Exec の stdin pipe は信頼して使えない。エラーが目に見える形で出るのは psql 系の対話プログラムを起動したときだけ、という具合です。
unbuffer や expect で頑張ればやれます。ただし overengineering #
sfujiwara 記事は unbuffer で PTY を被せて EOF 問題を回避するアプローチを示しています。PTY なら明示的に閉じるまで EOF にならないので、plugin の error 経路に乗らない、という理屈です。
unbuffer 単独はうまくいかない #
unbuffer -p aws ecs execute-command ... --command "sh -c 'psql ...'" < dump.sql
私の環境ではこれは即死でした。unbuffer -p が file の中身を一気に PTY に流したあと自分も exit するので、session 確立より前にデータが消えるレース、というのが原因と推測しています。
expect で psql の prompt と同期させれば動きます #
expect で psql の prompt を待ってから次の行を送るスクリプトなら、ちゃんとデータが届きます。
expect -f - <<'EOF'
spawn aws ecs execute-command --cluster <cluster> --task <task-arn> --container <container> \
--interactive \
--command {sh -c 'PGPASSWORD=... psql -h 127.0.0.1 -U postgres mydb'}
expect "mydb=#"
send -- "DROP TABLE IF EXISTS t;\r"
expect "mydb=#"
send -- "CREATE TABLE t (id int, name text);\r"
expect "mydb=#"
send -- "COPY t (id, name) FROM stdin;\r"
expect ">> "
send -- "1\tfoo\r"
send -- "2\tbar\r"
send -- "3\tbaz\r"
send -- "\\.\r"
expect "mydb=#"
send -- "SELECT * FROM t;\r"
expect "mydb=#"
send -- "\\q\r"
expect eof
EOF
実機で COPY も含めて綺麗に通り、3 行 insert に成功します。
mydb=# SELECT * FROM t;
id | name
----+------
1 | foo
2 | bar
3 | baz
(3 rows)
ただし、これを毎回 dump 投入のたびに書くのは普通に重いです。
- 通常 / 継続行 / transaction の 3 種類のプロンプトを正規表現で識別する
- pg_dump が出す複数行 SQL や COPY ブロックを状態遷移込みで扱う
- エラー検知 / バッファ溢れ / 特殊文字エスケープなどの運用面の作り込み
ECS Exec を踏み台にしたいだけなのに、対話 shell との同期 protocol を自前実装することになります。データ転送のためにこの仕組みを保守したくない、というのが本記事の判断です。
SSM Port Forwarding に切り替える #
「ECS Exec で remote shell に喋る」のをやめて、「ローカルの psql から RDS に直接届く TCP tunnel」を作ります。
aws ssm start-session \
--target ecs:<cluster-name>_<task-id>_<runtime-id> \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["<rds-endpoint>"],"portNumber":["5432"],"localPortNumber":["15432"]}'
<task-id> は task ARN の末尾、<runtime-id> は aws ecs describe-tasks ... --query "tasks[0].containers[?name=='<container>'].runtimeId | [0]" で取れます(複数コンテナ task でも安全な書き方)。
このコマンドが foreground で生きている間、127.0.0.1:15432 に来た TCP 接続が ECS Task 経由で <rds-endpoint>:5432 に forward されます。
別ターミナルで psql を実行します。
psql -h 127.0.0.1 -p 15432 -U postgres -d <db-name> -f /path/to/dump.sql
これで完走します。psql はローカルで動いて pgwire で RDS と直接喋る。間にいる SSM はバイトを中継しているだけです。
なぜ問題が消えるのか #
経路の構造が違います。
| 経路 | 抽象化 | 中身 |
|---|---|---|
| ECS Exec stdin pipe | remote 対話 shell に喋る | PTY 経由 / 同期必要 / 対話モード前提 |
| SSM Port Forwarding | TCP の中継 | port → port のバイト中継、それだけ |
ECS Exec stdin pipe で苦しんだ要素は、Port Forwarding では全部消えます。
| 要素 | ECS Exec stdin pipe | Port Forwarding |
|---|---|---|
| psql プロンプトの同期 | 必要(expect で書く) | 不要(psql が pgwire で処理) |
| session 確立タイミング | EOF と競合 | 無関係 |
| 対話モードの罠 | あり | なし |
最後に #
ECS Exec は対話のための機能で、データ転送には向きません。
stdin pipe で渡すと session ごと EOF で吹き飛ぶか、ハングするか、どちらかです。expect で頑張れば動かせますが、それは「ECS Exec を経由する」という前提が間違っていて、その間違いを補正するために大きなコードを書いている状態です。
SSM Port Forwarding は「ECS Task を TCP tunnel の起点として使う」というシンプルな抽象で、ローカルの素の psql で -f dump.sql だけで完結します。SSM 有効な ECS Task は aws ssm start-session --target ecs:... でそのまま target として扱えるので、踏み台を別に立てる必要もありません。
「ECS Exec で対話する」と「ECS Task の private subnet 越しに TCP データを流す」は別問題で、後者は Port Forwarding が筋。これが今回の学びです。ECS Exec 自体は対話 shell やデバッグには便利な機能なので、用途に応じて使い分けるのが正解です。
参考 #
- AWS docs: Amazon ECS Exec for debugging
- AWS docs: Start a session - port forwarding
- unbufferでAmazon ECS Execを端末以外から実行する - 酒日記 はてな支店
- aws/session-manager-plugin shellsession_unix.go