0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EventBridge + LambdaでMinecraft用サーバの利用料をEIPの分まで削減しよう

0
Posted at

はじめに

はじめてQiitaに記事を投稿します。
まだMarkdownにもあまり慣れていないので、執筆しながら少しずつ覚えていければと思っています。

今回は AWSでMinecraftサーバーを構築した際に、EIPの料金が気になったので自動で付け外しする仕組みを作った話です。

背景

勉強も兼ねてAWSアカウントを作成し、最初に EC2を使ったMinecraftサーバー を構築しました。

この構築自体は、AWS公式のブログに手順が公開されているため、その通りに進めれば比較的簡単に作成できます。

サーバーを構築後、Elastic IP(EIP)を付与して公開したところ、問題なくアクセスできました。

料金を見ていて気になったこと

サーバーの利用料金を計算していたときに、あることに気付きました。

「EIPの料金が地味に目に付く…」

具体的にはこんな感じです。

項目 月額(概算) 備考
EC2 $2.96 t4g.micro 9時間稼働/1日
EIP $3.75 解放等はせず常に利用料が発生

EC2より EIPの方が高い という状態になっていました。

もちろん高額ではないのですが、
「せっかく節約しているのにここはそのままなのか…」という気持ちになりました。

思いついた構成

EC2は EventBridge Scheduler を使えば自動起動・停止ができます。

そこで次のようなことを考えました。

EC2起動時にEIPを付与し、停止時に解放すればいいのでは?

つまり

  • EC2起動
    → EIP取得 & アタッチ

  • EC2停止
    → EIP解除 & 解放

という構成です。

Claudeニキに相談したところ、問題なく実現できそうだったので CloudFormationで構築してみました。


構成図

MinecraftServer自動起動停止.drawio.png

今回の構成は以下のようになっています。

EC2の起動停止

EventBridge SchedulerでEC2を自動起動・停止します。

  • 起動:18:00
  • 停止:03:00
    (JST)

EC2状態をトリガーにLambda実行

EventBridge Rulesを使い、以下のイベントを検知します。

  • EC2が起動 → EIPを付与するLambda
  • EC2が停止 → EIPを解放するLambda

これで EIPの取得・関連付け・解放が自動化できます。


IPアドレスの通知

EIPは 取得するたびにIPが変わる ため、現在のIPをユーザーに通知する必要があります。

今回は実装が簡単な方法として

SNS → メール通知

を使用しました。


構築

上記の構成をClaudeニキに説明したところ、CloudFormationのテンプレートを生成してくれました。

いつもお世話になっています。

CloudFormationテンプレート(クリックで展開)
code.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: >
  EC2の定期起動・停止に合わせてEIPを自動で取得/関連付け/解放するテンプレート。
  EventBridge Scheduler でEC2を制御し、状態変化をトリガーにLambdaがEIPを管理します。
  処理結果はSNS経由でメール通知されます。

# -----------------------------------------------
# パラメータ
# -----------------------------------------------
Parameters:
  EC2InstanceId:
    Type: String
    Default: "i-XXXXXXXXXXXXXXXX"
    Description: 対象のEC2インスタンスID

  NotificationEmail:
    Type: String
    Default: "XXXXXXXXX@example.com"
    Description: SNS通知先のメールアドレス

  StartSchedule:
    Type: String
    Default: "cron(0 18 * * ? *)"
    Description: EC2起動スケジュール (JST) 毎日18:00 JST

  StopSchedule:
    Type: String
    Default: "cron(0 3 * * ? *)"
    Description: EC2停止スケジュール (JST) 毎日03:00 JST

