SSMドキュメントでSSH不要のサーバ構築パイプラインを組む

こんにちは。CCI インフラ部のTADAです。

Amazon Linux 2023でホスト名・タイムゾーン・言語・nginx...などansible-playbookコマンドで行っていた設定作業をSSMドキュメント AWS-ApplyAnsiblePlaybooks を使って自動化してみました!

目次

困っていたこと

これまでCCIではAmazon Linuxの設定作業をAnsibleでコード化していましたが
Ansible実行ホストからAmazon LinuxまではSSH接続できる必要があり下記の制約がありました

  • Amazon LinuxはPublicサブネットに配置しなければならず、外部のどこからでも経路的に到達可能なリスクがある
  • ↑をケアするためにAnsible実行ホストに固定のグローバルIPを持たせてAmazon Linux側で受信を絞っていたがフィルタ追加が面倒 & 固定のグローバルIP必須
    • CI/CDパイプラインで固定のグローバルIPを使おうとするとself-hostedなRunnerを用意する必要がある

このような制約を解消するため AWS SSMドキュメントのAWS-ApplyAnsiblePlaybooksを試してみました

試す中でコマンドを定型化した方が良いなと感じたのでCI/CDパイプライン(GitlabRunner)で自動化しています
※shell scriptが書ければパイプライン実行環境はなんでもOK

SSMドキュメントとは
  • SSM AgentがインストールされたOSはAWS Systems Manager (SSM)を通じてリモート操作が可能です
    • Amazon Linuxはデフォルトでamazon-ssm-agent が自動起動します
  • SSMで操作可能になったサーバをSSMマネージドノードと呼びます
  • SSMマネージドノードに対し、実行する操作を定義したものがSSMドキュメントです

事前準備

  • 構築対象のAmazon LinuxはSSMマネージドノードである必要があります
    • AmazonSSMManagedInstanceCore ポリシーが付与されたIAM Roleをアタッチ
    • インターネットへの疎通性
      • Outbound通信ができればよいためPrivateサブネットでもOK
  • 今回はansible-playbookファイルの受け渡しにS3を利用しています。そのためS3バケットとPut/Get権限も必要です
  • 執筆時点で AWS-ApplyAnsiblePlaybooks は Amazon Linux 2023 に対応していません
    • そのため自作のSSMドキュメントで2023に対応させる必要がありますが、便宜上 AWS-ApplyAnsiblePlaybooks と呼称しています
    • どのようなSSMドキュメントを自作すればよいかは下記を参考にさせて頂きました
    • 参考: qiita.com

パイプラインの流れ

パイプライン

  1. Runner上でansible-playbookをzipファイルにまとめS3にアップロードします
  2. Runner上でSSM-RunCommandを送信し、Amazon Linuxを対象に AWS-ApplyAnsiblePlaybooks を実行します
    AWS-ApplyAnsiblePlaybooksは、実行するplaybookをS3から取得できます。ansible実行結果はAmazon Linux上の標準出力に出るため標準出力をS3に書き出すようにします
  3. 最後にS3に書き出された標準出力をRunnerにダウンロード、Runnerのjob実行結果として表示します

変数サンプル

variables:
  S3_URI: "s3://${BUCKET_NAME}"
  S3_VHOST_URI: "https://${BUCKET_NAME}.s3.ap-northeast-1.amazonaws.com"
  COMMON_PREFIX: "run-command"
  ANSIBLE_DOCUMENT_NAME: "Custom-ApplyAnsiblePlaybooksAL2023"
  ANSIBLE_DOCUMENT_VERSION: "$$DEFAULT"
  ANSIBLE_S3_KEY: "${COMMON_PREFIX}/${HOSTNAME}.zip"
  ANSIBLE_UPLOAD_PATH: "${S3_URI}/${ANSIBLE_S3_KEY}"
  ANSIBLE_SOURCE_PATH: "${S3_VHOST_URI}/${ANSIBLE_S3_KEY}"
  ANSIBLE_S3_OUTPUT: "${COMMON_PREFIX}/${HOSTNAME}/output/ansible"

※${BUCKET_NAME}は事前に用意した自バケット名を指定
※"Custom-ApplyAnsiblePlaybooksAL2023"は自作した2023対応のAWS-ApplyAnsiblePlaybooks

1. ansible-playbookをzipファイルにまとめS3にアップロード

AWS-ApplyAnsiblePlaybooks はソースタイプとしてGithub/S3をサポートしており、S3を利用する場合は単一の .zip ファイルまたはディレクトリ構造を引数として渡します
今回はzipファイルとして渡します

job:
  script:
    - zip -r ${HOSTNAME}.zip .
    - aws s3 cp ${HOSTNAME}.zip ${ANSIBLE_UPLOAD_PATH}
2. SSM-RunCommand を送信し、AWS-ApplyAnsiblePlaybooks を実行

