ENGINEER BLOG ENGINEER BLOG
  • 公開日
  • 最終更新日

【Amazon EC2】自動停止システム構築ハンズオン

この記事を共有する

目次

パーソル&サーバーワークスの印鑰です。

AWSを中心としたクラウドインフラの設計・構築・運用を担当しています。
エージェント開発にも注力しているエンジニアです。

このハンズオンで学べること

このハンズオンでは、以下一連の流れを体験することで、AWSのコスト最適化とインフラ自動化のスキルが身につきます。

  • Infrastructure as Code(IaC)によるインフラ構築
  • Bashスクリプトとsystemdによる定期監視の仕組み
  • Amazon CloudWatch LogsとAWS Lambdaを連携させたイベント駆動型の自動化処理

どんな人向け?

  • AWS マネジメントコンソールの基本操作ができて、Linuxコマンドライン(cd、ls、cat、viエディタ)の基礎がわかる方なら大丈夫です。
  • AWS CloudFormationやBashスクリプトの知識があるとさらに楽しめますが、なくてもコピー&ペーストで進められます。

1.概要

このシステムには以下のような特徴があります。

特徴 説明
コスト削減 使っていないAmazon EC2を自動で止めて、無駄な出費をカット
柔軟な設定 アイドル時間やデバウンス間隔を自由に調整
タグで簡単制御 auto_stopタグをつけるだけで停止対象を管理
Session Manager対応 SSHだけじゃなく、Session Managerの接続もバッチリ検知

2.システムアーキテクチャ

全体構成図

全体構成図-20260205-001.png

3.前提条件

ハンズオンをはじめる前に、以下をお読みください。

所要時間

  • 約1時間
    • 構築作業:約10~20分
    • 動作確認:約20~30分
    • 環境削除:約10分

必要な権限

以下の権限が必要です。

  • AWS マネジメントコンソールにアクセスできること
  • AWS CloudFormationスタックをデプロイできること
  • Amazon EC2、AWS IAM、Amazon CloudWatch Logs、AWS Lambdaを操作できること

リージョン

東京リージョン(ap-northeast-1) にリソースを作成してください。

開発環境

ローカルPCに以下がインストール・設定済みであることをご確認してください。

  • Windows 11
  • Visual Studio Code

付録:Session Managerポートフォワーディング経由のSSH接続 を行う場合は、以下が必要です。

  • AWS CLI(認証設定済み)
  • Session Manager Plugin

費用について

このハンズオンの費用の目安は、東京リージョンで1時間あたり約$0.10~$0.20程度です。

大事なポイント: 作業が終わったら、必ず環境削除の手順を実行してください。そのままにしておくと課金が続きます。また、為替やログ量、リージョン価格差で変動します。

AWSの設定

このハンズオンでは、AWSの設定が以下となっていることを前提としています。

  • AWS Systems Manager Session Manager
    • アイドルセッションタイムアウト:20分

ご参考: アイドルセッションのタイムアウト値を指定します。

4.構築手順

さあ、ここからが本番です。一緒に作っていきましょう。

ステップ一覧

  1. 基盤スタックのデプロイ
  2. EC2インスタンススタックのデプロイ
  3. Session Managerでインスタンスに接続
  4. 監視スクリプトの作成
  5. スクリプトの動作テスト
  6. systemdサービスとタイマーの設定
  7. CloudWatch Agentの設定
  8. 動作確認用の設定変更

ステップ1: 基盤スタックのデプロイ

まずは、VPC、AWS IAMロール、Amazon CloudWatch Logs、AWS Lambda関数といった基盤となるリソースを作っていきます。

基本スタックのデプロイ-20260204-002.png

1-1. AWS CloudFormationテンプレートファイルの作成

ローカルPCでec2-auto-stop-base-stack.yamlという名前のファイルを作って、以下の内容をコピー&ペーストしてください。

