オペレーション再現性を高めるための作業用ホスト使い捨て戦略

SREチームの長田です。 今回はAWSのVPC(Virtual Private Network)内で作業する時の話です。

VPC内で作業したい

VPC内で作業したいこと、ありますよね。 環境構築中の動作確認とか、不具合・障害調査のための定形外作業とか、メンテナンスためのイレギュラーな作業とか。 定常的に行うほどではないですが、AWSでVPCに絡んだサービスを使用しているなら、VPC内での作業は少なからずあると思います。

VPC内に閉じたリソースにアクセスする場合は、当然ですがVPC内からアクセスする必要があります。 VPC外からアクセスするための経路を用意すればそれも可能ですが、アプリケーションが使っている経路とは異なる経路を使うことになるため、 不具合調査時の再現確認などでは余計な要素となることがあります。

VPC内からの操作は、AWSであればCloudShellVPC環境を作るのが一番お手軽な方法でしょう。 ちょっとした疎通確認などはこれだけで事足りると思います。 最初からawscliも使えますし。

ただ、ちょっと凝ったことをしたくなると、CloudShellでは不便なことがあります。 例えばアプリケーションのコードを動かして動作確認したい場合、動作に必要な環境変数・認証情報・設定ファイルなどを用意する必要があります。 不要な差異をなくすために、VPC内での作業環境は操作対象の環境(本番環境やステージング環境など)と極力同一であることが望ましいです。

常駐作業用ホストの問題点

そのような作業環境を実現するために、以前は作業用のホストを常駐させていました。 例えばEC2インスタンスを常時起動しておいて、そこにsshで接続する、といった形です。 AMIを本番環境でアプリケーションが動作するために使うものと同じものにしておけば、環境の差異をなくすことができます。

しかし、常駐作業環境にはいくつかの問題点があります。

一点ものになりやすい

作業用環境が常駐しているということは、そこにファイルを置くことができるということです。 また、必要に応じて一時的に作業用ホストの設定を変更したり、追加でパッケージをインストールすることもあるでしょう。 それらのファイル・設定・パッケージ類は、そのホストが生きている限り残ります。 残っていることを前提とした作業は、作業用ホストを作り直した際の再現性がありません *1

変更履歴をきっちりと記録して、その都度プロビジョニングツール等に反映させていけばこの問題は解決できるでしょう。 ただ、パッケージ管理システム管理下のパッケージ等管理する手段があるものであればまだしも、 単なるファイルや手動で配置したツール類までそこに含めようとするとどうしても管理漏れが発生してしまいます。

常駐させるコスト

常駐させるということは、使っていない時間も課金が発生するということです。 使っていない時間の課金は無駄なのでできれば避けたいものです。

セキュリティに関連するリスク

外部から接続できる環境を常駐させておくということは、セキュリティ上のリスクを抱えることにもなります。 安全な接続方法を選択していたとしても、不要なホストは停止してしまったほうが賢明でしょう。

作業用ホストを使い捨てる

常駐させることに問題があるのであれば、常駐させなければいいのです。 それも、起動するたびにフレッシュな状態に戻る*2ようにしておけば、 一点ものになってしまうという課題も解決します。

具体的には、作業用ホストとして使い捨てのECS Taskを起動し、 用が済んだらそのTaskを終了(削除)する、という方法です。

作業用ホストを使い捨てることによるメリット

作業用ホストを使い捨てることによるメリットを挙げてみます。

一時的な変更が永続化しない

毎回フレッシュな作業用ホストが起動するので、前回作業時の変更を引き継ぎません。 つまり、前回作業時の作業用ホストへの変更が次回作業時に影響を及ぼすことはありません *3

逆に言えば、作業用ホストをいくらでもぶっ壊せるということです。 うっかりクリティカルな設定変更をしてしまったとしても、その作業用ホストを終了して、新しいホストを起動してしまえばいいのです。 コンテナによるアプリケーション実行環境と同じことを、作業用ホストにも適用しようというわけですね。

環境設定のコード化が強制される