aws ssm send-command で AWS-ApplyAnsiblePlaybooksを実行します
実行は非同期になりますので、コマンド実行時に得られる CommandId を引数に aws ssm get-command-invocation で実行結果を確認します

    # NameタグからINSTANCE_IDを取得
    # 設定対象のNameタグはユニークなので1台分の値が返ることを期待
    # instance-state-code=16 : running
    - >
      INSTANCE_IDS=$(aws ec2 describe-instances 
      --query "Reservations[].Instances[].{InstanceId:InstanceId}"
      --filters "Name=tag:Name,Values=${HOSTNAME}" "Name=instance-state-code,Values=16"
      )
    - >
      if [ $(echo "${INSTANCE_IDS}" | jq '. | length') -eq 1 ]; then
        INSTANCE_ID=$(echo "${INSTANCE_IDS}" | jq -r '.[].InstanceId')
      else
        exit 1
      fi

    # ansible-playbook実行
    # --check にしたいときは CHECK_MODE=True , 設定変更したいときは CHECK_MODE=False
    # ${PLAYBOOK} にはzipファイル内のplaybookファイルのpathを指定
    - >
      RESULT=$(aws ssm send-command 
      --document-name "${ANSIBLE_DOCUMENT_NAME}"
      --document-version ${ANSIBLE_DOCUMENT_VERSION}
      --targets "[{\"Key\":\"InstanceIds\", \"Values\":[\"${INSTANCE_ID}\"]}]" 
      --parameters 
      "{  \"SourceType\" : [\"S3\"],
          \"SourceInfo\" : [\"{\\\"path\\\":\\\"${ANSIBLE_SOURCE_PATH}\\\"}\"],
          \"InstallDependencies\" : [\"True\"],
          \"PlaybookFile\" : [\"${PLAYBOOK}\"],
          \"ExtraVariables\" : [\"SSM=True\"],
          \"Check\" : [\"${CHECK_MODE}\"],
          \"Verbose\" : [\"-v\"],
          \"TimeoutSeconds\" : [\"3600\"]
      }" 
      --timeout-seconds 600 
      --max-concurrency "50" 
      --max-errors "0" 
      --output-s3-bucket-name "${BUCKET_NAME}" 
      --output-s3-key-prefix "${ANSIBLE_S3_OUTPUT}")
    - echo ${RESULT}
    - CommandId=$(echo ${RESULT} | jq -r '.Command.CommandId')

    # 実行結果取得: Pending/InProgress以外のステータスになるまで複数回Get
    # Success以外になっても後続のstdoutダウンロードを行いたかったため Waiters(aws ssm wait command-executed) は使いませんでした
    - >
      for i in $(seq 0 9);do
        sleep 30

        COMMAND_RESULT=$(aws ssm get-command-invocation --command-id ${CommandId} --instance-id ${INSTANCE_ID})
        echo ${COMMAND_RESULT}

        if [ $(echo ${COMMAND_RESULT} | jq -r '.StatusDetails') != "Pending" ];then
          if [ $(echo ${COMMAND_RESULT} | jq -r '.StatusDetails') != "InProgress" ];then
            break
          fi 
        fi
      done

※ "${ANSIBLE_SOURCE_PATH}" = ""https://${BUCKET_NAME}.s3.${REGION}.amazonaws.com/${KEY}" の形式
 前出の${ANSIBLE_UPLOAD_PATH}とは形式が異なる点に注意

3. S3に書き出された標準出力をダウンロード・表示
    # stdout/stderrをS3からdownloadし出力
    - echo "Download stdout/stderr..."
    - aws s3 cp ${S3_URI}/${ANSIBLE_S3_OUTPUT}/${CommandId}/${INSTANCE_ID}/awsrunShellScript/runShellScript/stdout ./stdout
    - aws s3 cp ${S3_URI}/${ANSIBLE_S3_OUTPUT}/${CommandId}/${INSTANCE_ID}/awsrunShellScript/runShellScript/stderr ./stderr
    - cat stdout
    - cat stderr

    # 実行結果がSuccess以外のステータスの場合Runner JobもFailedとする
    - >
      if [ $(echo ${COMMAND_RESULT} | jq -r '.StatusDetails') != "Success" ]; then
        echo "StatusDetails: $(echo ${COMMAND_RESULT} | jq -r '.StatusDetails')"
        exit 1
      fi
ちょっと工夫

s3からダウンロードした stdout にansibleの実行結果が出力されますが、デフォルトでは白黒で見づらくなってしまいました
ansible.cfg に下記のように付けることで変更箇所に色が付き、ssh実行しているときと同じような見え方になりました

# ansible.cfg
[defaults]
force_color = True

最後に

aws ssm send-commandに渡す引数が多い・実行結果の取得を非同期に行う必要がある、という2点には気を付ける必要がありますが
1度パイプラインを組んでしまえば非常に簡単にansible実行が行えるようになりました

パイプラインの実行ホストはグローバルIPが変動する環境が多いと思いますが、SSM経由であればセキュアな通信経路を実現できそうです

またSSHで初期構築していたころは、最初にec2-userで初期構築用のplaybook -> 別ユーザーに切り替えて ec2-user でログインできなくするためのplaybookと多段実行を行っていました
SSMは実行するplaybookをS3からダウンロード・localhostに対して適用する形(ansible-playbook -c local)になるため、初期構築~ec2-user無効化まで1回で行えるようになりました

AWS-ApplyAnsiblePlaybooks 以外にも有用なSSMドキュメントは多く準備されていますので、いろいろ試して構築作業の質を高めていきたいと思います!