はじめに
はじめて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で構築してみました。
構成図
今回の構成は以下のようになっています。
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テンプレート(クリックで展開)
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がそれを意識できなかったためだと思います。
稼働確認
こんな感じでメールが届いていました。
メールの件名・文面は特に何も指定しませんでしたがこれで全く問題ないですね。
このメール内のIPを使用して問題なくサーバに接続が出来ました。
参加者からすると毎回メールでIPを確認しないといけないのは面倒ですが、無料でプレーさせてるんだから良いだろの精神で行きましょう。
まとめ
以上でEC2に加えてEIPについても利用していない時間帯の課金を停止させることができました。
不特定多数を相手にした中規模~大規模サーバでは微妙ですが、
利用者が限られている身内向けの小規模サーバには有用な構成だと思います。
この構成の課題点は IPの通知方法になるかと思います。
実装が簡単だったのでメールとしましたが、ゲーマー向けなら
- Discord通知
- LINE通知
- Bot通知
などにできると面白そうですよね。
ただ、ひとまずはこれで問題ないので、これで稼働させつつ改修を加えていきたいと思います。


