Step Functionsを使ってAWS Backupでも静止点でバックアップを取得する
はじめに
皆様こんにちは、あかいけです。
AWS Backupで、サーバーを停止して静止点でバックアップを取りたいと思ったことはありますか?私はあります。
しかしAWS Backupはインスタンスを稼働させたままバックアップを取得するので、そのままでは実現できません。
というわけで今回は、Step Functionsを利用してAWS Backupでも静止点でバックアップを取得する構成をまとめてみました。
AWS Backupの整合性について
前提としてAWS Backupは、EC2インスタンスに複数のEBSボリュームがアタッチされていても、それらを同一時点でまとめてスナップショット化してくれます。
AWS公式ドキュメントではこれを、クラッシュ整合性と呼んでいます。
Crash consistency means that the snapshots for every Amazon EBS volume attached to the same Amazon EC2 instance are taken at the exact same moment.
クラッシュ整合性バックアップは、「電源が状態でスナップショットを取る」イメージです。
多くのワークロードではこれで十分ですが、メモリ上に未書き込みのデータを抱えるアプリケーションでは、復元時にデータが不整合になるリスクがあります。
これを避けるには、アプリケーション整合性のあるバックアップを取得する必要があります。
実現方法は大きく次の2つです。
- 案①: アプリを稼働させたまま整合性を確保する
- WindowsであればVSS(Volume Shadow Copy Service)、Linuxであれば
fsfreezeや事前/事後スクリプトなどを使い、I/Oを一時的に静止させてバックアップを取得する方法です - ただしSSM AgentやVSSコンポーネントのセットアップが必要で、対象もVSS/フリーズ対応アプリケーションに限られます
- WindowsであればVSS(Volume Shadow Copy Service)、Linuxであれば
- 案②: インスタンスを停止してから整合性を確保する
- アプリケーションを完全に停止した状態、つまり「静止点」でバックアップを取得する方法です
- VSS非対応のアプリケーションでも確実に整合性の取れたバックアップが取得できます
今回はVSS非対応のワークロードも含めて確実に静止点を作りたかったため、案②を採用しました。
構成について
やりたいことはシンプルで、以下の流れを自動化します。
- EC2インスタンスを停止する
- 停止完了を待つ
- AWS Backupでバックアップを取得する
- バックアップ完了を待つ
- EC2インスタンスを起動する
この「停止 → 待機 → バックアップ → 待機 → 起動」という状態遷移を制御するために、Step Functionsを採用しました。
全体の構成は以下のようになります。