作業が終了したら作業用ホストが消滅するので、次回以降も使いたい設定・パッケージなどはプロビジョニングツールなどで管理する必要があります。 前回作業時に残されたものを惰性で使うことはできないので、必要なものを管理対象に含めることを強制することができます。

必要なコストしかかからない

作業をするときにしか作業用ホストを起動しないのであれば、それにかかるコストは最小化できます。

ECS Run Taskで作業用ホストを起動する仕組み

より具体的なはなしをしていきましょう。 例としてカヤックで開発・運用している「まちのコイン」で使用している仕組みを紹介します。

なお、ここからは「作業用ホストとして利用するECS Task」のことを「作業用Task」として表記します。 また、ECS Taskの操作には ecspresso を使用しています。

「まちのコイン」では、前述の通り作業用ホストとしてECS Taskを使用しています。 ECS TaskはRunTaskを使って単発のTaskとして起動します。 ecspresso run コマンドを使えば、Task定義ファイルを元にRunTaskを実行することができます。

起動時にタグとして使用者の名前を付けておくことで、他の作業者が使う作業用Taskと区別できるようにしています。 まだ自分用の作業用Taskが起動していない場合は新しく起動し、既に起動している作業用TaskがあればそのTaskに接続するようにしています。

以下に作業用Taskを起動するスクリプトを簡略化したものを示します。 なお、 bastion は作用行Taskを指す名称です *4

#!/bin/bash

set -eu

task_launchd_by="$1"

# 自分で起動している作業用TaskがすでにあればTask IDを返す関数
function running_task_id {
    # 既に起動している作業用Task一覧を取得
    tasks=$(ecspresso tasks --config service/bastion.jsonnet --output json | jq -r '@base64')

    for task in ${tasks[@]}; do
        # Taskの状態を取得
        last_status=$(echo "$task" | base64 -d | jq -r '.lastStatus')
        desired_status=$(echo "$task" | base64 -d | jq -r '.desiredStatus')

        # 起動済みのTaskでなければ次の候補へ
        if [[ "$last_status" != "RUNNING" ]] || [[ "$desired_status" != "RUNNING" ]]; then
            continue
        fi

        # タグから作業者名と記録されている値を取得
        task_launched_by=$(echo "$task" | base64 -d | jq -r '.tags[] | select(.key == "LAUNCHED_BY") | .value')

        # 自分で起動したTaskであればそのIDを返す
        if [[ "$task_launched_by" == "$LAUNCHED_BY" ]]; then
            echo "$task" | base64 -d | jq -r '.taskArn | split("/")[-1]'
            return
        fi
    done
}

# 自分用の作業用TaskのIDを取得(起動していなければ空文字列)
task_id=$(running_task_id)

if [[ -n "$task_id" ]]; then
    # すでに起動していれば新たに起動しない
    echo 'your bastion is already running'
else
    # まだ起動していなければ ecspresso run で作業用Taskを起動
    # 注: bastion.jsonnet 内で enableExecuteCommand=true という指定が必要
    ecspresso run \
        --config bastion.jsonnet \
        --latest-task-definition \
        --no-wait \
        --tags LAUNCHED_BY=${LAUNCHED_BY} \
        --propagate-tags=TASK_DEFINITION
fi

## 起動完了まで待つ
if [[ -z "$task_id" ]]; then
    echo -n 'waiting for launch'

    while [[ -z "$task_id" ]]; do
        echo -n '.'
        task_id=$(running_task_id)
        sleep 5
    done

    # Task起動直後はまだssm-agentが起動できていないので少し待つ
    sleep 10
fi

# ECS Execで作業用Taskに入る
exec ecspresso exec --id ${task_id} --container bastion

このスクリプトを実行すると、以下の処理が行われます。

  1. 作業用Taskを起動し
  2. 起動が完了するまで待ち
  3. ECS Execで作業用Taskに入る

作業用Taskに入ったあとは、常駐の作業用ホストと同じように作業を始められます。

使い終わった作業用ホストを自動で削除する仕組み

「作業用ホストを使い捨てる」というからには、使い終わったらそのホストを削除する必要があります。 まちのコインでは、作業用TaskへのECS Execによる接続があるかどうかで削除(停止)するべきかどうかを判断しています。