# -----------------------------------------------
# リソース
# -----------------------------------------------
Resources:

  # -----------------------------------------------
  # SNS トピック & メール通知サブスクリプション
  # -----------------------------------------------
  NotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub "${AWS::StackName}-ec2-eip-notification"
      DisplayName: EC2/EIP自動管理通知

  NotificationSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref NotificationTopic
      Protocol: email
      Endpoint: !Ref NotificationEmail

  # -----------------------------------------------
  # Lambda 実行ロール
  # -----------------------------------------------
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-lambda-role"
      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: EIPManagePolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:AllocateAddress
                  - ec2:AssociateAddress
                  - ec2:DisassociateAddress
                  - ec2:ReleaseAddress
                  - ec2:DescribeAddresses
                  - ec2:DescribeInstances
                Resource: "*"
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !Ref NotificationTopic

  # -----------------------------------------------
  # Lambda関数: EC2起動時 → EIP取得&関連付け
  # -----------------------------------------------
  AssociateEIPFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-associate-eip"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 60
      Environment:
        Variables:
          INSTANCE_ID: !Ref EC2InstanceId
          SNS_TOPIC_ARN: !Ref NotificationTopic
      Code:
        ZipFile: |
          import boto3
          import os
          import json

          ec2 = boto3.client('ec2')
          sns = boto3.client('sns')

          INSTANCE_ID = os.environ['INSTANCE_ID']
          SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']

          def handler(event, context):
              print(f"Event: {json.dumps(event)}")

              # イベントからインスタンスIDを取得(EventBridgeからの場合)
              instance_id = event.get('detail', {}).get('instance-id', INSTANCE_ID)

              try:
                  # EIPを新規割り当て
                  alloc = ec2.allocate_address(Domain='vpc')
                  allocation_id = alloc['AllocationId']
                  public_ip = alloc['PublicIp']
                  print(f"EIP割り当て完了: {public_ip} (AllocationId: {allocation_id})")

                  # EC2に関連付け
                  assoc = ec2.associate_address(
                      InstanceId=instance_id,
                      AllocationId=allocation_id
                  )
                  print(f"EIP関連付け完了: {assoc['AssociationId']}")

                  # SNS通知
                  sns.publish(
                      TopicArn=SNS_TOPIC_ARN,
                      Subject='[EC2/EIP] EIP関連付け完了',
                      Message=(
                          f"EC2インスタンスにEIPを関連付けました。\n\n"
                          f"インスタンスID : {instance_id}\n"
                          f"パブリックIP   : {public_ip}\n"
                          f"AllocationId  : {allocation_id}\n"
                          f"AssociationId : {assoc['AssociationId']}\n"
                      )
                  )
                  return {'statusCode': 200, 'publicIp': public_ip}

              except Exception as e:
                  print(f"エラー: {str(e)}")
                  sns.publish(
                      TopicArn=SNS_TOPIC_ARN,
                      Subject='[EC2/EIP] EIP関連付けエラー',
                      Message=f"EIPの関連付け中にエラーが発生しました。\n\nインスタンスID: {instance_id}\nエラー内容: {str(e)}"
                  )
                  raise

  # -----------------------------------------------
  # Lambda関数: EC2停止時 → EIP解除&解放
  # -----------------------------------------------
  ReleaseEIPFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-release-eip"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 60
      Environment:
        Variables:
          INSTANCE_ID: !Ref EC2InstanceId
          SNS_TOPIC_ARN: !Ref NotificationTopic
      Code:
        ZipFile: |
          import boto3
          import os
          import json

          ec2 = boto3.client('ec2')
          sns = boto3.client('sns')

          INSTANCE_ID = os.environ['INSTANCE_ID']
          SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']

          def handler(event, context):
              print(f"Event: {json.dumps(event)}")

              instance_id = event.get('detail', {}).get('instance-id', INSTANCE_ID)

              try:
                  # 対象インスタンスに関連付けられたEIPを検索
                  response = ec2.describe_addresses(
                      Filters=[{'Name': 'instance-id', 'Values': [instance_id]}]
                  )
                  addresses = response.get('Addresses', [])

                  if not addresses:
                      print("関連付けられたEIPが見つかりませんでした。")
                      sns.publish(
                          TopicArn=SNS_TOPIC_ARN,
                          Subject='[EC2/EIP] EIP解放スキップ',
                          Message=f"解放対象のEIPが見つかりませんでした。\n\nインスタンスID: {instance_id}"
                      )
                      return {'statusCode': 200, 'message': 'EIPなし'}

                  released = []
                  for addr in addresses:
                      public_ip = addr['PublicIp']
                      allocation_id = addr['AllocationId']
                      association_id = addr.get('AssociationId')

                      # 関連付け解除
                      if association_id:
                          ec2.disassociate_address(AssociationId=association_id)
                          print(f"EIP関連付け解除: {public_ip}")

                      # EIP解放
                      ec2.release_address(AllocationId=allocation_id)
                      print(f"EIP解放完了: {public_ip}")
                      released.append(public_ip)

                  # SNS通知
                  sns.publish(
                      TopicArn=SNS_TOPIC_ARN,
                      Subject='[EC2/EIP] EIP解放完了',
                      Message=(
                          f"EC2インスタンスのEIPを解放しました。\n\n"
                          f"インスタンスID  : {instance_id}\n"
                          f"解放したEIP    : {', '.join(released)}\n"
                      )
                  )
                  return {'statusCode': 200, 'releasedIPs': released}

              except Exception as e:
                  print(f"エラー: {str(e)}")
                  sns.publish(
                      TopicArn=SNS_TOPIC_ARN,
                      Subject='[EC2/EIP] EIP解放エラー',
                      Message=f"EIPの解放中にエラーが発生しました。\n\nインスタンスID: {instance_id}\nエラー内容: {str(e)}"
                  )
                  raise

  # -----------------------------------------------
  # EventBridge Rule: EC2起動(running)検知 → EIP関連付けLambda
  # -----------------------------------------------
  EC2RunningEventRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub "${AWS::StackName}-ec2-running"
      Description: EC2がrunning状態になったらEIPを関連付ける
      EventPattern:
        source:
          - aws.ec2
        detail-type:
          - EC2 Instance State-change Notification
        detail:
          state:
            - running
          instance-id:
            - !Ref EC2InstanceId
      State: ENABLED
      Targets:
        - Id: AssociateEIPTarget
          Arn: !GetAtt AssociateEIPFunction.Arn

  PermissionForRunningRule:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref AssociateEIPFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt EC2RunningEventRule.Arn

  # -----------------------------------------------
  # EventBridge Rule: EC2停止(stopped)検知 → EIP解放Lambda
  # -----------------------------------------------
  EC2StoppedEventRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub "${AWS::StackName}-ec2-stopped"
      Description: EC2がstopped状態になったらEIPを解放する
      EventPattern:
        source:
          - aws.ec2
        detail-type:
          - EC2 Instance State-change Notification
        detail:
          state:
            - stopped
          instance-id:
            - !Ref EC2InstanceId
      State: ENABLED
      Targets:
        - Id: ReleaseEIPTarget
          Arn: !GetAtt ReleaseEIPFunction.Arn

  PermissionForStoppedRule:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref ReleaseEIPFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt EC2StoppedEventRule.Arn

  # -----------------------------------------------
  # EventBridge Scheduler 用IAMロール
  # -----------------------------------------------
  SchedulerRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-scheduler-role"
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: EC2StartStopPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:StartInstances
                  - ec2:StopInstances
                Resource: !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${EC2InstanceId}"

  # -----------------------------------------------
  # EventBridge Scheduler: EC2定期起動
  # -----------------------------------------------
  EC2StartScheduler:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub "${AWS::StackName}-ec2-start"
      Description: EC2定期起動スケジュール
      ScheduleExpression: !Ref StartSchedule
      ScheduleExpressionTimezone: Asia/Tokyo
      FlexibleTimeWindow:
        Mode: "OFF"
      State: ENABLED
      Target:
        Arn: arn:aws:scheduler:::aws-sdk:ec2:startInstances
        RoleArn: !GetAtt SchedulerRole.Arn
        Input: !Sub '{"InstanceIds":["${EC2InstanceId}"]}'

  # -----------------------------------------------
  # EventBridge Scheduler: EC2定期停止
  # -----------------------------------------------
  EC2StopScheduler:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub "${AWS::StackName}-ec2-stop"
      Description: EC2定期停止スケジュール
      ScheduleExpression: !Ref StopSchedule
      ScheduleExpressionTimezone: Asia/Tokyo
      FlexibleTimeWindow:
        Mode: "OFF"
      State: ENABLED
      Target:
        Arn: arn:aws:scheduler:::aws-sdk:ec2:stopInstances
        RoleArn: !GetAtt SchedulerRole.Arn
        Input: !Sub '{"InstanceIds":["${EC2InstanceId}"]}'

