ここ1ヶ月半くらい、ちょっとした独自システムを創っていたのですが、そこで編み出したテクニックを紹介してみます。
要はECS Execを使い込んだよってだけなんですが、こういうオモチャを使ったオモチャを作らせたら一級品の自負、ある。
ECS Exec の概要
基本的なところは他に任せたいのでリンクだけ貼っておきます。- New – Amazon ECS Exec による AWS Fargate, Amazon EC2 上のコンテナへのアクセス | Amazon Web Services ブログ
- [アップデート] 実行中のコンテナに乗り込んでコマンドを実行できる「ECS Exec」が公開されました | DevelopersIO
- デバッグに Amazon ECS Exec を使用する – Amazon ECS
外からコマンドを実行できる旨味
ECSっていっても自分の中では Fargate の話になるのですが、コンテナでのコマンドの実行って、起動時の ENTRYPOINT に詰め込むとか、SSHを開けておいてログインして作業、が基本でした。そこに、ECS Exec がきたことで、例えば CLI から aws ecs execute-command を実行して任意のコマンドを実行したり、/bin/sh でシェルを取ったりして、いわゆるコンソール作業みたいなことができるようになったというわけです。
便利っちゃ便利ですが、個人的にはその用途ではあまり使わないと思っています。
開発・テスト環境なら、普通にSSH Serverを起動しておく方が効率的ですし、本番環境なら外部へのログ確保と作り直し対応がきっちりしていれば使うこともほぼない。コンテナ+SSHはアカンみたいな話もよく見るけど、私はちゃんと構成すれば本番コンテナにSSHあっても全然いいと思っている派なので、原因調査もそれでって感じ。
強いて言うなら、1タスクに複数コンテナを起動した時、DBやKVSに異常発生したときに診るのには役立ちそうです。多分、半分くらい動かないぶっ壊れ方してるだろうけど。
じゃあ何処に使うかっていうと、自動化の簡略化です。
必ず元イメージがあって、ちょっと仕込みたい何かがあると、ビルドして新イメージとして仕込んだり、ENTRYPOINTに上手いこと柔軟な処理をさせたり、ってなるわけで、それが少々の量だと無駄の大きい構造といえます。
なので、そこを Exec してしまえホトトギスというわけです。
簡素な準備に
ちょっと python3 イメージに用があったので、python3:3.7.10-slim (Debian 10) を元にした内容になりますが、タスク起動・/usr/bin/tail -f /dev/null で常駐後にこんな風にPythonコーディングすることでSSHでログインする準備ができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
root_password = "Root@Password" INSTALLS = [ 'set -x', 'apt update', 'apt -y -q install openssh-server less curl binutils jq', f'echo "{root_password}\n{root_password}" | passwd root', 'echo "PermitRootLogin yes" >> /etc/ssh/sshd_config', '/etc/init.d/ssh restart', 'pip3 --quiet install boto3', ] command = '; '.join(INSTALLS) shell_command = f"/bin/sh -c '{command}'" res = ecs.executeCommand(shell_command, cluster_name, task_arn) |
executeCommand のところは、オレオレの切り抜きになりますが、こんな感じ。タスク起動直後だとExec受付準備ができてなかったりするので、リトライを仕込んでます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
def executeCommand(self, command, cluster, task_arn, container=None, interactive=True, retry=10): ecs = self.getECS() params = { "cluster" : cluster, "task" : task_arn, "command" : command, "interactive": interactive, } if container: params["container"] = container sleep = 5 for i in range(retry): try: res = ecs.execute_command(**params) except Exception as e: if "Wait and try again" in str(e): logger.log(f"Not ready task = {task_arn} in {cluster}") logger.log(f"Try again ... wait {sleep} seconds ...") time.sleep(sleep) continue logger.log(f"Failed execute command. task = {task_arn} in {cluster}") logger.log(f"Error: {e}") return False break return res |
Security Group で tcp/22 を必要なソースに絞って開けておけば、ssh -p22 root@{ip_address} と指定したパスワードで入ることができます。
ログがいまいちなので調整する
コマンドは一行ずつ実行することもできるのですが、先程の例ではあえてワンライナーにまとめています。これには理由があり、create cluster 時に Exec のために指定する設定がこんな感じなのですが
1 2 3 4 5 6 7 8 |
configuration = { 'executeCommandConfiguration': { 'logging': 'OVERRIDE', 'logConfiguration': { 'cloudWatchLogGroupName': log_group, }, }, } |
CloudWatchLogs に残るログは、このロググループの下に自動的にストリームができて、その中にこんな出力結果が保存されます。
1 2 3 4 5 |
Script started on 2021-05-16 03:14:17+00:00 [<not executed on terminal>] ... コマンドの出力内容 ... Script done on 2021-05-16 03:14:17+00:00 [COMMAND_EXIT_CODE="0"] |
この時、ストリーム名が ecs-execute-command-b0b0*** や ecs-eni-provisioning-011a*** のようなランダム文字列であることと、ログ内容から、どのタスクで実行されたか判断できないことがわかります。
また、1 execute command につき 1 stream が作成されるので、コマンドを1行ずつループして実行するとエライことになるのは明白で、そのためワンライナーとしています。
どのタスクでの出来事なのか不明な点については、例えばこういう風に出力を仕込むことで、ストリーム名からはわからなくとも、せめて内容を見たらわかるようにしておきます。
1 2 3 4 5 6 7 8 |
INSTALLS = [ ... 'INIT_ENV=$(strings /proc/1/environ)', '$(echo "$INIT_ENV" | grep -v _PACKAGES | sed -e "s/^/export /g")', 'ECS_CONTAINER_METADATA=$(curl -s $ECS_CONTAINER_METADATA_URI)', 'echo "METADATA from $ECS_CONTAINER_METADATA_URI"', 'echo "$ECS_CONTAINER_METADATA" | jq . | cat', ] |
タスク起動直後すぎると、strings /proc/1/environ を取得できないので、そういうタイミングでは後回し気味にするとよいです。
この記述は以前にも別記事で紹介したメタデータの取り扱いですが、出力内容はこんな感じになります。jq のままだとカラーリング文字列が残っちゃうので、cat で消してます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
{ "DockerId": "a612485eb1ff42c29248200a70772beb-2418476321", "Name": "clustername", "DockerName": "dockername", "Image": "python:3.7.10-slim", "ImageID": "sha256:123814d8afbbb14562a4df02bd8799b8f0ee4963d76739d1281dc42c3e2e6302", "Labels": { "com.amazonaws.ecs.cluster": "arn:aws:ecs:ap-northeast-1:328939823261:cluster/clustername", "com.amazonaws.ecs.container-name": "containername", "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:0123456789:task/clustername/a66ad85ab1ff42e29248200c70772beb", "com.amazonaws.ecs.task-definition-family": "familyname", "com.amazonaws.ecs.task-definition-version": "75" }, "DesiredStatus": "RUNNING", "KnownStatus": "RUNNING", "Limits": { "CPU": 2 }, "CreatedAt": "2021-05-16T03:13:19.100036876Z", "StartedAt": "2021-05-16T03:13:19.100036876Z", "Type": "NORMAL", "Networks": [ { "NetworkMode": "awsvpc", "IPv4Addresses": [ "10.0.1.2" ] } ] } |
こういうシステムを扱う時、なによりもまずはログ整備をすることで、その後の動作確認とかスピーディにできるので、面倒臭がらず取り掛かりたいですね。
Python + boto3 を実行する
ここまでくれば、あとは工夫次第でなんでもできそうな雰囲気になったと思います。大きめのテキスト── スクリプトや設定ファイルを直に記述することもできますし、S3 からファイルをダウンロードさせれば、任意の外部スクリプトを実行することもできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
AWS_CREDENTIAL = [ 'set +x', 'INIT_ENV=$(strings /proc/1/environ)', '$(echo "$INIT_ENV" | grep -v _PACKAGES | sed -e "s/^/export /g")', 'ECS_CONTAINER_METADATA=$(curl -s $ECS_CONTAINER_METADATA_URI)', 'echo "METADATA from $ECS_CONTAINER_METADATA_URI"', 'echo "$ECS_CONTAINER_METADATA" | jq . | cat', 'METADATA_ADDR=$(echo "$ECS_CONTAINER_METADATA_URI" | awk -F/ "{print \$3}")', 'AWS_CONTAINER_CREDENTIAL=$(curl -s http://${METADATA_ADDR}${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI})', 'export AWS_ACCESS_KEY_ID=$(echo "$AWS_CONTAINER_CREDENTIAL" | jq .AccessKeyId -r)', 'export AWS_SECRET_ACCESS_KEY=$(echo "$AWS_CONTAINER_CREDENTIAL" | jq .SecretAccessKey -r)', 'export AWS_SESSION_TOKEN=$(echo "$AWS_CONTAINER_CREDENTIAL" | jq .Token -r)', 'set -x', ] credential_command = '; '.join(AWS_CREDENTIAL) + '; ' python_command = [ "import boto3", "s3 = boto3.resource('s3')", f"s3.meta.client.download_file('{bucket}', '{prefix}{file_name}', '{local_file}')", ] execute_command = credential_command execute_command += 'python3 -c "' execute_command += '; '.join(python_command).replace("'", "'\\''") execute_command += '"; ' execute_command += f"chmod +x {local_file}; " execute_command += f"{local_file}; " shell_command = f"/bin/sh -c '{execute_command}'" |
コンテナよろしく認証情報を有効にし、PythonでS3からファイルをダウンロードし、実行しています。同じようなことは AWS CLI でもできるのですが、awscli はインストールも実行自体も遅めなので、boto3 の方がなにかと効率的で便利かと思います。
ただ python3 コードを /bin/sh -c で囲むので、シングルクォーテーション周りが若干面倒くさく、(この場合は)dash と python3 が共存できるように replace でなんとかしています。
あと、鍵情報をログ出力して残すのはダメなので、set +x と set -x で挟んで抑制している紳士の嗜みです。
プロセスを並列実行する
一気に飛んでコマンドを並列で複数実行する方法です。完全に自分事例なんですが、とあるジョブ系コマンドを実行する必要があり、しかしシングルスレッドでCPU 100% までしか稼働できないため、2つの選択を迫られました。1つは cpu = 1024 のタスクを大量に並べる。もう1つは cpu = 4096 なら 4 プロセスを並列稼働させる。つまり workers = math.ceil(cpu / 1024) ということ。
普通に考えれば後者でやるべきなので、トライしたのですが、Exec 経由のバックグラウンド化がなかなかに曲者でした。
セッションは最大2
2並列にするなら、バックグラウンドとかじゃなくてもシンプルにコマンドを2回実行すれば並列になります。が、Amazon ECS service quotas にある通り、- ECS Exec sessions : default = 2, Adjustable = Yes
一応調整可っぽいとはいえ、あまりそこに頼らずに構築するには、まぁバックグラウンド行きが正着だよねってことで次へいきます。
初歩のバックグラウンド失敗
例えば、こういうコマンドにすると、そもそもバックグラウンドで実行させたコマンドが即終了してしまいます。
1 2 3 4 5 6 7 8 9 |
base_command = "set -x; " base_command += "ulimit -n 1000000; " base_command += f"cd {work_dir}; " execute_command = base_command for i in range(workers_per_task): execute_command += f"{background_command} & " shell_command = f"/bin/sh -c '{execute_command}'" |
これは、バックグラウンドで実行したコマンドがログアウトと同時に消滅する事象と同じで、コマンド実行者(=Exec実行者) がすぐいなくなるために、バックグラウンドプロセスも即終了されるからです。なので、実行するだけなら、nohup をつけるだけで解決しますが、ログを正常に残せなくなる場合があります。
1 |
shell_command = f"nohup /bin/sh -c '{execute_command}'" |
永久ゾンビ
実行者とバックグラウンドプロセスを切り離す手段は、nohup, disown などいくつかあるのですが、nohup /bin/sh -c にしようと、
execute_command += f”nohup {background_command} & ” にしようと、
実行こそ完了できても、そのあとにゾンビプロセスになります。
理由はやはり完了後にやり取りする親であるExec実行者がいないからで、そしてコンテナ的理由でか、親はプロセスID = 1 と記録されているので、ゾンビ共を一掃することもできない。
起動して処理させてすぐ破棄するようなタスクなら、多少の zombie process はいいじゃんて部分もあるのですが、溜まる使い方をする時に困るやんってのと、単にエンジニアとして気持ち悪いので解決したい、ということで思いつきました💡
1 2 3 4 5 6 7 8 9 10 |
base_command = "set -x; " base_command += "ulimit -n 1000000; " base_command += f"cd {work_dir}; " execute_command = base_command for i in range(workers_per_task): execute_command += f"{background_command} & " execute_command += f"sleep {run_time_seconds + 10}; " # <--- NEW!! shell_command = f"/bin/sh -c '{execute_command}'" |
一通りジョブをバックグラウンドで実行したら、ジョブ終了までシェルが待てばいいじゃない、という解決案。こうすれば、ゾンビに成ることなく全てが正常に完了できました。この場合、nohup も不要です。
実行時間が不明な場合は、もう少し複雑な待機処理になるでしょうが…… そこまで清潔さを求めるところでもないので、いくつか試して落とし所を見つければよいと思います。
ゾンビ対策(追記 2021/05/17 15:30)
結果的に上記ゾンビの対策としてはあと一歩届かなかったのですが、とても良き情報をいただきました。ゾンビプロセス対策にはタスク定義の initProcessEnabled フラグを試してみてもらえると良さそうな気がしますね〜
— Tori Hara (@toricls) May 17, 2021
/ "AWS ECS Exec を使ってみたTips | 外道父の匠" https://t.co/SntVFAm2Tm
nohup /bin/sh -c ‘{command} &’ を実行した時に、この TaskDefinition 設定を入れておくと、処理終了後にzombie化せず綺麗に完了することを確認しました。ただ上記にもチラリと書きましたが、Exec に対して nohup でのバックグラウンド切り離しを実行すると、CloudWatchLogsに残る内容が、
1 |
nohup: ignoring input and appending output to 'nohup.out' |
こうなっちゃうので、バックグラウンドにしたい、かつログも残しつつ、ゾンビ化もしない、という条件下では、やはり大本の /bin/sh を終了しないよう明示的に待機させる、という手法になりそうです。
ただ、initProcessEnabled を入れるだけで消せぬはずの親ID=1 のゾンビが消えたので、他のゾンビ化するかもしれない条件下の対策としては、助けとなる可能性が高いです。
>@toricls さん、情報提供ありがとうございました
今回はたまたま面白そうな機能が追加されたなーってのを頭の片隅に置いてあったところに、創作物にピタリとハマったので採用しましたが、構成によっては別に ECS Exec じゃなくても SSH 経由でコマンドを実行してもいいと思います。
ただSSHだと、経路の確保が必要になるので、ECS ExecならAWS鍵さえあれば完全外部からでも実行できちゃうってのが魅力です。あと最初のSSH Serverをどうやって準備するか……でビルドかExecかで選択肢となり、使い捨て・軽量なシステムならExecが楽かなってところ。
必須な仕組みではないですが、ECS Fargateを取り扱う人なら、何ができるのか覚えておくと、ところどころでハッピーになれるのではないか。っていうのと、単にこれ使ってシステム組むのが楽しいのでオススメです:-)