AWS Session Manager は SSH の夢を見るか?

top image

AWS (Amazon Web Services) には数多くのサービスがある1Systems Manager もそのうちの 1 つであり、Session Manager は Systems Manager の機能の 1 つだ。

Session Manager は、EC2 インスタンスへのログインやポートフォワーディングといった機能を提供する。 これらを行うために現在広く使われている SSH と比べて、以下の利点がある。

  • SSH 鍵を使わないため、鍵の管理を行う必要がない2
  • インスタンスの SSH ポートへのインバウンドアクセスを許可する必要がない3

では、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

sshaws ssm start-session に変更しただけなのに、なぜだろうか?

原因

まず、^C が入力されたときに何が起きるのか、細部を省略して簡単に述べる。

  1. カーネルの TTY ドライバが ^C を受け取る
  2. カーネルはシグナル SIGINT を foreground process group に送信する

つまり、^C が入力されると、終了シグナルである12 SIGINT がターミナルで現在実行中のプロセスおよびその子プロセス全体に送信される13aws ssm start-session コマンド自体はその子プロセスも含め SIGINT を無視(あるいは適切に処理)するように設定されている。 従って、その親プロセスである python スクリプトの実行プロセスが SIGINT を受けて終了したのである。

しかし、その理屈では ssh を使っていた場合にも同様に終了するように思える。なぜ ssh の場合は問題なかったのだろうか?

それは、ssh が現在のターミナルにおいて SIGINT の送信を行わないように TTY 設定を行っているからである。

TTY 設定は、stty コマンドで確認できる14ssh を使うバージョンのスクリプトを実行中に、別のターミナルで 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 を入力してもセッションが終了することはないからだ。 本稿で述べた限られた場合においてのみ、sshaws ssm start-session で素直に置き換えたとき問題が生じるということに過ぎない。

ただ、Session Manager にはそれ以外にもユーザビリティ上の問題がいくつか存在する。

  • ログインユーザを指定しても、そのユーザのホームディレクトリやシェルがリスペクトされない17
  • ポートフォワーディングにおいて、ローカルでのバインドアドレスやリモートホストを指定できない18

これらの問題も回避可能だが、手間がかかる。

本稿では 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


  1. Cloud Products を参照。本稿執筆時点で 239 個の製品が存在する。

  2. Session Manager を用いて SSH 接続する場合は、鍵の管理が必要となる。

  3. これは、アクセス制御が IAM によって完全に行えることを意味し、MFA の利用を強制したりできる。このことは Session Manager を使って SSH 接続する場合も適用される。

  4. 本稿で扱うのは、Session Manager と SSH プロトコルとの比較ではなく、aws ssm start-sessionssh コマンドとの比較である。先の注2で述べた通り、ssh コマンドを使って Session Manager を介した接続を行うこともできるため、両者を補完的に使うことも可能である。

  5. 本稿で述べる問題は、session-manager-plugin 1.2.54.0aws-cli/2.1.25 を想定している。ただし、aws コマンドについては 1.x 系でも変わらない。

  6. そもそもインスタンスにログインして直接作業すること自体を避けたいところだが、そうしたくなるケースはある。

  7. ここでは、本稿執筆時点での最新版である Python 3.9.1 を想定している。

  8. 例えば、インスタンスに付けられたタグからログインユーザ名や IP、鍵のパスを導出する一連のステップを自動化するといったもの。

  9. 本稿に記すコードは全て例示のために書かれたものであり、考えなしに本番環境で使って良いものではない。例えば、subprocess.run() において shell=True を指定するのは、外部入力を受け付ける場合に shell injection を可能にしてしまう。

  10. スクリプトを実行するホスト環境は Ubuntu を想定しているが、他の GNU/Linux ディストリビューションmacOS でも変わらないはずである。

  11. 言うまでもないことだが、userhostkey_file といった変数は適切に定義されているものとする。以降のコードでも同様である。また、出力は例示のために書かれたものであり、実際の出力ではない。

  12. デフォルトの振る舞いが終了であるという意味。

  13. The TTY demystified が詳しい。

  14. stty コマンドで TTY 設定を変更することもできる。

  15. デフォルトの TTY 設定は cooked モードになっている。stty の man ページに書かれている通り、cooked モードでは isig が有効になっている。

  16. termios — POSIX style tty control — Python 3.9.1 documentation を参照。

  17. 実際、AWS 公式ブログではカスタムドキュメントを指定することでセッション開始時にデフォルト以外のシェルを起動する方法を解説している。これを使えば好きなシェルを起動してホームディレクトリに移動することは可能である。

  18. 例えば、bridge network を利用している Docker コンテナ内でポートフォワーディングを行い、ホストからそれに接続したい場合にはバインドアドレスを 127.0.0.1 から変更したくなる。リモートホストについては、RDS に bastion ホストを経由してトンネリングしたいときに指定したくなる。これについては、Session Manager が RDS インスタンスへのフォワーディングをサポートしてほしいところだ。いずれの問題も socat などを使って追加のフォワーディングを行うことで解決できる。また、Session Manager のポートフォワーディングは、最近まで複数の同時接続をサポートしていなかった。その問題があったときは、Web UI にフォワーディングしても使い物にならなかった。

  19. bash でなくても、よほど古くないシェルなら通用するはずだ。

  20. Proper handling of SIGINT/SIGQUIT が詳しい。bash はここで述べられている WCE (Wait and Cooperative Exit) を実装している。