ECS Execによる接続状況は、AWS Systems ManagerのSession Managerを使って取得することができます。 接続がない状態が一定時間以上継続したら、コンテナのentrypointであるスクリプトを終了することで、作業用Taskを削除しています。

以下にentrypointとして使用しているスクリプトを簡略化したものを示します。

#!/usr/bin/env bash

set -eu

# 起動時に必要なセットアップがあればここで行う
# アプリケーションのコードやバイナリを配置するなど
./init.sh

# ECS Taskのメタデータからクラスタ名とTask IDを取得
cluster=$(curl -s "${ECS_CONTAINER_METADATA_URI_V4}/task" | jq -r '.Cluster | split("/")[-1]')
task_id=$(curl -s "${ECS_CONTAINER_METADATA_URI_V4}/task" | jq -r '.TaskARN | split("/")[-1]')

# ECS Execでのセッション状態を取得するためのクエリ
session_status_query="[.Sessions[] | select(.Target | startswith(\"ecs:${cluster}_${task_id}_\"))][0] | .Status"

# 作業用Task終了判定パラメータ
# 終了カウント(terminate_count)が一定回数(terminate_threshold)を超えたらentrypointを抜けてTaskを終了する
# この例では接続がない状態が 60 * 30 = 1800秒 継続したら終了
terminate_count=0
terminate_interval=60 # sec
terminate_threshold=30 # times

while true
do
    echo "wait until no session exists" >&2
    sleep $terminate_interval

    # ECS Execでのセッション状態を取得
    # 一度も接続していない場合は終了カウントが進まないように、Historyのセッションも取得している
    active=$(aws ssm describe-sessions --state Active | jq -r "$session_status_query")
    history=$(aws ssm describe-sessions --state History | jq -r "$session_status_query")

    if [[ "$active" != "Connected" ]] && [[ "$history" == "Terminated" ]]; then
        # アクティブな接続が無く、かつ過去に接続したことがある場合は終了カウントを進める
        terminate_count=$(($terminate_count + 1))
        echo "terminate_count ${terminate_count}" >&2
    else
        # アクティブな接続があれば終了カウントをリセット
        terminate_count=0
        echo "terminate_count reset" >&2
    fi

    # 接続がないまま (terminate_interval * terminate_threshold)sec 経過したら終了
    if [[ $terminate_threshold -le $terminate_count ]]; then
        break
    fi
done

echo "bye" >&2

exit 0

ちなみに、「接続がなければ終了する」ためのアプリケーションとして、同じくSREチームメンバーである池田作の ecs-task-self-terminator というものもあります。 こちらのほうがより簡単に自動削除を実現できるでしょう。 詳しい使い方は ecs-task-self-terminator のREADMEを参照してください。

作業用ホストを使い捨てることによるデメリット

作業用ホストを使い捨てることによるデメリットも挙げておきましょう。

起動に時間がかかる

作業用ホストを使い捨てるためには、その都度新しいホストを起動する必要があります。 ECS Run Taskで作業用Taskを起動する場合、イメージのサイズにもよりますが1〜数分程度の待ち時間が発生します。 常駐の作業用ホストであれば(接続の仕方にもよりますが)待ち時間がほぼゼロであることを考えると、ストレスの原因になってしまうかもしれません。

ちなみに、作業用ホストはSeekable OCIとの相性が良く、 実際「まちのコイン」で導入したところ、作業用Taskの起動待ち時間は2分から1分に半減しました。

長時間作業には向かない

今回紹介した仕組みに限ったはなしではありますが、長時間かかるバッチ処理等の手動実行には向きません。 バッチ実行している間に接続が切れて、作業用Taskが削除されてしまう場合があるからです。

時間のかかる作業を行う場合は自動削除を無効化するオプションを用意するか、 あるいはECS Run Taskでその作業のためのコマンドのみを直接実行するとよいでしょう。

ファイルのやり取りが面倒