ここを押すと展開します

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Base stack: VPC, IAM, Logs, Lambda, KeyPair'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  VpcCidr:
    Type: String
    Default: '10.0.0.0/16'
  PublicSubnetCidr:
    Type: String
    Default: '10.0.1.0/24'
  LogRetentionInDays:
    Type: Number
    Default: 7
    AllowedValues: [1,3,5,7,14,30,60,90,120,150,180,365,400,545,731,1827,3653]
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
      -
        Label:
          default: Network Configuration
        Parameters:
          - VpcCidr
          - PublicSubnetCidr
      -
        Label:
          default: Log Configuration
Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Sub '${Environment}-${ProjectId}-vpc'
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref PublicSubnetCidr
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub '${Environment}-${ProjectId}-public-subnet'
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      GatewayId: !Ref InternetGateway
  PublicSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable
  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security Group for SSM-only'
      VpcId: !Ref VPC
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: '0.0.0.0/0'
  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-ec2-ssm-rl'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: ec2.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
  EC2LogPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Sub '${Environment}-${ProjectId}-ec2-log-group-write-log-pl'
      Roles:
        - !Ref EC2Role
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              - logs:DescribeLogStreams
            Resource:
              - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${Environment}-${ProjectId}-ec2-log-group-write-logs'
              - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${Environment}-${ProjectId}-ec2-log-group-write-logs:*'
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-instance-profile'
      Roles:
        - !Ref EC2Role
  EC2KeyPair:
    Type: AWS::EC2::KeyPair
    Properties:
      KeyName: !Sub '${Environment}-${ProjectId}-ec2-keypair'
      KeyType: rsa
      KeyFormat: pem
  EC2WriteLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '${Environment}-${ProjectId}-ec2-log-group-write-logs'
      RetentionInDays: !Ref LogRetentionInDays
  LambdaWriteLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '${Environment}-${ProjectId}-lambda-log-group-write-logs'
      RetentionInDays: !Ref LogRetentionInDays
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-lambda-ec2-stop-rl'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: !Sub '${Environment}-${ProjectId}-lambda-ec2-stop-pl'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [ ec2:DescribeInstances ]
                Resource: '*'
              - Effect: Allow
                Action: [ ec2:StopInstances ]
                Resource: !Sub 'arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/*'
                Condition:
                  StringNotEquals: { aws:ResourceTag/auto_stop: 'false' }
              - Effect: Allow
                Action: [ logs:CreateLogStream, logs:PutLogEvents ]
                Resource: [ !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${Environment}-${ProjectId}-lambda-log-group-write-logs:*' ]
  LambdaFunction:
    Type: AWS::Lambda::Function
    DependsOn: [ LambdaWriteLogGroup ]
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-lambda-ec2-stop'
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      LoggingConfig:
        LogFormat: Text
        LogGroup: !Sub '${Environment}-${ProjectId}-lambda-log-group-write-logs'
      Code:
        ZipFile: |
          import boto3
          import gzip
          import json
          import base64
          import os
          import logging
          logger = logging.getLogger(__name__)
          logger.setLevel(os.environ.get("LOG_LEVEL", "INFO").upper())
          handler_stream = logging.StreamHandler()
          formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
          handler_stream.setFormatter(formatter)
          logger.handlers = [handler_stream]
          logger.propagate = False
          ec2 = boto3.client("ec2")
          def lambda_handler(event, context):
              environment = os.environ.get("ENVIRONMENT", "dev")
              project_id = os.environ.get("PROJECT_ID", "001")
              try:
                  payload = base64.b64decode(event["awslogs"]["data"])
                  data = json.loads(gzip.decompress(payload))
              except Exception as e:
                  logger.error(json.dumps({"msg":"CloudWatch Logs ペイロードのデコードに失敗","error":str(e)}))
                  return {"statusCode": 400, "body": "Bad payload"}
              for log_event in data.get("logEvents", []):
                  msg = log_event.get("message", "")
                  try:
                      obj = json.loads(msg)
                  except json.JSONDecodeError:
                      logger.warning(json.dumps({"msg":"JSONメッセージのパースに失敗","raw":msg}))
                      continue
                  if obj.get("event") not in ("no-active-sessions"):
                      logger.debug(json.dumps({"msg":"対象外イベントのため無視","event":obj.get("event")}))
                      continue
                  instance_id = obj.get("instanceId")
                  if not instance_id:
                      logger.warning(json.dumps({"msg":"イベントに instanceId が含まれていません","event":obj}))
                      continue
                  try:
                      r = ec2.describe_instances(InstanceIds=[instance_id])
                  except Exception as e:
                      logger.error(json.dumps({"msg":"DescribeInstances の呼び出しに失敗","instanceId":instance_id,"error":str(e)}))
                      continue
                  reservations = r.get("Reservations", [])
                  if not reservations or not reservations[0].get("Instances"):
                      logger.warning(json.dumps({"msg":"インスタンスが見つかりません","instanceId":instance_id}))
                      continue
                  inst = reservations[0]["Instances"][0]
                  tags = {t.get("Key",""): t.get("Value","") for t in inst.get("Tags", [])}
                  val = (tags.get("auto_stop") or "").strip().lower()
                  if val == "false":
                      logger.info(json.dumps({"msg":"停止をスキップ: auto_stop=false","instanceId":instance_id}))
                      continue
                  state = inst.get("State", {}).get("Name")
                  if state != "running":
                      logger.info(json.dumps({"msg":"停止をスキップ: running ではない","instanceId":instance_id,"state":state}))
                      continue
                  try:
                      ec2.stop_instances(InstanceIds=[instance_id])
                      logger.info(json.dumps({"msg":"インスタンスを停止しました","instanceId":instance_id}))
                  except Exception as e:
                      logger.error(json.dumps({"msg":"StopInstances の呼び出しに失敗","instanceId":instance_id,"error":str(e)}))
              return {"statusCode": 200, "body": "Processed"}
  LambdaInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref LambdaFunction
      Action: lambda:InvokeFunction
      Principal: logs.amazonaws.com
      SourceArn: !GetAtt EC2WriteLogGroup.Arn
  LogSubscriptionFilter:
    Type: AWS::Logs::SubscriptionFilter
    DependsOn:
      - LambdaInvokePermission
      - LambdaFunction
      - EC2WriteLogGroup
    Properties:
      LogGroupName: !Sub '${Environment}-${ProjectId}-ec2-log-group-write-logs'
      FilterName: !Sub '${Environment}-${ProjectId}-ec2-log-group-write-logs-filter'
      FilterPattern: '{ $.event = "no-active-sessions" }'
      DestinationArn: !GetAtt LambdaFunction.Arn
Outputs:
  PublicSubnetId:
    Value: !Ref PublicSubnet
    Export:
      Name: !Sub '${Environment}-${ProjectId}-auto-stop-public-subnet-id'
  SecurityGroupId:
    Value: !Ref EC2SecurityGroup
    Export:
      Name: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-sg-id'
  InstanceProfileName:
    Value: !Ref EC2InstanceProfile
    Export:
      Name: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-instance-profile-name'
  KeyPairName:
    Value: !Ref EC2KeyPair
    Export:
      Name: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-keypair-name'

ファイルを保存しましょう。

1-2. AWS CloudFormationコンソールを開く

  1. AWS マネジメントコンソールにログイン
  2. サービスメニューから「AWS CloudFormation」を選択
  3. 「スタックの作成」→「新しいリソースを使用(標準)」を選択

1-3. テンプレートのアップロード

  1. 「テンプレートの準備完了」を選択
  2. 「テンプレートファイルのアップロード」を選択
  3. 作成したAWS CloudFormationテンプレートファイルを選んでアップロード
  4. 「次へ」を選択

1-4. スタック詳細の指定

スタック名: ec2-auto-stop-base-stack

パラメーターはこんな感じで設定してください。

パラメーター名 推奨値 説明
Environment dev 環境識別子
ProjectId 001 プロジェクトID
VpcCidr 10.0.0.0/16 VPCのCIDRブロック
PublicSubnetCidr 10.0.1.0/24 パブリックサブネットのCIDR
LogRetentionInDays 7 ログ保持期間(日数)

「次へ」を選択しましょう。

1-5. スタックオプションの設定

  1. 「AWS CloudFormationによってAWS IAMリソースが作成される場合があることを承認します」にチェックを入れ、「次へ」を選択

1-6. 確認して作成

  1. 設定内容をざっと確認し、「送信」を選択

1-7. デプロイ完了の確認

スタックのステータスが「CREATE_COMPLETE」になるまで、待ちましょう。(約3~5分かかります)


ステップ2: EC2インスタンススタックのデプロイ

次は、監視対象となるEC2インスタンスを作っていきます。

EC2インスタンススタックのデプロイ-20260205-002.png

2-1. AWS CloudFormationテンプレートファイルの作成

ローカルPCでec2-auto-stop-instance-stack.yamlという名前のファイルを作って、以下の内容をコピー&ペーストしてください。

ここを押すと展開します

AWSTemplateFormatVersion: '2010-09-09'
Description: 'EC2 instance stack'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  EC2ProjectId:
    Type: String
    Default: '001'
  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues: [ t3.micro, t3.small, t3.medium, t3.large ]
  ImageId:
    Type: String
    Default: ami-03d1820163e6b9f5d
    AllowedPattern: "^ami-[0-9a-f]{17}$"
  EbsVolumeSize:
    Type: Number
    Default: 8
    MinValue: 8
    MaxValue: 50
  AutoStopTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true','false']
  IdleMinutes:
    Type: Number
    Default: 30
    Description: 'Idle detection window in minutes. If no activity occurs for this period, the stop event is considered by Lambda.'
    MinValue: 10
    MaxValue: 120
  DebounceMinutes:
    Type: Number
    Default: 5
    Description: 'Debounce interval in minutes to suppress duplicate notifications. If a notification was sent within this interval, subsequent notifications are suppressed.'
    MinValue: 5
    MaxValue: 110
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - EC2ProjectId
      -
        Label:
          default: EC2 Configuration
        Parameters:
          - InstanceType
          - ImageId
          - EbsVolumeSize
          - AutoStopTag
      -
        Label:
          default: Tool Configuration
        Parameters:
          - IdleMinutes
          - DebounceMinutes
Resources:
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ImageId
      InstanceType: !Ref InstanceType
      KeyName:
        Fn::ImportValue: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-keypair-name'
      IamInstanceProfile:
        Fn::ImportValue: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-instance-profile-name'
      NetworkInterfaces:
        - AssociatePublicIpAddress: true
          DeviceIndex: 0
          GroupSet:
            - Fn::ImportValue: !Sub '${Environment}-${ProjectId}-auto-stop-ec2-sg-id'
          SubnetId:
            Fn::ImportValue: !Sub '${Environment}-${ProjectId}-auto-stop-public-subnet-id'
      BlockDeviceMappings:
        - DeviceName: /dev/sda1
          Ebs:
            VolumeSize: !Ref EbsVolumeSize
            VolumeType: gp3
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          set -euo pipefail
          exec > >(tee /var/log/user-data.log) 2>&1
          echo "=== UserData script started: $(date) ==="
          # CloudFormation parameters to shell variables
          IDLE_MINUTES=${IdleMinutes}
          DEBOUNCE_MINUTES=${DebounceMinutes}
          LOG_GROUP_NAME=${Environment}-${ProjectId}-ec2-log-group-write-logs
          # Create dedicated environment file for systemd services
          cat > /etc/default/check_connections.conf <<ENV_EOF
          AUTO_STOP_IDLE_MINUTES=${!IDLE_MINUTES}
          AUTO_STOP_DEBOUNCE_MINUTES=${!DEBOUNCE_MINUTES}
          AUTO_STOP_LOG_GROUP_NAME=${!LOG_GROUP_NAME}
          ENV_EOF
          chmod 644 /etc/default/check_connections.conf
          echo "Environment file created: /etc/default/check_connections.conf"
          echo "AUTO_STOP_IDLE_MINUTES=$(grep '^AUTO_STOP_IDLE_MINUTES=' /etc/default/check_connections.conf | cut -d '=' -f2)"
          echo "AUTO_STOP_DEBOUNCE_MINUTES=$(grep '^AUTO_STOP_DEBOUNCE_MINUTES=' /etc/default/check_connections.conf | cut -d '=' -f2)"
          echo "AUTO_STOP_LOG_GROUP_NAME=$(grep '^AUTO_STOP_LOG_GROUP_NAME=' /etc/default/check_connections.conf | cut -d '=' -f2)"
          echo "=== UserData script completed: $(date) ==="
      Tags:
        - Key: Name
          Value: !Sub '${Environment}-${EC2ProjectId}-linux-auto-stop-sv'
        - Key: Environment
          Value: !Ref Environment
        - Key: ProjectId
          Value: !Ref ProjectId
        - Key: auto_stop
          Value: !Ref AutoStopTag

ファイルを保存しましょう。

2-2. 新しいスタックの作成とデプロイ

ステップ1: 基盤スタックのデプロイ の手順を参考に、リソースをデプロイします。

スタック名: ec2-auto-stop-instance-stack

パラメーターはこんな感じで設定してください。

パラメーター名 推奨値 説明
Environment dev 環境識別子(基盤スタックと同じにしてください)
ProjectId 001 プロジェクトID(基盤スタックと同じにしてください)
EC2ProjectId 001 EC2固有のプロジェクトID
InstanceType t3.micro インスタンスタイプ
ImageId ami-03d1820163e6b9f5d Amazon Linux 2023のAMI ID
EbsVolumeSize 8 EBSボリュームサイズ(GB)
AutoStopTag true 自動停止を有効化
IdleMinutes 30 アイドル検知の閾値(分)
DebounceMinutes 5 重複通知抑制の間隔(分)

注意: ImageIdはリージョンごとにことなります。Amazon EC2 コンソールを使用した AL2023 の起動の手順を参照し、最新のAMI IDに更新してください。


ステップ3: Session Managerでインスタンスに接続

全体構成図-20260205-002.png

3-1. EC2インスタンスの確認

  1. AWS マネジメントコンソールで「Amazon EC2」を開く
  2. 左ペインから「インスタンス」を選択
  3. dev-001-linux-auto-stop-svという名前のインスタンスを選択

補足: ステップ2: EC2インスタンススタックのデプロイの直後は、EC2が自動で起動します。もし、「インスタンスの状態」が「停止済み」の場合は、EC2インスタンスを選択後、「インスタンスの状態 > インスタンスを開始」を選択し、EC2を起動させます。

3-2. Session Managerで接続

  1. 「インスタンスの状態」が「実行中」、「ステータスチェック」が「3/3 のチェックに合格しました」となるまで待つ
  2. 「接続」ボタンを選択
  3. 「SSM Session Manager」タブにて「接続」ボタンを選択

ブラウザー上でターミナルセッションが開きます。


ステップ4: 監視スクリプトの作成

さあ、ここからが面白いところです。監視スクリプトを作っていきましょう。

4-1. 設定ファイルの確認

まずは、AWS CloudFormationで自動作成された設定ファイルを確認してみましょう。

sudo cat /etc/default/check_connections.conf

出力例

AUTO_STOP_IDLE_MINUTES=30
AUTO_STOP_DEBOUNCE_MINUTES=5
AUTO_STOP_LOG_GROUP_NAME=dev-001-ec2-log-group-write-logs

補足: check_connections.confには、スタックのパラメーター「アイドル検知の閾値(分)」「重複通知抑制の間隔(分)」および、Amazon CloudWatch Logs のロググループ名が格納されます。この情報は後続のセットアップで利用します。

4-2. 監視スクリプトの作成

それでは、viエディターでスクリプトファイルを作っていきましょう。

sudo vi /usr/local/bin/check_connections.sh

iキーを押して挿入モードに入ったら、以下のスクリプトをコピー&ペーストしてください。

ここを押すと展開します

#!/usr/bin/env bash
#------------------------------------------------------------------------------
# スクリプト名: check_connections
#
# 目的:
# - SSH(TCP 22)および Session Managerのアクティブな接続を確認します。
# - アクティブなセッションが検出されず、指定したアイドル時間の閾値を超えた場合に、
#   インスタンスIDと可読時刻のみを含むJSONを指定のログディレクトリへ追記します。
# - 通知の重複を避けるため、デバウンス間隔(分)を使用して過剰通知を抑制します。
#
# 使い方:
#   check_connections    [DEBOUNCE_MINUTES]
#
# リターンコード規約:
#   0   : 正常終了(通知あり)
#   2   : 正常終了(通知不要:セッションがアクティブ、またはアイドル閾値未到達)
#   3   : 正常終了(通知不要:デバウンス期間内のため抑制)
#   4   : 正常終了(初期化のみ実施:初回実行で最終アクティブ時刻を書き込み)
#   501 : エラー(IMDSv2 トークン取得失敗)
#   502 : エラー(IMDSv2 インスタンスID取得失敗)
#   500+: エラー(その他の致命的エラー)
#------------------------------------------------------------------------------
set -euo pipefail
fatal() {
  echo "FATAL: $*" >&2
  exit 500
}
# 引数の解析
IDLE_MINUTES="${1:-}"
STATE_DIR="${2:-}"
LOG_DIR="${3:-}"
DEBOUNCE_MINUTES="${4:-5}"
[[ -n "$IDLE_MINUTES" && -n "$STATE_DIR" && -n "$LOG_DIR" ]] || fatal "Usage: $0    [DEBOUNCE_MINUTES]"
([[ "$IDLE_MINUTES" =~ ^[0-9]+$ ]] && [[ "$IDLE_MINUTES" -gt 0 ]]) || fatal "IDLE_MINUTES は分を表す正の整数で指定してください。"
([[ "$DEBOUNCE_MINUTES" =~ ^[0-9]+$ ]]) || fatal "DEBOUNCE_MINUTES は分を表す0以上の整数で指定してください。"
# 設定値
LOG_FILENAME="notify.log"
EVENT_NAME="no-active-sessions"
SSH_PORT="22"
TZ_REGION="Asia/Tokyo"
READABLE_TIME_FORMAT="%Y-%m-%d %H:%M:%S"
# パス導出
LOG_FILE="${LOG_DIR%/}/${LOG_FILENAME}"
LAST_ACTIVE_FILE="${STATE_DIR%/}/last_active_epoch"
LAST_ACTIVE_FILE_SEC="${STATE_DIR%/}/last_active_epoch_sec"
MARK="${STATE_DIR%/}/last_notified_epoch"
mkdir -p "$STATE_DIR" "$LOG_DIR" || fatal "failed to create directories: $STATE_DIR, $LOG_DIR"
# IMDSv2: インスタンスID取得
if ! TOKEN=$(curl -sS --fail -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"); then
  echo "ERROR: failed to get IMDSv2 token" >&2
  exit 501
fi
if ! INSTANCE_ID=$(curl -sS --fail "http://169.254.169.254/latest/meta-data/instance-id" \
  -H "X-aws-ec2-metadata-token: $TOKEN"); then
  echo "ERROR: failed to get instance-id via IMDSv2" >&2
  exit 502
fi
# アクティブセッションの判定
has_active_sessions() {
  if ss -tn state established "( dport = :$SSH_PORT or sport = :$SSH_PORT )" 2>/dev/null | grep -q ESTAB; then
    return 0
  fi
  if pgrep -f "ssm-session-worker" > /dev/null 2>&1; then
    return 0
  fi
  return 1
}
# 現在時刻の取得
NOW_EPOCH=$(date +%s)
NOW_READABLE=$(TZ="$TZ_REGION" date +"$READABLE_TIME_FORMAT")
# アクティブなら最終アクティブ時刻を更新
if has_active_sessions; then
  echo "$NOW_READABLE" > "$LAST_ACTIVE_FILE" || fatal "failed to write $LAST_ACTIVE_FILE"
  echo "$NOW_EPOCH"    > "$LAST_ACTIVE_FILE_SEC" || fatal "failed to write $LAST_ACTIVE_FILE_SEC"
  exit 2
fi
# 初回実行時の初期化
if [[ ! -f "$LAST_ACTIVE_FILE_SEC" ]]; then
  echo "$NOW_READABLE" > "$LAST_ACTIVE_FILE" || fatal "failed to write $LAST_ACTIVE_FILE"
  echo "$NOW_EPOCH"    > "$LAST_ACTIVE_FILE_SEC" || fatal "failed to write $LAST_ACTIVE_FILE_SEC"
  exit 4
fi
# アイドル時間の計算
LAST_ACTIVE_EPOCH="$(cat "$LAST_ACTIVE_FILE_SEC")" || fatal "failed to read $LAST_ACTIVE_FILE_SEC"
IDLE_SECONDS=$(( NOW_EPOCH - LAST_ACTIVE_EPOCH ))
THRESHOLD_SECONDS=$(( IDLE_MINUTES * 60 ))
if [[ "$IDLE_SECONDS" -lt "$THRESHOLD_SECONDS" ]]; then
  exit 2
fi
# デバウンス抑制
DEBOUNCE_SECONDS=$(( DEBOUNCE_MINUTES * 60 ))
if [[ -f "$MARK" ]]; then
  LAST_NOTIFIED="$(cat "$MARK")" || fatal "failed to read $MARK"
  if (( NOW_EPOCH - LAST_NOTIFIED < DEBOUNCE_SECONDS )); then
    exit 3
  fi
fi
# 通知(JSONログ出力)
echo "{\"event\":\"$EVENT_NAME\",\"instanceId\":\"$INSTANCE_ID\",\"readableTime\":\"$NOW_READABLE\"}" \
  | tee -a "$LOG_FILE" >/dev/null || fatal "failed to append $LOG_FILE"
echo "$NOW_EPOCH" > "$MARK" || fatal "failed to write $MARK"
exit 0

Escキーを押して、:wqと入力して保存しましょう。

4-3. スクリプトの確認と実行権限の付与

スクリプトがちゃんと保存されたか確認して、実行権限をつけてあげます。

# スクリプトの内容を確認
sudo cat /usr/local/bin/check_connections.sh
# 実行権限を付与
sudo chmod +x /usr/local/bin/check_connections.sh

ステップ5: スクリプトの動作テスト

本番で使う前に、スクリプトがちゃんと動くかテストしてみましょう。

5-1. テスト環境の準備

まずはテスト用のディレクトリを準備します。

# テストディレクトリの初期化
sudo rm -rf /tmp/check_sessions_test /tmp/check_connections_test
sudo mkdir -p /tmp/check_sessions_test /tmp/check_connections_test
# バックアップを作成
sudo cp -a /usr/local/bin/check_connections.sh /usr/local/bin/check_connections.sh.bak

5-2. テスト用にスクリプトを一時修正

なぜ修正するの?
このスクリプトは本来、SSHとSession Managerの両方の接続を検知します。でも、テスト中はSession Managerで接続したままスクリプトを実行したいので、Session Manager検知部分だけをコメントアウトして、疑似的に「接続なし」の状況を作ります。こうすることで、接続したままテストができるんです。

それでは修正してみましょう。

sudo vi /usr/local/bin/check_connections.sh

iキーを押して、以下の部分に「#」を追加し、コメントアウトします。

修正前

# アクティブセッションの判定
has_active_sessions() {
  if ss -tn state established "( dport = :$SSH_PORT or sport = :$SSH_PORT )" 2>/dev/null | grep -q ESTAB; then
    return 0
  fi
  if pgrep -f "ssm-session-worker" > /dev/null 2>&1; then
    return 0
  fi
  return 1
}

修正後

 アクティブセッションの判定
has_active_sessions() {
  if ss -tn state established "( dport = :$SSH_PORT or sport = :$SSH_PORT )" 2>/dev/null | grep -q ESTAB; then
    return 0
  fi
#  if pgrep -f "ssm-session-worker" > /dev/null 2>&1; then
#    return 0
#  fi
  return 1
}

Escキーを押して、:wqと入力して保存しましょう。

5-3. テストケース1: 初期化(終了コード4)

何をテストするの?: スクリプトの初回実行時に、状態ファイルがちゃんと作られるかチェックします。

どういうシナリオ?: 状態ファイル(last_active_epoch_sec)がない状態でスクリプトを実行すると、現在時刻を記録して初期化してくれます。これは「初回起動時の基準時刻設定」です。

さっそく試してみましょう。

# 初期状態を作成(ファイル削除)
sudo rm -f /tmp/check_sessions_test/last_active_epoch_sec
# 実行
sudo /usr/local/bin/check_connections.sh 1 /tmp/check_sessions_test /tmp/check_connections_test
echo "終了コード: $?"
# 状態ファイルの確認
sudo cat /tmp/check_sessions_test/last_active_epoch
sudo cat /tmp/check_sessions_test/last_active_epoch_sec

処理フロー

テストケース1-20260205-001.png

  • 初回実行時は通知を行わず、次回以降の判定基準となる時刻を記録するだけです。

期待される結果

  • 終了コード 4(初期化完了)
  • last_active_epochファイルに現在時刻(読みやすい形式)が記録される
  • last_active_epoch_secファイルに現在時刻(epoch秒)が記録される

5-4. テストケース2: 閾値未到達(終了コード2)

何をテストするの?: アイドル時間が閾値に達していない場合、通知が行われないことを確認します。

どういうシナリオ?: 最終アクティブ時刻を「現在時刻」に設定して、アイドル閾値を10分に設定します。この場合、アイドル時間は0分なので、閾値(10分)に達していないため、通知は行われません。

やってみましょう。

# 現在時刻をセット
echo "$(date +%s)" | sudo tee /tmp/check_sessions_test/last_active_epoch_sec >/dev/null
# 実行(閾値を10分に設定)
sudo /usr/local/bin/check_connections.sh 10 /tmp/check_sessions_test /tmp/check_connections_test
echo "終了コード: $?"

処理フロー

テストケース2-20260205-001.png

  • 最終アクティブ時刻: 現在時刻
  • 現在時刻: 実行時の時刻
  • アイドル時間: 0分
  • 閾値: 10分
  • 判定: 0分 < 10分 → 通知不要

期待される結果

  • 終了コード 2(通知不要)
  • notify.logにログが出力されない

5-5. テストケース3: デバウンス抑制(終了コード3)

何をテストするの?: 短時間に重複した通知が送信されないよう、デバウンス機能がちゃんと動くか確認します。

どういうシナリオ?: 最終アクティブ時刻を1時間前に設定して(アイドル閾値を超える)、最終通知時刻を現在時刻に設定します。この場合、アイドル閾値は超えていますが、直近で通知済みなので、デバウンス機能により通知が抑制されます。

デバウンスの意義: 同じインスタンスに対して短時間に何度も停止命令が送られるのを防ぎます。

試してみましょう。

# 1時間前をセット
echo "$(( $(date +%s) - 3600 ))" | sudo tee /tmp/check_sessions_test/last_active_epoch_sec >/dev/null
# 直近通知時刻を現在時刻に設定
echo "$(date +%s)" | sudo tee /tmp/check_sessions_test/last_notified_epoch >/dev/null
# 実行
sudo /usr/local/bin/check_connections.sh 1 /tmp/check_sessions_test /tmp/check_connections_test
echo "終了コード: $?"

処理フロー

テストケース3-20260205-001.png

  • 最終アクティブ時刻: 1時間前
  • アイドル時間: 60分(閾値1分を超過)
  • 最終通知時刻: 現在時刻
  • デバウンス間隔: 5分(デフォルト)
  • 判定: 前回通知から5分未満 → 通知抑制

期待される結果

  • 終了コード 3(デバウンス抑制)
  • notify.logにログが出力されない

5-6. テストケース4: 通知あり(終了コード0)

何をテストするの?: すべての条件を満たした場合に、ちゃんと通知が行われるか確認します。

どういうシナリオ?: 最終アクティブ時刻を1時間前に設定して(アイドル閾値を超える)、デバウンスマークを削除します(前回通知なし)。この場合、アイドル閾値を超えていて、デバウンス抑制もないので、通知が行われます。

さあ、やってみましょう。

# 1時間前をセット
echo "$(( $(date +%s) - 3600 ))" | sudo tee /tmp/check_sessions_test/last_active_epoch_sec >/dev/null
# デバウンスマーク削除
sudo rm -f /tmp/check_sessions_test/last_notified_epoch
# 実行
sudo /usr/local/bin/check_connections.sh 1 /tmp/check_sessions_test /tmp/check_connections_test
echo "終了コード: $?"
# ログファイルの確認
sudo cat /tmp/check_connections_test/notify.log

処理フロー

テストケース4-20260205-001.png

  • 最終アクティブ時刻: 1時間前
  • アイドル時間: 60分(閾値1分を超過)
  • デバウンスマーク: なし
  • 判定: アイドル閾値超過 & デバウンス期間外 → 通知実行

期待される結果

  • 終了コード 0(通知あり)
  • notify.logに以下のようなJSON出力
{"event":"no-active-sessions","instanceId":"i-xxxxxxxxxxxxxxxxx","readableTime":"YYYY-MM-DD hh:mm:ss"}

JSONフィールドの意味

  • event: イベント種別(no-active-sessions = アクティブセッションなし)
  • instanceId: 対象のEC2インスタンスID(IMDSv2から自動取得)
  • readableTime: 通知時刻(Asia/Tokyoタイムゾーン)

このJSONがAmazon CloudWatch Logsに送信されて、AWS Lambda関数がトリガーされてEC2停止処理が実行されます。

5-7. テスト環境のクリーンアップ

テストが終わったら、お掃除しておきましょう。

# テストディレクトリの削除
sudo rm -rf /tmp/check_sessions_test /tmp/check_connections_test

ステップ6: systemdサービスとタイマーの設定

さあ、スクリプトを定期的に動かす仕組みを作っていきましょう。

6-1. サービスユニットの作成

まずはサービスユニットを作ります。

sudo vi /etc/systemd/system/check_connections.service

iキーを押して、以下の内容を入力してください。

[Unit]
Description=Run check_connections.sh to detect no-active-sessions
[Service]
Type=oneshot
# 環境ファイルから読み込み
EnvironmentFile=/etc/default/check_connections.conf
# 固定環境
Environment=STATE_DIR=/var/lib/check_sessions
Environment=LOG_DIR=/var/log/check_connections
# 終了コード 0, 2, 3, 4 を成功として扱う
# 0: 通知あり, 2: 通知不要(アクティブまたは閾値未到達)
# 3: 通知不要(デバウンス抑制), 4: 初期化完了
SuccessExitStatus=0 2 3 4
# スクリプト実行
ExecStart=/usr/local/bin/check_connections.sh \
  ${AUTO_STOP_IDLE_MINUTES} \
  ${STATE_DIR} \
  ${LOG_DIR} \
  ${AUTO_STOP_DEBOUNCE_MINUTES}

Escキーを押して、:wqと入力して保存しましょう。

6-2. サービスユニットの確認

ちゃんと保存されたか確認してみます。

sudo cat /etc/systemd/system/check_connections.service

6-3. タイマーユニットの作成

次はタイマーユニットを作ります。

sudo vi /etc/systemd/system/check_connections.timer

iキーを押して、以下の内容を入力してください。

[Unit]
Description=Run check_connections.service every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target

Escキーを押して、:wqと入力して保存しましょう。

6-4. タイマーユニットの確認

こちらも確認してみます。

sudo cat /etc/systemd/system/check_connections.timer

6-5. systemdへの反映と有効化

systemdに新しいサービスとタイマーを認識させて、自動起動を有効化しましょう。

# systemdに反映
sudo systemctl daemon-reload
# タイマー有効化・起動
sudo systemctl enable check_connections.timer
sudo systemctl start check_connections.timer
# 動作確認
sudo systemctl status check_connections.timer --no-pager
sudo systemctl status check_connections.service --no-pager || true

各コマンドの説明

コマンド 説明
daemon-reload systemdに新しいユニットファイルを読み込み
enable システム起動時にタイマーが自動起動するよう設定
start タイマーを今すぐ起動
status 現在の状態を確認

check_connections.timerのステータス確認

タイマーが正常に起動すると、こんな感じの出力が表示されます。

● check_connections.timer - Run check_connections.service every 5 minutes
     Loaded: loaded (/etc/systemd/system/check_connections.timer; enabled; preset: disabled)
     Active: active (waiting) since Tue 2026-02-03 07:30:00 UTC; 2min ago
    Trigger: Tue 2026-02-03 07:35:00 UTC; 2min 30s left
   Triggers: ● check_connections.service

確認ポイント

  • Active: active (waiting) - タイマーが待機中(正常です)

check_connections.serviceのステータス確認

サービスの実行状況も確認できます。出力内容はスクリプトの終了コードによって変わります。

出力例(終了コード2の場合)

○ check_connections.service - Run check_connections.sh to detect no-active-sessions
     Loaded: loaded (/etc/systemd/system/check_connections.service; static)
     Active: inactive (dead) since Tue 2026-02-03 07:50:12 UTC; 32s ago
TriggeredBy: ● check_connections.timer
    Process: 3865 ExecStart=/usr/local/bin/check_connections.sh ${AUTO_STOP_IDLE_MINUTES} ${STATE_DIR} ${LOG_DIR} ${AUTO_STOP_DEBOUNCE_MINUTES} (code=exited, status=2)
   Main PID: 3865 (code=exited, status=2)
        CPU: 24ms
Feb 03 07:50:12 ip-10-0-1-47.ap-northeast-1.compute.internal systemd[1]: Starting check_connections.service - Run check_connections.sh to detect no-active-sessions...
Feb 03 07:50:12 ip-10-0-1-47.ap-northeast-1.compute.internal systemd[1]: check_connections.service: Deactivated successfully.
Feb 03 07:50:12 ip-10-0-1-47.ap-northeast-1.compute.internal systemd[1]: Finished check_connections.service - Run check_connections.sh to detect no-active-sessions.

確認ポイント

  • Active: inactive (dead) - Type=oneshotのサービスは実行完了後に停止状態になります(これが正常です)

終了コードによる出力パターン

終了コード 意味
0 通知あり(EC2停止イベント発生)
2 通知不要(アクティブまたは閾値未到達)
3 通知不要(デバウンス抑制)
4 初期化完了(初回実行時)

6-6. 手動実行テスト

きちんと動作するか、手動で試してみましょう。

# サービスを手動実行
sudo systemctl start check_connections.service
# 次回のタイマー実行時間(UTC表示)を確認
systemctl list-timers | grep check_connections

ステップ7: CloudWatch Agentの設定

次は、ログをAmazon CloudWatchに送る設定をしていきましょう。

7-1. 設定ファイルの読み込み

まずは環境変数を読み込みます。

# 環境変数を読み込み
. /etc/default/check_connections.conf
# ロググループ名を変数に格納
LOG_GROUP_NAME="${AUTO_STOP_LOG_GROUP_NAME}"

7-2. CloudWatch Agentのインストール

CloudWatch Agentをインストールしましょう。

# CloudWatch AgentをインストールAmazon Linux 2023)
sudo dnf install -y amazon-cloudwatch-agent

7-3. CloudWatch Agent設定ファイルの作成

設定ファイルを作っていきます。

sudo tee /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json >/dev/null <<CW_EOF
{
  "logs": {
    "logs_collected": {
      "files": {
        "collect_list": [
          {
            "file_path": "/var/log/check_connections/notify.log",
            "log_group_name": "${AUTO_STOP_LOG_GROUP_NAME}",
            "log_stream_name": "check-connections-{instance_id}"
          }
        ]
      }
    }
  },
  "agent": {
    "region": "ap-northeast-1"
  }
}
CW_EOF

注意: regionは使っているAWSリージョンに合わせて変更してください。

7-4. タイマーの一時停止とログクリア

タイマーを一旦止めて、ログをクリアします。

# タイマーを停止
sudo systemctl stop check_connections.timer
# ログファイルを削除
sudo rm -f /var/log/check_connections/notify.log

7-5. CloudWatch Agentの起動

さあ、CloudWatch Agentを起動してみましょう。

# CloudWatch Agentを停止(既に起動している場合)
sudo systemctl stop amazon-cloudwatch-agent || true
# 設定を適用して起動
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config -m ec2 -s \
  -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
# ステータス確認
sudo systemctl status amazon-cloudwatch-agent --no-pager || true

確認ポイント

  • Active: active (running) - サービスが動作中(正常です)

ステップ8: 動作確認用の設定変更

テストのために、アイドル時間を短く設定してみましょう。

8-1. 設定ファイルのバックアップ

まずはバックアップを取っておきます。

sudo cp -a /etc/default/check_connections.conf /etc/default/check_connections.conf.bak

8-2. 設定の変更

設定を変更してみましょう。

sudo vi /etc/default/check_connections.conf

iキーを押して、以下のように変更します。

修正前

AUTO_STOP_IDLE_MINUTES=30
AUTO_STOP_DEBOUNCE_MINUTES=5

修正後

AUTO_STOP_IDLE_MINUTES=5
AUTO_STOP_DEBOUNCE_MINUTES=1

Escキーを押して、:wqと入力して保存しましょう。


5.動作確認

さあ、いよいよ実際に動かしてみましょう。

ステップ一覧

  1. 自動停止機能のテスト
  2. auto_stopタグによる制御テスト
  3. 本番設定への復元

ステップ1: 自動停止機能のテスト

処理フロー

  • OS内のフロー

自動停止機能のテスト-20260205-003.png

  • AWS上のフロー

自動停止機能のテスト-20260205-002.png

  • T=0分:EC2起動、Session Manager接続
  • T=5分:初回スクリプト実行(接続中なので最終アクティブ時刻を記録)
  • T=5~10分:Session Manager切断(アイドル状態開始)
  • T=10~15分:2回目のスクリプト実行(アイドル時間5分 = 閾値5分 → 停止判定)
  • T=10~15分+数秒:AWS Lambda実行、EC2停止開始

1-1. スクリプトをバックアップから復元

まずはスクリプトをバックアップから復元して、Session Manager検知を有効化します。

# バックアップから復元(Session Manager検知を有効化)
sudo cp -a /usr/local/bin/check_connections.sh.bak /usr/local/bin/check_connections.sh
自動停止機能のテスト-20260205-002.png
# systemdに反映
sudo systemctl daemon-reload

1-2. タイマーの起動

タイマーを起動してみましょう。

# タイマーを起動
sudo systemctl start check_connections.timer
# 次回の実行時間を確認
systemctl list-timers | grep check_connections

出力例

NEXT                        LEFT          LAST  PASSED  UNIT                       ACTIVATES
Mon YYYY-MM-DD hh:mm:ss JST 4min 30s left -     -       check_connections.timer    check_connections.service

1-3. Session Managerセッションを閉じる

  1. ブラウザーのターミナルセッションにて「終了」を押下
  2. 「セッションの終了」にて「終了」を押下

SSH接続している場合も切断してください。

注意: ターミナルセッションのブラウザのタブで閉じると、セッションが終了されません。その場合、セッションを終了するを実施して下さい。

期待される停止タイミング

  • EC2起動後、約10分で自動停止します

1-4. Amazon CloudWatch Logsでの確認

CloudWatchでも確認してみましょう。

  1. AWS マネジメントコンソールで「CloudWatch」を開く
  2. 左ペインから「ログ > Log Management」→「ロググループ」を選択
  3. dev-001-ec2-log-group-write-logsを開く
  4. 作成されたログストリーム(check-connections-i-xxxxxxxxx)を選択

約15分後 こんな感じのログイベントが追記されます。

{
    "event": "no-active-sessions",
    "instanceId": "i-xxxxxxxxxxxxxxxxx",
    "readableTime": "YYYY-MM-DD hh:mm:ss"
}

1-5. AWS Lambda関数のログ確認

  1. AWS マネジメントコンソールで「AWS Lambda」を開く
  2. dev-001-lambda-ec2-stop関数を選択
  3. 「モニタリング」タブを選択
  4. 「CloudWatch のログを表示」を選択
  5. 作成されたログストリーム(YYYY/MM/DD/dev-001-lambda-ec2-stop~)を選択

以下のようなログが出力されていることを確認します。

{
    "msg": "インスタンスを停止しました",
    "instanceId": "i-xxxxxxxxxxxxxxxxx"
}

1-6. EC2インスタンスの停止確認

AWS マネジメントコンソールで「Amazon EC2」を開いて、インスタンスのステータスが「停止済み」となっていることを確認しましょう。

ステップ2: auto_stopタグによる制御テスト

次は、タグで停止を制御できるか試してみましょう。

2-1. auto_stopタグをfalseに変更

タグを変更してみます。

  1. インスタンスを選択した状態で「タグ」タブを選択
  2. 「タグを管理」を選択
  3. auto_stopタグの値をtrueからfalseに変更
  4. 「変更を保存」を選択

2-2. EC2インスタンスの起動

まずはインスタンスを起動します。

  1. AWS マネジメントコンソールで「Amazon EC2」を開く
  2. dev-001-linux-auto-stop-svという名前のインスタンスを選択
  3. 「インスタンスの状態」→「インスタンスを開始」を選択
  4. 「インスタンスの状態」が「実行中」、「ステータスチェック」が「3/3 のチェックに合格しました」となるまで待つ

2-3. Amazon CloudWatch Logsでの確認

CloudWatchでも確認してみましょう。

  1. AWS マネジメントコンソールで「CloudWatch」を開く
  2. 左ペインから「ログ > Log Management」→「ロググループ」を選択
  3. dev-001-ec2-log-group-write-logsを開く
  4. 作成されたログストリーム(check-connections-i-xxxxxxxxx)を選択

約5分後 こんな感じのログイベントが追記されます。

{
    "event": "no-active-sessions",
    "instanceId": "i-xxxxxxxxxxxxxxxxx",
    "readableTime": "YYYY-MM-DD hh:mm:ss"
}

2-4. AWS Lambda関数のログ確認

AWS Lambda関数のログで、こんなメッセージが出力されていることを確認してください。

{
    "msg": "停止をスキップ: auto_stop=false",
    "instanceId": "i-xxxxxxxxxxxxxxxxx"
}

EC2インスタンスは停止されず、実行中のままです。タグで制御できています。

注意: auto_stopタグによる制御は、一時的な機能です。セッションが無い状態で、EC2を起動し続けると、notify.logが肥大化し、ディスク容量がひっ迫します。

2-5. auto_stopタグをtrueに戻す

テストが終わったら、タグを元に戻しておきましょう。

  1. AWS マネジメントコンソールで「Amazon EC2」を開く
  2. インスタンスを選択
  3. 「タグ」タブからauto_stopタグの値をfalseからtrueに変更
  4. 「変更を保存」を選択

完璧です。


ステップ3: 本番設定への復元

テストが完了したら、本番用の設定に戻しましょう。

3-1. 設定ファイルの復元

Session Managerで接続して、以下を実行してください。

# バックアップから復元
sudo cp -a /etc/default/check_connections.conf.bak /etc/default/check_connections.conf
# systemdに反映
sudo systemctl daemon-reload
sudo systemctl restart check_connections.timer

3-2. 設定内容の確認

ちゃんと戻っているか確認してみましょう。

sudo cat /etc/default/check_connections.conf

以下のように戻っていることを確認します。

AUTO_STOP_IDLE_MINUTES=30
AUTO_STOP_DEBOUNCE_MINUTES=5
AUTO_STOP_LOG_GROUP_NAME=dev-001-ec2-log-group-write-logs

6.環境削除手順

テストが完了したら、以下の手順で環境をきれいにお掃除しましょう。

ステップ一覧

  1. EC2インスタンスの停止
  2. AWS CloudFormationスタックの削除

ステップ1: EC2インスタンスの停止

まずはインスタンスを停止します。

  1. AWS マネジメントコンソールで「Amazon EC2」を開く
  2. インスタンスを選択
  3. 「インスタンスの状態」→「インスタンスを停止」を選択
  4. インスタンスが「停止済み」になるまで待つ

ステップ2: AWS CloudFormationスタックの削除

2-1. EC2スタックの削除

  1. AWS マネジメントコンソールで「AWS CloudFormation」を開く
  2. スタック名: ec2-auto-stop-instance-stackを選択
  3. 「削除」ボタンを選択
  4. 確認ダイアログで「削除」を選択
  5. ステータスが「DELETE_COMPLETE」になるまで待つ(約3~5分)

2-2. 基盤スタックの削除

  1. 2-1. EC2スタックの削除 の手順を参考に、スタック名: ec2-auto-stop-base-stackを削除します。

大事なポイント: スタックは必ず上記の順序で削除してください。EC2スタックが基盤スタックのリソースに依存しているので、逆順で削除するとエラーになります。


まとめ

お疲れさまでした。自動停止システム構築ハンズオンが完了しました。いかがでしたでしょうか。

このハンズオンでは、以下の技術を組み合わせて、コスト最適化のための自動停止システムを構築しました。

技術・サービス 役割
AWS CloudFormation インフラのコード化(VPC、AWS IAMロール、AWS Lambda関数など)
Bashスクリプト SSH/Session Manager接続の監視
systemd 定期実行の仕組み
Amazon CloudWatch Logs + AWS Lambda イベント駆動型の自動停止処理
タグベース制御 柔軟な停止対象の管理

このシステムにより、使用していないEC2インスタンスを自動的に停止することで、無駄なコストを削減できます。アイドル時間やデバウンス間隔は環境に合わせて調整可能で、auto_stopタグで簡単にオン・オフを切り替えられます。

ぜひ、いろいろ試してみてください。

プラスワンのハンズオン

さらに理解を深めたい方は、付録:Session Managerポートフォワーディング経由のSSH接続を参考に、Session Managerのポートフォワーディング機能を使ったSSH接続でも、監視スクリプトが正しく接続を検知して自動停止が動作するか確認してみてください。

付録:Session Managerポートフォワーディング経由のSSH接続

Session Managerのポートフォワーディング機能を使って、ローカルPCからSSH接続する方法を説明します。Visual Studio Codeでリモート開発したい方におすすめです。

ステップ一覧

  1. キーペアのダウンロード
  2. SSH Configファイルの設定
  3. Visual Studio Code Remote-SSH拡張機能のインストール
  4. Session Managerポートフォワーディングの開始
  5. Visual Studio Code Remote SSHで接続

ステップ1: キーペアのダウンロード

AWS CloudFormationで作成されたキーペアの秘密鍵を取得します。

1-1. キーペアIDの確認

  1. AWS マネジメントコンソールで「Amazon EC2」を開く
  2. 左ペインから「ネットワーク & セキュリティ」→「キーペア」を選択
  3. dev-001-ec2-keypairを検索して選択
  4. 「キーペアID」をコピー(key-xxxxxxxxxxxxxxxxxの形式)

1-2. AWS Systems Manager Parameter Storeから秘密鍵を取得

  1. AWS マネジメントコンソールで「AWS Systems Manager」を開く
  2. 左ペインから「パラメーターストア」を選択
  3. 先程、コピーしたキーペアIDで検索して選択
  4. 「復号化された値を表示」をオンにして、表示された値をコピー

1-3. 秘密鍵ファイルの保存

  1. エクスプローラーで「C:\Users\【ユーザー名】\.ssh」フォルダーを開く
  2. dev-001-ec2-keypair.pemという名前で新規ファイルを作成
  3. コピーした秘密鍵の内容を貼り付けて保存
  4. ファイルを右クリック→「プロパティ」→「読み取り専用」にチェック→「OK」

補足: .sshフォルダーがない場合は作成してください。


ステップ2: SSH Configファイルの設定

SSH接続の設定を記述します。

2-1. Configファイルの作成・編集

  1. エクスプローラーで「C:\Users\【ユーザー名】.ssh」フォルダーを開く
  2. configファイルがなければ作成(拡張子なし)
  3. テキストエディターで開いて、以下の内容を入力または追記

補足: 【ユーザー名】*の部分は実際のユーザー名に置き換えてください

Host dev-001-linux-auto-stop-sv
    HostName localhost
    User ec2-user
    Port 10022
    IdentityFile C:\Users\【ユーザー名】\.ssh\dev-001-ec2-keypair.pem
    IdentitiesOnly yes
    StrictHostKeyChecking no
    UserKnownHostsFile NUL

ステップ3: Visual Studio Code Remote-SSH拡張機能のインストール

Visual Studio CodeでSSH接続できるようにします。

  1. Visual Studio Codeを開く
  2. 左サイドバーの「拡張機能」アイコンを選択
  3. 検索ボックスにRemote - SSHと入力
  4. Microsoft公式の「Remote - SSH」拡張機能をインストール
  5. インストール完了後、VS Codeを再起動

ステップ4: Session Managerポートフォワーディングの開始

接続フロー

接続フロー-20260205-001.png

4-1. インスタンスIDの取得とポートフォワーディング開始

PowerShellを開いて、以下のコマンドを実行します。(このウィンドウは開いたままにしてください)

# インスタンスIDを取得
$INSTANCE_ID = aws ec2 describe-instances `
  --filters "Name=tag:Name,Values=dev-001-linux-auto-stop-sv" `
  --query "Reservations[0].Instances[0].InstanceId" `
  --output text `
  --region ap-northeast-1
echo "Instance ID: $INSTANCE_ID"
# ポートフォワーディング開始
aws ssm start-session `
  --target $INSTANCE_ID `
  --document-name AWS-StartPortForwardingSession `
  --parameters '{\"portNumber\":[\"22\"],\"localPortNumber\":[\"10022\"]}' `
  --region ap-northeast-1

成功すると以下のように表示されます

Instance ID: i-xxxxxxxxxxxxxxxxx
Starting session with SessionId: your-session-id
Port 10022 opened for sessionId your-session-id.
Waiting for connections...

大事なポイント: このPowerShellウィンドウは閉じないでください。ポートフォワーディングが動作し続けます。


ステップ5: Visual Studio Code Remote SSHで接続

5-1. Visual Studio Codeから接続

  1. Visual Studio Codeを開く
  2. F1キーを押してコマンドパレットを開く
  3. Remote-SSH: Connect to Host...と入力して選択
  4. dev-001-linux-auto-stop-svを選択
  5. 新しいウィンドウが開いて、接続が開始
  6. 初回「Select the platform~」と表示しますのでLinuxを選択
  7. 接続に成功すると、VS Codeの左下に「SSH: dev-001-linux-auto-stop-sv」と表示
  8. Ctrl + @キーを押下し、ターミナルを起動

接続の終了

1. Visual Studio Code接続の終了

  1. Visual Studio Codeの左下の「SSH: dev-001-linux-auto-stop-sv」を選択
  2. 「リモート接続を終了する」を選択

2. Session Managerポートフォワーディングの終了

  1. ポートフォワーディングを実行しているPowerShellウィンドウでCtrl + Cキーを押下

この記事は私が書きました

印鑰 幸太

記事一覧

全ての AWS 認定を取得。AWSサービスでは、AWS CloudFormationが好きです。ジム通いが趣味です。

印鑰 幸太

この記事を共有する

クラウドのご相談

CONTACT

クラウド導入や運用でお悩みの方は、お気軽にご相談ください。
専門家がサポートします。

サービス資料ダウンロード

DOWNLOAD

ビジネスをクラウドで加速させる準備はできていますか?
今すぐサービス資料をダウンロードして、詳細をご確認ください。