Session Managerで縛りプレイを攻略

どうも岡_山です。

この記事は[Japan AWS Jr. Champions Advent Calendar 2024](https://qiita.com/advent-calendar/2024/japan-aws-jr-champions)の22日目の記事です。

今回の記事はSeason Manager(SSM)を駆使して様々な制約を回避してみようという、SSMでこんな事もできるよというネタ記事です(ベストプラクティスの紹介ではありません)。  
このネタはローカルPCからElastic Kubernetes Service(EKS)クラスタのPodのログを取得しようと試行錯誤する中で生まれました。  
「Podからのログ取得」&rarr;「取得したログをファイルへ書き込み」&rarr; 「書き込んだファイルをローカルPCへダウンロード」という手順の中で、制約を回避して行おうとしたのは特に以下の2つです。  

1. 実行権限を持つユーザーが限られているコマンドを実行する
2. S3バケットをを経由せずにEC2インスタンスからファイルを取得する

今回はEKS周りはかなり簡略化した代替物を用いて紹介します。  

## SSMによるEC2インスタンス上でのコマンド実行

Session Manager(SSM)はAWS Systems Mangerの機能の一部であり、SSHに依らずEC2インスタンスのシェルコマンドが実行できるものです。  
AWS側のサービスであるためインバウンドアクセスを全て禁止した状態でも利用できるため非常にセキュアで、SSHを用いる場合のように踏み台サーバを用意する必要がありません。
EKSクラスタの操作コマンドツールである`kubectl`も、実行用のEC2インスタンスにSSMにより指令することで実行可能です。  

まずはこのSSMをpythonのboto3により用いてローカルPCからEC2インスタンスのシェルコマンドを実行してみます。  

SSMによりシェルコマンドを実行するには、SSMクライアントのsend_commandメソッドを用います。  
実行コマンドは以下のようにsend_commandの引数Parametersに指定します。  
コマンドの実行結果を後に取得するため、send_commandの返り値からコマンドの実行IDを記録します。  

```python
session = boto3.Session(
    aws_access_key_id=<アクセスキーID>,
    aws_secret_access_key=<シークレットアクセスキー>,
    aws_session_token=<セッショントークン>,
    region_name="ap-northeast-3" # リージョン名
)

ssm_client = session.client("ssm")

res = ssm_client.send_command(
    InstanceIds=["<EC2インスタンスID>"],
    DocumentName="AWS-RunShellScript",
    Parameters={"commands": ["whoami"]}
)

command_id = res["Command"]["CommandId"]
```

続いてコマンドの完了を待つためsleepを挟んだ後、list_command_invocationsメソッドにより記録したコマンドIDを指定して実行結果を取得します。  

```python
time.sleep(5)

list_inv = ssm_client.list_command_invocations(
    CommandId = command_id,
    Details = True
)

print(list_inv["CommandInvocations"][0]["CommandPlugins"][0]["Output"])
```

上記のようにコマンドの実行結果であるEC2インスタンスの標準出力をprintすると、以下のように実行させた`whoami`コマンドの結果が得られます。


```
root
```

## 実行権限を持つユーザーが限られているコマンドの実行

`kubectl`コマンドによるEKSクラスタの操作は、クラスタへのアクセス権限を設定したユーザーしか行うことができません。  
その状況を簡単に再現するため、次のような特定のユーザー(ec2-user)が実行したときはダミーのログデータを出力し、それ以外のユーザーでは権限無しと表示するシェルスクリプト(echo_dummy_data.sh)を作成してみました。  
このシェルスクリプトをEC2インスタンスに置き、これを実行することを`kubectl`を用いたログ取得コマンド実行の代わりとします。  

```bash:echo_dummy_data.sh
#!/bin/bash

user=$(whoami)

echo $user

if [[ $user == "ec2-user"  ]]; then
    for i in $(seq -w 1 200)
    do
        # ec2-userであればダミーのログデータを出力
        echo $i Total time 0$(bc <<< "scale=5; $RANDOM/32767")
    done
else
    echo "No Permission."
fi
```
(数値の0埋め[^1]と乱数の生成は[^2]は記事末尾に記載したサイトを参考にさせていただいています)

このシェルスクリプトをec2-user(EKSクラスタへのアクセス権限を有するユーザーと仮定)が実行すると、実行ユーザーと200行のダミーログデータが出力されます。  

```
ec2-user
001 Total time 0.94064
002 Total time 0.91253
003 Total time 0.85030
004 Total time 0.55214
005 Total time 0.07245
006 Total time 0.98565
...
(略)
...
198 Total time 0.16367
199 Total time 0.43671
200 Total time 0.84218
```

ではSSMを用いてシェルスクリプトの実行を試してみます。

```python
res = ssm_client.send_command(
    InstanceIds=["<EC2インスタンスID>"],
    DocumentName="AWS-RunShellScript",
    Parameters={"commands": ["bash /home/ec2-user/echo_dummy_data.sh"]}
)
```

こちらの結果としては……
```
root
No Permission.
```
となり、ダミーログデータを取得することができません。  
[先の節](#ssmによるec2インスタンス上でのコマンド実行)で`whoami`を実行した結果から分かるように、boto3によってSSMを実行すると、ユーザーはec2-userではなくrootユーザーとなっているためです。  

ユーザーが異なるのであればスイッチユーザー`su`使えば実行可能なのではと考えますが、

```python
res = ssm_client.send_command(
    InstanceIds=["<EC2インスタンスID>"],
    DocumentName="AWS-RunShellScript",
    Parameters={"commands": ["su - ec2-user && whoami && bash /home/ec2-user/echo_dummy_data.sh"]}
)
```

結果としては以下の通りで、rootが実行ユーザーであることを変えられません。

```
root
root
No Permission.
```

ではどうするかと言いますと、ヒアドキュメントを用います。
次のようなヒアドキュメントを用いたシェルスクリプトget_log.shを読み込み、SSMによって実行すると、

```bash:get_log.sh
#!/bin/sh

su - ec2-user <<EOF
bash /home/ec2-user/echo_dummy_data.sh
EOF
```

```python
with open("get_log.sh", mode="r", encoding="utf-8") as s:
    get_log_script = s.read()

res = ssm_client.send_command(
    InstanceIds=["<EC2インスタンスID>"],
    DocumentName="AWS-RunShellScript",
    Parameters={"commands": [get_log_script]}
)
```

ec2-userによる実行扱いとなり、ダミーログデータを取得することができます。

```
Last login: Sun Dec 22 14:40:33 UTC 2024
ec2-user
001 Total time 0.56370
002 Total time 0.52507
003 Total time 0.32306
004 Total time 0.58571
...
(略)
...
103 Total time 0.19403
104 Total time 0.13013
105 Total time 0.86211
106 Total ti
---Output truncated---
```

ログデータを取得できたのでこれにてめでたしめでたし……とは残念ながらなりません。  
print出力の中に「Output truncated」とあるように、出力が省略されていて全て取得できていません。  
boto3のSSMクライアントを用いる場合、取得できる標準出力の行数に制限があるため、今回用意した200行のダミーログデータ全てを取得することができません。  
そこでダミーログデータを一旦ファイルに書き込み、そのファイルをダウンロードすることを考えます。  

```bash
#!/bin/sh

su - ec2-user <<EOF
bash /home/ec2-user/echo_dummy_data.sh > /home/ec2-user/dummy.log
EOF
```

## S3バケットをを経由せずにEC2インスタンスからファイルを取得

EC2インスタンスからローカルPCへファイルを転送する場合、S3バケットを経由させる方法が順当に考えられます。  
しかしS3バケットの数にはアカウントごとに限りがあるため(AWSへ上限緩和申請をすることで増やすことはできますが)、企業の場合は社内ルールとして新規バケットの申請が必要だったり、既存バケットに関しては使用許可が必要であったりします。  
しかしそれらの手続きはどれも面倒なので、SSMを使ってファイルをダウンロードしてみます。  

SSMはSSH接続のトンネルとして利用することができ、SSH設定ファイル~/.ssh/configに以下のような設定をすることで[^3]、SSHコマンド`ssh -i <キーペアファイルパス> ec2-user@<EC"インスタンスID>`を利用してEC2インスタンスに接続することができます。  

```
# SSH over Session Manager
host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
```

SSHが使えるということはSCPも使える……ということで、pythonのparamikoライブラリを用いてSCPファイル転送[^4]によりダミーログデータを書き込んだファイルをローカルPCへ転送します。  

```python
ec2_id = <EC2インスタンスID>
port_no = 22

p_cmd = paramiko.ProxyCommand(f"aws ssm start-session --target {ec2_id} --document-name AWS-StartSSHSession --parameters 'portNumber={port_no}'")

with paramiko.SSHClient() as sshc:
    sshc.load_system_host_keys()
    sshc.connect(
        hostname=ec2_id,
        port=port_no,
        username="ec2-user",
        key_filename=<キーペアファイルパス>,
        sock=p_cmd,
        banner_timeout=120
    )

    with scp.SCPClient(sshc.get_transport()) as scpc:
        scpc.get(remote_path="/home/ec2-user/dummy.log", local_path="./dummy.log")
```

これによってダミーログデータ全体が記録されたファイルを取得することができます。

以上によりSSMだけで実行可能ユーザーが制限されたコマンドを実行し、結果を書き込んだファイルを取得することができました。  
最後に今回利用したpythonソースコードの全文を載せます。

```python
import os
import time

import boto3
import paramiko
import scp

def main():

    with open("get_log.sh", mode="r", encoding="utf-8") as s:
        get_log_script = s.read()

    ec2_id = "<EC2インスタンスID>"
    port_no = 22

    session = boto3.Session(
        aws_access_key_id=<アクセスキーID>,
        aws_secret_access_key=<シークレットアクセスキー>,
        aws_session_token=<セッショントークン>,
        region_name="ap-northeast-3" # リージョン名
    )

    ssm_client = session.client("ssm")

    res = ssm_client.send_command(
        InstanceIds=[ec2_id],
        DocumentName="AWS-RunShellScript",
        Parameters={"commands": [get_log_script]}
    )

    command_id = res["Command"]["CommandId"]

    time.sleep(5)

    list_inv = ssm_client.list_command_invocations(
        CommandId = command_id,
        Details = True
    )
    
    print(list_inv["CommandInvocations"][0]["CommandPlugins"][0]["Output"])

    p_cmd = paramiko.ProxyCommand(f"aws ssm start-session --target {ec2_id} --document-name AWS-StartSSHSession --parameters 'portNumber={port_no}'")

    with paramiko.SSHClient() as sshc:
        sshc.load_system_host_keys()
        sshc.connect(
            hostname=ec2_id,
            port=port_no,
            username="ec2-user",
            key_filename=<キーペアファイルパス>,
            sock=p_cmd,
            banner_timeout=120
        )

        with scp.SCPClient(sshc.get_transport()) as scpc:
            scpc.get(remote_path="/home/ec2-user/dummy.log", local_path="./dummy.log")




if __name__ == "__main__":
    main()

```

---

**参考サイト**
[^1]: https://qiita.com/NT90957869/items/07e0708d0e57bd20baf0
[^2]: https://qiita.com/Ryo-0131/items/dc766dc2baac6f617945
[^3]: https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html
[^4]: https://qiita.com/persimmon/items/c9e6c0d6dda72b6752e9