ECS Taskには直接rsyncなどでファイルを転送する方法が使えないので、ローカルマシンとのファイルのやり取りは少々面倒です。 ファイルのやり取りを実現するためには、作業用Taskとローカルマシンが共通してアクセスできるS3などのストレージを経由することになります。 頻繁にファイルのやり取りが発生する場合は、その操作をスクリプト化するなどして簡略化するとよいでしょう。

簡略化の手段として、ECS操作ツールである ecsta *5 が提供する ecsta cp というサブコマンドが利用できます。 サブコマンド名の通り、 cp コマンドのようにECS Taskとローカルマシン間で相互にファイルをやり取りすることができます。

$ ecsta cp data.csv ${TASK_ID}:/tmp/data.csv

使い方の詳細や仕組み、利用に必要な条件等については ecsta のREADMEを参照してください。

番外編

作業環境の使い捨てと本質的なつながりはないですが、関連したはなしをいくつか紹介しておきます。

作業用ホストへの接続

リモートホストへの接続としてまず思い浮かぶのはsshです。 しかし、うかつにssh接続用のポートを外部にさらしておくと、あっという間に不正アクセスに晒されます。 sshを使わずに済むのであれば、リスクを回避するためにもそれに越したことはありません。

AWSであればAWS Systems Manager Session Managerを利用するとよいでしょう。 ポートを開放する必要がなく、IAM Policyでアクセスを制御できます。 プライベートサブネットにあるホストにも接続できるので、パブリックなネットワークに晒す必要もありません。 地味にコストのかかるElastic IPを確保する必要がないのもうれしいポイントです。 ECS Execも、対象Taskへの接続にはSession Managerを使用しています。

本記事の「ECS Run Taskで作業用ホストを起動する仕組み」で紹介したスクリプトでも、 ECS Exec(ecspresso exec)を使って作業用Taskに接続しています。

Session Managerでの接続には、対象ホスト上でSSM Agentが動作している必要があります。 他にもいくつか必要な設定があるので、詳しくはAWSのドキュメントを参照してください。

作業用ホストの使用通知

作業用ホストを使用するということは、対象の環境に対して何かしらの操作を行うということです。 特に本番環境であれば、環境に対する操作は慎重に行うべきですし、またその操作を行っていることを開発運用メンバーに周知する必要もあるでしょう。

ECS Taskを作業用ホストとして使用し、ECS Execでコンテナに接続する場合は、 EventBridgeにRuleを設定することでこの周知を自動化することができます。

例えば以下のようなRuleを設定し、SNS経由で ChatBotに接続、 その後Slackなどに通知するとよいでしょう。

{
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventName": ["ExecuteCommand"],
    "eventSource": ["ecs.amazonaws.com"]
  }
}

不審な接続に気づく手段としても有効でしょう。

まとめ

作業用ホストを使い捨てる運用について紹介しました。 デメリットはいくつかありますが、それを上回るメリットがあると考えています。

定形外の作業には、常にオペレーションミスのリスクが伴います。 作業時に考慮しなければならないことを減らすためにも、作業用ホストはいつでも同じ状態で起動してほしいのです。


カヤックでは不確実性を減らしたいエンジニアも募集しています!

hubspot.kayac.com

*1:実際に社内でも「作業用ホストにしか存在しないスクリプト」を使ってデプロイしている例がありました。 たまたまそのホストが事故などで失われる前に再現性のあるセットアップ方式に改善できたので事なきを得ましたが、 うっかり消えてしまっていたら同一のデプロイフローを再構築するにはコストもリスクも生じていたでしょう。

*2:ブラウザのincognito modeを想像してもらえばいいでしょう。

*3:もちろん、作業用ホスト外への変更、例えばデータベース内のデータ変更等は永続化するのでこの限りではありません

*4: "bastion" という単語は、もともとは プライベートネットワーク外に置かれたホストを「砦」になぞらえて使われていた呼び名のようです。 本稿で主題としている「作業用ホスト」はプライベートネットワーク内で動作するので、本来の役割とは異なりますが、 昔からの習慣で現在も "bastion" という名前を使っています。

*5:ecstaはECS操作ライブラリとしてecspressoからも使用されています。