AWS (Amazon Web Services) には数多くのサービスがある1。 Systems Manager もそのうちの 1 つであり、Session Manager は Systems Manager の機能の 1 つだ。
Session Manager は、EC2 インスタンスへのログインやポートフォワーディングといった機能を提供する。 これらを行うために現在広く使われている SSH と比べて、以下の利点がある。
では、Session Manager は SSH の代替ツール4として十分に使えるだろうか? 答えは概ね肯定である。 もちろん、いくつかの問題点を挙げることはできる。 本稿では、そのうち極めて特殊でマイナーな(そして解決可能な)問題を取り上げる5。
問題
あなたは AWS 上にインフラストラクチャをもつサービスのオペレーションチームの一員だ。
サービスは多様な役割を持つ複数のインスタンスで構成され、インスタンスにログインする際に ssh
コマンドを手打ちするのは非効率的である6。
オペレーションの効率化に熱心なあなたのチームは、Python7 で書かれた一連のスクリプト8を用意している。
Python コードは、 ssh
を実行する以下のような部分を含む9。
import subprocess
subprocess.run(f"ssh {user}@{host} -i {key_file}", shell=True)
これを実行する10と、正常にインスタンスにログインすることができる11。
Last login: Fri Dec 10 00:00:00 2077 from localhost
__| __|_ )
_| ( / Amazon Linux 2 AMI
___|\___|___|
https://aws.amazon.com/amazon-linux-2/
[ec2-user@i-ede5f3e66ff6f3e0 ~]$
Control+C
(^C
) などの制御文字を入力しても問題ない。
[ec2-user@i-ede5f3e66ff6f3e0 ~]$ ^C
[ec2-user@i-ede5f3e66ff6f3e0 ~]$
あなたのタスクは、ssh
を使うことをやめ、Session Manager でインスタンスにログインできるようにすることだ。
そうすることで、先に述べたオペレーション、セキュリティ上の恩恵を得られる。
あなたはチームメンバーの IAM ユーザに適切なポリシーを付与したあと、オペレーションスクリプトを以下のように変更した。
import subprocess
subprocess.run(f"aws ssm start-session --profile {aws_profile} --target {instance_id}", shell=True)
極めて素直な変更である。これを実行し、正常にログインできることを確認した。
Starting session with SessionId: me-ede5f3e66ff6f3e0
sh-4.2$
しかし、^C
を入力するとセッションが終了してしまった。
sh-4.2$ ^C
sh-4.2$ Traceback (most recent call last):
File "./xyzzy.py", line X, in <module>
subprocess.run("aws ssm start-session --profile op --target i-ede5f3e66ff6f3e0", shell=True)
...
KeyboardInterrupt
ssh
を aws ssm start-session
に変更しただけなのに、なぜだろうか?
原因
まず、^C
が入力されたときに何が起きるのか、細部を省略して簡単に述べる。
- カーネルの TTY ドライバが
^C
を受け取る - カーネルはシグナル
SIGINT
を foreground process group に送信する
つまり、^C
が入力されると、終了シグナルである12 SIGINT
がターミナルで現在実行中のプロセスおよびその子プロセス全体に送信される13。
aws ssm start-session
コマンド自体はその子プロセスも含め SIGINT
を無視(あるいは適切に処理)するように設定されている。
従って、その親プロセスである python スクリプトの実行プロセスが SIGINT
を受けて終了したのである。
しかし、その理屈では ssh
を使っていた場合にも同様に終了するように思える。なぜ ssh
の場合は問題なかったのだろうか?
それは、ssh
が現在のターミナルにおいて SIGINT
の送信を行わないように TTY 設定を行っているからである。
TTY 設定は、stty
コマンドで確認できる14。
ssh
を使うバージョンのスクリプトを実行中に、別のターミナルで TTY 設定を見てみよう。
まずは、対象プロセスの TTY デバイスを調べる。
bash-4.2# ps a -o tty,pid,pgid,stat,cmd --forest
TT PID PGID STAT CMD
...
pts/0 8 8 Ss bash
pts/0 15 15 S+ \_ python3 ./xyzzy.py
pts/0 16 15 S+ \_ ssh ec2-user@X.X.X.X -i /home/me/.ssh/key.pem
TT
の列をみると、対象プロセスは pts/0
に接続していることが分かる。
stty
で対象デバイスを指定して設定を表示する。
bash-4.2# stty -F /dev/pts/0
speed 38400 baud; line = 0;
min = 1; time = 0;
-brkint -icrnl -imaxbel
-opost
-isig -icanon -iexten -echo -echoe -echok
表示内容を理解するには、stty の man ページを読むと良い。
我々にとって重要なのは -isig
である。
[-]isig
enable interrupt, quit, and suspend special characters
「-
」 は対象の設定を無効にすることを意味するから、-isig
は interrupt、quit、および suspend 制御文字を無効にする。
ssh
がこの設定を行うため、^C
入力時にそもそも SIGINT
が送信されておらず、親プロセスの終了を免れていたのである。
念の為、aws ssm start-session
を使ったバージョンでの TTY 設定も確認しておこう。
bash-4.2# stty -F /dev/pts/0
speed 38400 baud; line = 0;
min = 1; time = 0;
-brkint -imaxbel
-icanon -echo
予想通り、-isig
は存在しない15。
解決
問題の原因が明らかになった。大変幸運なことに、この問題の解決は容易である。TTY 設定を変更するだけだ。 あなたは下記のようにスクリプトを書き換えた16。
import subprocess
import sys
import termios
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
new = termios.tcgetattr(fd)
new[3] &= ~termios.ISIG
try:
termios.tcsetattr(fd, termios.TCSADRAIN, new)
subprocess.run(f"aws ssm start-session --profile {aws_profile} --target {instance_id}", shell=True)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
TTY 設定を確認:
bash-4.2# stty -F /dev/pts/0
speed 38400 baud; line = 0;
min = 1; time = 0;
-brkint -imaxbel
-isig -icanon -echo
^C
を入力:
sh-4.2$ ^C
sh-4.2$
問題は解決した。今夜は良い夢を見れそうだ。
おわりに
本稿で取り上げた問題を Session Manager 自体の問題とするのは些か公平ではない。
aws ssm start-session
をそのまま使えば、^C
を入力してもセッションが終了することはないからだ。
本稿で述べた限られた場合においてのみ、ssh
を aws ssm start-session
で素直に置き換えたとき問題が生じるということに過ぎない。
ただ、Session Manager にはそれ以外にもユーザビリティ上の問題がいくつか存在する。
これらの問題も回避可能だが、手間がかかる。
本稿では TTY 設定の問題を扱った。この知識は Session Manager と関係ない場面で同種の問題が起きた際にも参考になるだろう。
ボーナスステージ
実は、TTY 設定を行わずより簡単に問題を回避する方法がある。subprocess.run()
の代わりに os.system()
を使えば良い。
import os
os.system(f"aws ssm start-session --profile {aws_profile} --target {instance_id}")
os.system()
は 標準 C ライブラリの system()
関数を呼び出している。
マニュアルに書かれている通り、system()
関数を呼び出しているプロセスの SIGINT
は無視される。
python
を使わなくて良いなら、例えば bash
スクリプト19でも問題は起きない。
#!/usr/bin/env bash
aws ssm start-session --profile $AWS_PROFILE --target $INSTANCE_ID
これは、bash
が子コマンドが終了するまで SIGINT
をブロックするためである20。
-
Cloud Products を参照。本稿執筆時点で 239 個の製品が存在する。 ↩︎
-
Session Manager を用いて SSH 接続する場合は、鍵の管理が必要となる。 ↩︎
-
これは、アクセス制御が IAM によって完全に行えることを意味し、MFA の利用を強制したりできる。このことは Session Manager を使って SSH 接続する場合も適用される。 ↩︎
-
本稿で扱うのは、Session Manager と SSH プロトコルとの比較ではなく、
aws ssm start-session
とssh
コマンドとの比較である。先の注2で述べた通り、ssh
コマンドを使って Session Manager を介した接続を行うこともできるため、両者を補完的に使うことも可能である。 ↩︎ -
本稿で述べる問題は、
session-manager-plugin 1.2.54.0
、aws-cli/2.1.25
を想定している。ただし、aws
コマンドについては1.x
系でも変わらない。 ↩︎ -
そもそもインスタンスにログインして直接作業すること自体を避けたいところだが、そうしたくなるケースはある。 ↩︎
-
ここでは、本稿執筆時点での最新版である Python 3.9.1 を想定している。 ↩︎
-
例えば、インスタンスに付けられたタグからログインユーザ名や IP、鍵のパスを導出する一連のステップを自動化するといったもの。 ↩︎
-
本稿に記すコードは全て例示のために書かれたものであり、考えなしに本番環境で使って良いものではない。例えば、
subprocess.run()
においてshell=True
を指定するのは、外部入力を受け付ける場合に shell injection を可能にしてしまう。 ↩︎ -
スクリプトを実行するホスト環境は Ubuntu を想定しているが、他の GNU/Linux ディストリビューションや macOS でも変わらないはずである。 ↩︎
-
言うまでもないことだが、
user
やhost
、key_file
といった変数は適切に定義されているものとする。以降のコードでも同様である。また、出力は例示のために書かれたものであり、実際の出力ではない。 ↩︎ -
デフォルトの振る舞いが終了であるという意味。 ↩︎
-
The TTY demystified が詳しい。 ↩︎
-
stty
コマンドで TTY 設定を変更することもできる。 ↩︎ -
デフォルトの TTY 設定は cooked モードになっている。stty の man ページに書かれている通り、cooked モードでは
isig
が有効になっている。 ↩︎ -
termios — POSIX style tty control — Python 3.9.1 documentation を参照。 ↩︎
-
実際、AWS 公式ブログではカスタムドキュメントを指定することでセッション開始時にデフォルト以外のシェルを起動する方法を解説している。これを使えば好きなシェルを起動してホームディレクトリに移動することは可能である。 ↩︎
-
例えば、bridge network を利用している Docker コンテナ内でポートフォワーディングを行い、ホストからそれに接続したい場合にはバインドアドレスを 127.0.0.1 から変更したくなる。リモートホストについては、RDS に bastion ホストを経由してトンネリングしたいときに指定したくなる。これについては、Session Manager が RDS インスタンスへのフォワーディングをサポートしてほしいところだ。いずれの問題も
socat
などを使って追加のフォワーディングを行うことで解決できる。また、Session Manager のポートフォワーディングは、最近まで複数の同時接続をサポートしていなかった。その問題があったときは、Web UI にフォワーディングしても使い物にならなかった。 ↩︎ -
bash
でなくても、よほど古くないシェルなら通用するはずだ。 ↩︎ -
Proper handling of SIGINT/SIGQUIT が詳しい。
bash
はここで述べられている WCE (Wait and Cooperative Exit) を実装している。 ↩︎