Cloud RunをAWSのSession Managerでシェル操作する

Cloud RunにSession Managerを使って強引にシェルアクセスする方法を紹介します。
2021.12.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、MAD事業部の加藤です。

本エントリーはクラスメソッドGoogle Cloud Advent Calendar 2021の20日目の記事です。

Cloud RunにSession Managerを使って強引にシェルアクセスする方法を紹介します。Cloud Run、Session Managerともに想定されていないと思われる使い方をしており、Dockerコンテナ運用方法として褒められた物では無いので決して本番環境では使用しないようにお願いします。開発・検証環境でログ出力によるデバッグが手間、雑にでも良いからコンテナ上で簡単に行いたい作業がある場合などを想定しています。

解説

ハイブリッド環境におけるSystems Manager

ハイブリッド環境(AWS以外の環境を併用すること)のサーバーをSystems Managerの管理下に置くためには、AWS上のインスタンスを管理する場合と異なりアクティベーション作業を行う必要があります。 アクティベーション作業は、アクティベーションコードおよびIDの発行と登録の2段階で行われます。登録されたサーバーはマネージドインスタンスとして管理されます。

アクティベーションコード1つに対して登録出来るサーバー数は指定可能ではあるが有限です。これはCloud Runの様にコンテナが自動で増減する環境とは相性が良くありません。今回の方法ではこれに対処する為に、コードの発行自体も動的に行います。また、発行されたコードやマネージドインスタンスの情報がいつまで残っているのは運用に負担をかけるのでコンテナの終了時にこれらの削除処理も行います。

動的なコード発行&削除およびマネージドインスタンスの登録&登録解除

Cloud Runにはサイドカーとしてコンテナを動かして初期処理などを別コンテナに任せるような機能は存在しません。なので、ENTRYPOINTにコード発行&削除およびマネージドインスタンスの登録&登録解除を行わせます。具体的には、起動時に発行&登録を行った後にCMDで指定された処理(以降、メインプロセス)を開始し、SIGTERM受け取ったら削除&登録解除とメインプロセスを終了します。

実践

AWS IAMユーザーの発行

Cloud RunからSession Managerを操作する必要があるためIAMユーザーを発行します、おそらくGoogle CloudのサービスアカウントにIAMロールを割り当て、一時クレデンシャルを発行する仕組みを用意すればよりセキュアに出来るのですが、本筋では無いのでIAMユーザーを発行し、アクセスキーとシークレットアクセスキーを使用します。

作成したIAMユーザーに下記をインラインポリシーとしてアタッチします。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iam:GetRole",
        "iam:PassRole",
        "ssm:CreateActivation",
        "ssm:DeleteActivation",
        "ssm:DeregisterManagedInstance",
        "ssm:RegisterManagedInstance"
      ],
      "Resource": "*"
    }
  ]
}

Dockerfileの作成

AWS CLIとSSMエージェントのインストールを行い、Session Managerで使用するssm-userにパスワード無しでsudo出来る権限を与えておきます。 ENTRYPOINTの処理をbashで書こうとするとしんどかったでのgoogleのzxを使って居ます。そのためにzxをグローバルインストールしています。

オリジナルのDockerfileにこれらの処理を追記して、Dockerfile.devとして、以下のように作成します。

FROM node:16.13-bullseye

ENV REGION "ap-northeast-1"

RUN npm install --global zx

WORKDIR /var/tmp
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install && \
    rm -rf ./aws && \
    rm -f ./awscliv2.zip

WORKDIR /var/tmp
RUN wget https://s3.${REGION}.amazonaws.com/amazon-ssm-${REGION}/latest/debian_amd64/amazon-ssm-agent.deb && \
dpkg -i amazon-ssm-agent.deb && \
rm amazon-ssm-agent.deb
RUN apt-get update -y && \
    apt-get install -y sudo
RUN useradd -m ssm-user && \
    echo "ssm-user ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

ENTRYPOINT ["zx", "./docker-entrypoint.ts"]

# 以降はオリジナルのDockerfile
WORKDIR /opt/app

COPY package*.json ./
RUN npm ci

COPY . ./
RUN npm run compile

CMD ["npm", "start"]

ENTRYPOINTの作成

コードとIDの発行

AWS SDKを使いコードとIDを発行します。DefaultInstanceNameDescriptionは自由に変更できます。IAMロールにservice-role/AmazonEC2RunCommandRoleForManagedInstancesを使用しています。もしこのサービスロールが存在しなかった場合はAWSマネジメントコンソールから一度アクティベーションコードの発行を行ってみてください。