役割を整理すると次のとおりです。
| サービス | 役割 |
|---|---|
| EventBridge Scheduler | 曜日・時刻ごとにステートマシンを起動する |
| Step Functions | 停止 → バックアップ → 起動の一連の流れを制御する |
| AWS Backup | 実際のバックアップ(リカバリーポイント)を取得・保管する |
Terraform
今回作成するTerraform全体を掲載します。
詳細は次の「実装の解説」で説明します。
コード
- aws_backup.tf
# -----------------------------------------------------------------------------
# Backup Vault - 本番用
# -----------------------------------------------------------------------------
resource "aws_backup_vault" "prod" {
name = "example-prod-backup-vault"
tags = {
Name = "example-prod-backup-vault"
}
}
# Vault Lock - ガバナンスモード
# changeable_for_days 未指定でガバナンスモード(ロック解除・変更可能)
resource "aws_backup_vault_lock_configuration" "prod" {
backup_vault_name = aws_backup_vault.prod.name
min_retention_days = 15
}
- iam.tf
# -----------------------------------------------------------------------------
# AWS Backup 実行ロール
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "backup_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["backup.amazonaws.com"]
}
}
}
resource "aws_iam_role" "backup" {
name = "example-backup-role"
assume_role_policy = data.aws_iam_policy_document.backup_assume_role.json
}
resource "aws_iam_role_policy_attachment" "backup_service" {
role = aws_iam_role.backup.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
}
# -----------------------------------------------------------------------------
# Step Functions 実行ロール
# -----------------------------------------------------------------------------
data "aws_iam_policy_document" "sfn_inline" {
# EC2の停止・起動・状態確認
statement {
effect = "Allow"
actions = [
"ec2:StopInstances",
"ec2:StartInstances",
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
]
resources = ["*"]
}
# AWS Backupの起動・状態確認
statement {
effect = "Allow"
actions = [
"backup:StartBackupJob",
"backup:DescribeBackupJob",
]
resources = ["*"]
}
# AWS Backupの実行ロールを渡す権限
statement {
effect = "Allow"
actions = ["iam:PassRole"]
resources = [aws_iam_role.backup.arn]
}
# CloudWatch Logs / X-Ray(ログ出力・トレース用)
statement {
effect = "Allow"
actions = [
"logs:CreateLogDelivery",
"logs:GetLogDelivery",
"logs:UpdateLogDelivery",
"logs:DeleteLogDelivery",
"logs:ListLogDeliveries",
"logs:PutResourcePolicy",
"logs:DescribeResourcePolicies",
"logs:DescribeLogGroups",
]
resources = ["*"]
}
statement {
effect = "Allow"
actions = [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets",
]
resources = ["*"]
}
}
- step_functions.tf
resource "aws_sfn_state_machine" "stop_backup_start" {
name = "example-stop-backup-start-sfn"
role_arn = aws_iam_role.sfn.arn
type = "STANDARD"
logging_configuration {
log_destination = "${aws_cloudwatch_log_group.sfn_stop_backup_start.arn}:*"
include_execution_data = true
level = "ALL"
}
tracing_configuration {
enabled = true
}
definition = jsonencode({
Comment = "EC2を停止してから静止点でAWS Backupを取得し、完了後に起動する"
StartAt = "LookupInstanceId"
States = {
# Nameタグからインスタンスを1台に特定する
LookupInstanceId = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:describeInstances"
Parameters = {
Filters = [
{
Name = "tag:Name"
"Values.$" = "States.Array($.instance_name)"
},
{
Name = "instance-state-name"
Values = ["pending", "running", "stopping", "stopped"]
}
]
}
ResultSelector = {
"instance_ids.$" = "$.Reservations[*].Instances[*].InstanceId"
}
ResultPath = "$.lookup"
Next = "CountInstances"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "ExecutionFailed" }]
}
CountInstances = {
Type = "Pass"
Parameters = {
"count.$" = "States.ArrayLength($.lookup.instance_ids)"
}
ResultPath = "$.lookup_meta"
Next = "CheckLookupCount"
}
CheckLookupCount = {
Type = "Choice"
Choices = [
{
Variable = "$.lookup_meta.count"
NumericEquals = 1
Next = "ExtractInstanceId"
}
]
Default = "LookupFailed"
}
# ヒット件数が1件のときだけインスタンスIDを取り出す
ExtractInstanceId = {
Type = "Pass"
Parameters = {
"instance_id.$" = "$.lookup.instance_ids[0]"
}
ResultPath = "$.target"
Next = "StopInstance"
}
# 0件または複数件ヒットした場合は誤操作防止のため失敗させる
LookupFailed = {
Type = "Fail"
Cause = "Name tag did not match exactly one instance"
}
StopInstance = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:stopInstances"
Parameters = {
"InstanceIds.$" = "States.Array($.target.instance_id)"
}
ResultPath = "$.stop_result"
Next = "WaitBeforeCheckStopped"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "ExecutionFailed" }]
}
WaitBeforeCheckStopped = {
Type = "Wait"
Seconds = 30
Next = "CheckStopped"
}
# IncludeAllInstances: true を付けないと停止済みインスタンスが結果に含まれない
CheckStopped = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus"
Parameters = {
"InstanceIds.$" = "States.Array($.target.instance_id)"
IncludeAllInstances = true
}
ResultPath = "$.status_result"
Next = "IsStopped"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "FallbackStartInstance" }]
}
IsStopped = {
Type = "Choice"
Choices = [
{
Variable = "$.status_result.InstanceStatuses[0].InstanceState.Code"
NumericEquals = 80
Next = "StartBackupJob"
}
]
Default = "WaitBeforeCheckStopped"
}
StartBackupJob = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:backup:startBackupJob"
Parameters = {
"BackupVaultName.$" = "$.backup_vault_name"
"ResourceArn.$" = "States.Format('arn:aws:ec2:ap-northeast-1:123456789012:instance/{}', $.target.instance_id)"
IamRoleArn = "arn:aws:iam::123456789012:role/example-backup-role"
Lifecycle = {
"DeleteAfterDays.$" = "$.retention_days"
}
RecoveryPointTags = {
"Name.$" = "$.instance_name"
"Environment.$" = "$.environment"
}
}
ResultPath = "$.backup_result"
Next = "WaitBeforeCheckBackup"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "FallbackStartInstance" }]
}
WaitBeforeCheckBackup = {
Type = "Wait"
Seconds = 60
Next = "CheckBackupComplete"
}
CheckBackupComplete = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:backup:describeBackupJob"
Parameters = {
"BackupJobId.$" = "$.backup_result.BackupJobId"
}
ResultPath = "$.backup_status"
Next = "IsBackupComplete"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "FallbackStartInstance" }]
}
IsBackupComplete = {
Type = "Choice"
Choices = [
{
Variable = "$.backup_status.State"
StringEquals = "COMPLETED"
Next = "StartInstance"
}
]
Default = "WaitBeforeCheckBackup"
}
StartInstance = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:startInstances"
Parameters = {
"InstanceIds.$" = "States.Array($.target.instance_id)"
}
ResultPath = "$.start_result"
Next = "WaitBeforeCheckRunning"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "ExecutionFailed" }]
}
WaitBeforeCheckRunning = {
Type = "Wait"
Seconds = 30
Next = "CheckRunning"
}
CheckRunning = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus"
Parameters = {
"InstanceIds.$" = "States.Array($.target.instance_id)"
}
ResultPath = "$.running_result"
Next = "IsRunning"
Catch = [{ ErrorEquals = ["States.ALL"], Next = "ExecutionFailed" }]
}
# running かつシステム/インスタンスの両ステータスチェックがokになるまで待つ
IsRunning = {
Type = "Choice"
Choices = [
{
And = [
{
Variable = "$.running_result.InstanceStatuses[0].InstanceState.Code"
NumericEquals = 16
},
{
Variable = "$.running_result.InstanceStatuses[0].SystemStatus.Status"
StringEquals = "ok"
},
{
Variable = "$.running_result.InstanceStatuses[0].InstanceStatus.Status"
StringEquals = "ok"
}
]
Next = "Done"
}
]
Default = "WaitBeforeCheckRunning"
}
# 失敗時も停止したまま放置しないよう、起動だけは試みてから失敗扱いにする
FallbackStartInstance = {
Type = "Task"
Resource = "arn:aws:states:::aws-sdk:ec2:startInstances"
Parameters = {
"InstanceIds.$" = "States.Array($.target.instance_id)"
}
Next = "ExecutionFailed"
}
ExecutionFailed = {
Type = "Fail"
}
Done = {
Type = "Succeed"
}
}
})
}
- eventbridge.tf
resource "aws_scheduler_schedule" "app_stop_backup_start" {
name = "example-app-stop-backup-start-schedule"
description = "APサーバーの停止→バックアップ→起動運用"
schedule_expression = "cron(0 1 ? * FRI *)"
schedule_expression_timezone = "Asia/Tokyo"
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_sfn_state_machine.stop_backup_start.arn
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({
instance_name = "example-app-server"
backup_vault_name = aws_backup_vault.prod.name
retention_days = 21
environment = "prod"
})
retry_policy {
maximum_retry_attempts = 0
}
}
}
実装について
ここからは、上記の実装のポイントを解説していきます。
AWS Backup について
バックアップの保管先となるBackup Vaultを作成します。
また今回はバックアッププラン(スケジュール)は作成せず、Step FunctionsからStartBackupJobで都度バックアップを起動する方針としました。
スケジュール管理はEventBridge Scheduler側に寄せたかったためです。
Step Functions について
step_functions.tfのdefinitionに定義するステートマシンについて、処理の流れは以下のとおりです。
NameタグからインスタンスIDを解決する- インスタンスを停止する
stoppedになるまでポーリングするStartBackupJobでバックアップを起動する- バックアップが
COMPLETEDになるまでポーリングする - インスタンスを起動する
runningかつステータスチェックがokになるまでポーリングする
以降、各ステートを順に見ていきます。
Nameタグからインスタンスを解決する
まずEventBridge Schedulerから渡すのはインスタンスIDではなくNameタグの値にしています。
タグ名を指定することで運用開始後にリストアした際などインスタンスIDが変更になった際も対応できるようにしています。
describeInstancesでタグ検索し、該当インスタンスが1台であることを確認してからIDを取り出します。
想定外に複数台ヒットした場合や0台の場合はFailさせることで、意図しないインスタンスを停止してしまう事故を防いでいます。
"LookupInstanceId": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
"Parameters": {
"Filters": [
{
"Name": "tag:Name",
"Values.$": "States.Array($.instance_name)"
},
{
"Name": "instance-state-name",
"Values": ["pending", "running", "stopping", "stopped"]
}
]
},
"ResultSelector": {
"instance_ids.$": "$.Reservations[*].Instances[*].InstanceId"
},
"ResultPath": "$.lookup",
"Next": "CountInstances",
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ExecutionFailed" }]
}
続いて、ヒットするのが1台かどうかを判定します。
"CountInstances": {
"Type": "Pass",
"Parameters": {
"count.$": "States.ArrayLength($.lookup.instance_ids)"
},
"ResultPath": "$.lookup_meta",
"Next": "CheckLookupCount"
},
"CheckLookupCount": {
"Type": "Choice",
"Choices": [{
"Variable": "$.lookup_meta.count",
"NumericEquals": 1,
"Next": "ExtractInstanceId"
}],
"Default": "LookupFailed"
}
インスタンスを停止して停止完了を待つ
インスタンスIDが確定したら停止します。停止リクエスト後はすぐにstoppedにはならないため、Waitで一定時間待ってからdescribeInstanceStatusで状態を確認し、Choiceでループさせるポーリングを組んでいます。
EC2の状態コードは80がstoppedを表します。この状態になるまで待機と確認を繰り返します。
"StopInstance": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
"Parameters": {
"InstanceIds.$": "States.Array($.target.instance_id)"
},
"ResultPath": "$.stop_result",
"Next": "WaitBeforeCheckStopped",
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ExecutionFailed" }]
},
"WaitBeforeCheckStopped": {
"Type": "Wait",
"Seconds": 30,
"Next": "CheckStopped"
},
"CheckStopped": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus",
"Parameters": {
"InstanceIds.$": "States.Array($.target.instance_id)",
"IncludeAllInstances": true
},
"ResultPath": "$.status_result",
"Next": "IsStopped",
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "FallbackStartInstance" }]
},
"IsStopped": {
"Type": "Choice",
"Choices": [{
"Variable": "$.status_result.InstanceStatuses[0].InstanceState.Code",
"NumericEquals": 80,
"Next": "StartBackupJob"
}],
"Default": "WaitBeforeCheckStopped"
}
describeInstanceStatusはデフォルトではrunning状態のインスタンスしか返さないため、停止確認ではIncludeAllInstances: trueを指定している点がポイントです。これを付けないと停止済みインスタンスが結果に含まれず、判定できなくなります。
バックアップを開始して完了を待つ
インスタンスが停止したら、いよいよStartBackupJobでバックアップを起動します。この時点でアプリケーションは完全に停止しているため、静止点でのバックアップが取得できます。
ResourceArnにはEC2インスタンスのARNを指定します。リージョンとアカウントIDはStates.Formatで組み立てています。保持期間(DeleteAfterDays)やタグはEventBridge Schedulerからの入力値で動的に切り替えられるようにしています。
"StartBackupJob": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:backup:startBackupJob",
"Parameters": {
"BackupVaultName.$": "$.backup_vault_name",
"ResourceArn.$": "States.Format('arn:aws:ec2:ap-northeast-1:123456789012:instance/{}', $.target.instance_id)",
"IamRoleArn": "arn:aws:iam::123456789012:role/example-backup-role",
"Lifecycle": {
"DeleteAfterDays.$": "$.retention_days"
},
"RecoveryPointTags": {
"Name.$": "$.instance_name",
"Environment.$": "$.environment"
}
},
"ResultPath": "$.backup_result",
"Next": "WaitBeforeCheckBackup",
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "FallbackStartInstance" }]
}
バックアップジョブも完了まで時間がかかるため、describeBackupJobでStateがCOMPLETEDになるまでポーリングします。
"WaitBeforeCheckBackup": {
"Type": "Wait",
"Seconds": 60,
"Next": "CheckBackupComplete"
},
"CheckBackupComplete": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:backup:describeBackupJob",
"Parameters": {
"BackupJobId.$": "$.backup_result.BackupJobId"
},
"ResultPath": "$.backup_status",
"Next": "IsBackupComplete",
"Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "FallbackStartInstance" }]
},
"IsBackupComplete": {
"Type": "Choice",
"Choices": [{
"Variable": "$.backup_status.State",
"StringEquals": "COMPLETED",
"Next": "StartInstance"
}],
"Default": "WaitBeforeCheckBackup"
}
インスタンスを起動して起動完了を待つ
最後にインスタンスを起動し、running(状態コード16)かつシステム/インスタンスのステータスチェックが両方okになるまで待機します。ステータスチェックまで確認することで、OSが起動しきってサービスが利用可能になるまで見届けています。
"IsRunning": {
"Type": "Choice",
"Choices": [{
"And": [
{
"Variable": "$.running_result.InstanceStatuses[0].InstanceState.Code",
"NumericEquals": 16
},
{
"Variable": "$.running_result.InstanceStatuses[0].SystemStatus.Status",
"StringEquals": "ok"
},
{
"Variable": "$.running_result.InstanceStatuses[0].InstanceStatus.Status",
"StringEquals": "ok"
}
],
"Next": "Done"
}],
"Default": "WaitBeforeCheckRunning"
}
失敗時のフォールバック
バックアップやステータス確認の途中で失敗した場合、インスタンスが停止したまま放置されてしまうと業務影響が出てしまいます。
そこで各TaskのCatchでFallbackStartInstanceに遷移させ、「とりあえずインスタンスの起動だけは試みてから失敗扱いにする」という作りにしています。
"FallbackStartInstance": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
"Parameters": {
"InstanceIds.$": "States.Array($.target.instance_id)"
},
"Next": "ExecutionFailed"
},
"ExecutionFailed": {
"Type": "Fail"
}
これで、バックアップに失敗しても「インスタンスは起動を試みたうえでワークフローとしては失敗として通知される」状態になり、停止したまま翌朝を迎えてしまう事故を防げます。
EventBridge Schedulerでスケジュール実行
最後に、作成したステートマシンをEventBridge Schedulerから定期実行します。eventbridge.tfではタイムゾーンをAsia/Tokyoに設定し、cron式で実行タイミングを指定しています。
ステートマシンへの入力(input)として、対象インスタンスのNameタグ・Backup Vault名・保持期間・環境名を渡しています。これにより、同じステートマシンを複数のインスタンスやスケジュールで使い回せるようになっています。
その他 考慮事項
実装にあたって意識しておきたい点をまとめます。
- ダウンタイムが発生する
- 静止点バックアップはインスタンスを停止する以上、その間サービスは停止します。
- 深夜帯など業務影響の少ない時間帯にスケジュールするのが前提になります。
- ポーリング間隔と所要時間
Waitの秒数はインスタンスの停止・起動やバックアップにかかる時間に合わせて調整してください。- 短すぎるとAPI呼び出しが増え、長すぎると全体の所要時間が伸びます。
- クラッシュ整合性で十分か再確認する
- 冒頭でも触れたとおり、ワークロードによってはクラッシュ整合性バックアップで十分な場合もあります。
- 停止を伴う静止点バックアップは確実性が高い反面ダウンタイムを伴うため、本当に停止が必要かは要件と照らして判断しましょう。
- 失敗時の通知
- 本記事では触れていませんが、ステートマシンが
Failした際にSNSやChatbotで通知する仕組みを併せて用意しておくと、停止・起動が絡む処理だけに安心して運用できます。
- 本記事では触れていませんが、ステートマシンが
さいごに
以上、Step Functionsを使ってAWS Backupで静止点でバックアップを取得してみました。
AWS Backupのクラッシュ整合性バックアップだけでは要件を満たせない場面でも、Step Functionsを組み合わせることで静止点でのバックアップを実現できました。
またStep FunctionsはLambdaで実装する場合に比べて、コード管理やそれに伴うバージョン管理などが不要となるため、運用面でもおすすめです。