# -----------------------------------------------
# 出力
# -----------------------------------------------
Outputs:
  SNSTopicArn:
    Description: SNSトピックARN
    Value: !Ref NotificationTopic

  AssociateEIPFunctionName:
    Description: EIP関連付けLambda関数名
    Value: !Ref AssociateEIPFunction

  ReleaseEIPFunctionName:
    Description: EIP解放Lambda関数名
    Value: !Ref ReleaseEIPFunction

普通に貼り付けたらめっちゃ長かったので折りたたみました。
冒頭にあるパラメータ(EC2のインスタンスIDとSNSの通知先のメールアドレス)を環境に即した内容へ編集していただければ、すぐに使用できるかと思います。

ちなみに

ちなみにClaudeが出力した状態だと、日付指定のcron式に誤りがありました。

EventBridge SchedulerのタイムゾーンをJSTに指定しつつ、UTCに+9した値を使用していたため、起動も停止も9時間ズレていました。

構築したリージョンは東京リージョンだったのでタイムゾーンのデフォルトがJSTとなっており、Claudeがそれを意識できなかったためだと思います。

稼働確認

image.png

image.png

こんな感じでメールが届いていました。

メールの件名・文面は特に何も指定しませんでしたがこれで全く問題ないですね。

このメール内のIPを使用して問題なくサーバに接続が出来ました。

参加者からすると毎回メールでIPを確認しないといけないのは面倒ですが、無料でプレーさせてるんだから良いだろの精神で行きましょう。

まとめ

以上でEC2に加えてEIPについても利用していない時間帯の課金を停止させることができました。

不特定多数を相手にした中規模~大規模サーバでは微妙ですが、
利用者が限られている身内向けの小規模サーバには有用な構成だと思います。

この構成の課題点は IPの通知方法になるかと思います。

実装が簡単だったのでメールとしましたが、ゲーマー向けなら

  • Discord通知
  • LINE通知
  • Bot通知

などにできると面白そうですよね。

ただ、ひとまずはこれで問題ないので、これで稼働させつつ改修を加えていきたいと思います。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?