type ActivateParams = { activateId: string; activateCode: string };

async function createActivate(): Promise<ActivateParams> {
  const command = new CreateActivationCommand({
    DefaultInstanceName: "cloud-run-dev",
    Description: "cloud-run-dev",
    IamRole: "service-role/AmazonEC2RunCommandRoleForManagedInstances",
  });
  const { ActivationId: activateId, ActivationCode: activateCode } =
    await ssmClient.send(command);

  console.log(`create activate id: ${activateId}`);

  return {
    activateId,
    activateCode,
  };
}

マネージドインスタンスの登録

SSMエージェントでマネージドインスタンスを登録します。この際にレスポンスから正規表現でインスタンスIDを抽出します。

async function register({
  activateId,
  activateCode,
}: ActivateParams): Promise<{ instanceId: string }> {
  console.log(activateCode);
  const { stdout, stderr } =
    await $`amazon-ssm-agent -register -id ${activateId} -code ${activateCode} -region ${REGION} -y`;

  const regex = /mi-.*/;
  const found = stdout.match(regex);

  if (found.length !== 1) {
    throw new Error(
      `Failed to get instance id. stdour: ${stdout}, stderr: ${stderr}`
    );
  }

  const instanceId = found[0];

  console.log(`register instance id: ${instanceId}`);

  return { instanceId };
}

全体の流れ

上記の関数を使いマネージドインスタンスの登録まで終わったら、SSMエージェントをバックグラウンドで起動します。その後メインプロセス(今回の場合はnpm start)を実行します。SIGTERMを受け取った際はメインプロセスへSIGTERNの送信、コードの削除、マネージドインスタンスの登録解除を並列で行います。

async function main() {
  console.log("start docker entrypoint");
  const command = process.argv.slice(3);

  const { activateId, activateCode } = await createActivate();
  const { instanceId } = await register({ activateId, activateCode });

  $`nohup amazon-ssm-agent > /dev/null &`;

  const proc = $`${command[0]} ${command.slice(1)}`;
  // FIXME: https://github.com/google/zx/issues/249#issuecomment-971710172
  proc.catch(() => {});

  process.on("SIGTERM", async () => {
    console.log("docker-entroypoint.ts: SIGTERM received, terminate server.");
    await Promise.all([
      proc.kill("SIGTERM"),
      terminate({ activateId, instanceId }),
    ]);
    exit();
  });

  process.on("SIGINT", async () => {
    console.log("docker-entroypoint.ts: SIGINT received, terminate server.");
    await Promise.all([
      proc.kill("SIGINT"),
      terminate({ activateId, instanceId }),
    ]);
    exit();
  });
}

Cloud Runの作成

ソースコードを事前にGitHubにアップロードしておき、ソースリポジトリとして指定します。Build Typeで指定するDockerfileのソースの場所が/Dockerfile.devとする必要があることに注意してください。

変に動いてコンテナが大量に立ち上がったら嫌だったので、インスタンス最大数を4にし、検証用となので未認証の呼び出しを許可、シークレットに作成したIAMユーザーのアクセスキーとシークレットアクセスキーを環境変数として設定し、作成します。

動作確認

Cloud Runの作成が完了したら、curl ${URL}でリクエストを投げてレスポンスが返ってくるか確認します。リクエストが返ってきたら、AWSマネジメントコンソールからSession Managerを開くとインスタンスを確認できるのでセッションを開始します。

良い確認方法が浮かばなかったのでIPアドレスがGoogleのモノか雑に調べてみました。(digコマンドは事前にsudo apt-get install dnsutilsでインストールしていました)

リクエストが無いまま時間が経つと無事にアイドルインスタンスも0になり、アクティベーションコードの削除とインスタンスの登録解除が行われた事も確認できました。

あとがき

はい、というわけでネタブログでした。ネタですが、IAMユーザーで妥協としたところ以外は自動で登録削除まで行うし結構良い感じに出来たなって感じです。 zxは慣れているTypeScriptで処理を書くことができ、実行したプロセスへのKillやstdout/errの取得も簡単に出来てすごい便利でした。CloudRunで前処理、後処理をしたくなった時はこのスクリプトを流用していこうと思います。

今回の書いたコードは全てここに置いてあります。intercept6/session-manager-in-cloud-run(https://github.com/intercept6/session-manager-in-cloud-run)

以上です、ありがとうございました。