2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【自動起票編】Zabbix × ServiceNow × AWX × Bedrockで障害初動対応を自動化してみた

2
Last updated at Posted at 2026-06-14

はじめに

こんにちは!

先日、InteropTokyo2026に参加してきました。
初のInteropでしたがどこのブースもAI取り入れた技術が多かったです。
実際に行って感じた事などは後日記事にまとめようと思います!

余談はここまでにして本題に入っていこうと思います!
前回はZabbix設定編②ということで、ZabbixのメディアタイプとActionを設定して、実際にLambdaにWebhookが届くところまで確認しましたね。

前回の記事はこちらに置いておきます
【Zabbix設定編②】Zabbix × ServiceNow × AWX × Bedrockで障害初動対応を自動化してみた

今回はその続きとして自動起票編を書いていこうと思います。

前回でZabbixからLambdaへのWebhookが届くようにしてるので、いよいよそのLambdaにServiceNow連携を追加していきます。

今回のゴール

ZabbixでNW機器の障害を検知したとき、ServiceNowにそのインシデントが自動で起票されて復旧時に自動クローズされることを確認することをゴールとします。

[Zabbix] 障害検知 → Webhook
        ↓ HTTPS POST
[Lambda]
        ↓
  event_type が PROBLEM → ServiceNow にインシデント起票
  event_type が RECOVERY → ServiceNow のインシデントをクローズ

全体構成図だと赤枠の設定がメインとなります。
名称未設定のデザイン (1).png

今回の流れ

# 内容
1 ServiceNow PDI の準備
2 連携用ユーザー(ZabbixIncident_Lambda)の作成・ロール付与
3 疎通確認
4 AWS Secrets Manager 登録
5 Lambda IAMロール変更
6 Lambda 環境変数設定
7 Lambda コード改修
8 単体確認
9 動作確認

ServiceNow PDI の準備

ServiceNow PDI とは

まずはServiceNow PDIを用意しなければなりません。

すでにインスタンス持ってるよーって方は飛ばしてください。

そもそもServiceNow PDIってなあに?ってとこなんですが、PDI(Personal Developer Instance) という個人向けの無料インスタンスを提供してくれています。
ありがたいっす。
無償版ということで使える機能は限られていますが今回の検証では特に問題なく使えるのでこれを活用していこうと思います。

インスタンス起動までは簡単にですが記述しておきます。

https://developer.servicenow.com にアクセス
        ↓
Sign Up でアカウント作成(無料)
        ↓
Request Instance でインスタンスを申請
        ↓
https://dev******.service-now.com 形式のURLが発行される

PDIは10日間アクセスしないと自動停止します。
データは保持されており、Developer Portalから再起動すれば戻ってきます。

ログイン方法

デフォルトだと App Engine Studio に飛ばされてしまうため、以下のURLでクラシックUI(ITSMの管理画面)に直接アクセスします。

https://devxxxxxx.service-now.com/now/nav/ui/classic

連携用ユーザーの作成

まずはLambdaがServiceNowのREST APIを叩くための専用ユーザーを作成します。

ユーザー作成手順

すべて → ユーザー管理 → ユーザー → 新規

今回は以下の連携ユーザーを作ります。

項目
ユーザーID ZabbixIncident_Lambda
ZabbixIncident
Lambda
IDタイプ マシン

「送信」をクリックしてユーザーを作成します。

image.png

IDタイプをマシンにするとブラウザからのログインができなくなります。
APIからのアクセスは許可されるので、連携専用ユーザーとして使うには理想的な設定ですね。

作成後の詳細画面で 「パスワードを設定」 をクリックします。
その後に生成ボタンがあるのでクリックして自動で生成してもらいましょう
(このパスワードは後でLambdaの環境変数に設定しますのでコピーしてメモっておきます)
image.png

メモできたらパスワードを保存をクリックして保存します。

ロールの付与

次にロールを付与していきます。
今回はitilロールを付与していきます。
ユーザー詳細画面を下にスクロールして 「ロール」タブ → 「編集」

左側の検索欄に itil と入力 → 選択して > ボタンで右に移動 → 「保存」

ロール 用途
itil インシデントの作成・更新・クローズに必要な権限

image.png
image.png


REST API の疎通確認

コードを書く前に作成したユーザーで実際にAPIが叩けるか確認しておきます。

ServiceNow REST API Explorer で確認

まずはREST API Explorerでパラメータの形を確認しておきます。
すべて → REST APIエクスプローラー
image.png

レコードを作成(POST)をクリックしてtableNameを incidentにします
image.png

スクロールしていくと「生」というタブがあるので以下のJSONを貼り付けて送信します。

{
  "short_description": "APIテスト起票",
  "urgency": "2",
  "impact": "2",
  "caller_id": "ZabbixIncident_Lambda"
}

image.png

応答本文のJSONに以下が含まれていれば成功です。

    "number": "INCxxxxxxx",

実際にINC番号も探してみましょう。
image.png

WSL から curl で連携ユーザーの確認

次に、作成した ZabbixIncident_Lambda でちゃんとAPIが通るかを確認します。
WSLで以下を実行します。

SN_PASS='ここにメモしたパスワードを入れる'
curl -s -X POST \
  "https://devxxxxx.service-now.com/api/now/table/incident" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -u "ZabbixIncident_Lambda:${SN_PASS}" \
  -d '{"short_description":"curlテスト","urgency":"2","impact":"2","caller_id":"ZabbixIncident_Lambda"}' \
  | python3 -m json.tool

以下のようにJSONが返ってくれば成功です。

{
    "result": {
        "parent": "",
        "made_sla": "true",
        "caused_by": "",
        "watch_list": "",
        "upon_reject": "cancel",
        "sys_updated_on": "2026-06-13 11:32:29",
        "child_incidents": "0",
        "hold_reason": "",
        "origin_table": "",
        "task_effective_number": "INC0010105",
        "approval_history": "",
        "number": "INC0010105",
        "resolved_by": "",
        "sys_updated_by": "ZabbixIncident_Lambda",
        "opened_by": {
            "link": "https://dev321070.service-now.com/api/now/table/sys_user/xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        },
        "user_input": "",
        "sys_created_on": "2026-06-13 11:32:29",
        "sys_domain": {
            "link": "https://devxxxxxx.service-now.com/api/now/table/sys_user_group/global",
            "value": "global"
        },
        "state": "1",
        "route_reason": "",
        "sys_created_by": "ZabbixIncident_Lambda",
        "knowledge": "false",
        "order": "",
        "calendar_stc": "",
        "closed_at": "",
        "cmdb_ci": "",
        "contract": "",
        "impact": "2",
        "active": "true",
        "work_notes_list": "",
        "business_service": "",
        "business_impact": "",
        "priority": "3",
        "sys_domain_path": "/",
        "rfc": "",
        "time_worked": "",
        "expected_start": "",
        "opened_at": "2026-06-13 11:32:29",
        "business_duration": "",
        "group_list": "",
        "work_end": "",
        "caller_id": {
            "link": "https://devxxxxxx.service-now.com/api/now/table/sys_user/xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        },
        "reopened_time": "",
        "resolved_at": "",
        "approval_set": "",
        "subcategory": "",
        "work_notes": "",
        "universal_request": "",
        "short_description": "curl\u30c6\u30b9\u30c8",
        "close_code": "",
        "correlation_display": "",
        "work_start": "",
        "assignment_group": "",
        "additional_assignee_list": "",
        "business_stc": "",
        "cause": "",
        "description": "",
        "origin_id": "",
        "calendar_duration": "",
        "close_notes": "",
        "notify": "1",
        "service_offering": "",
        "sys_class_name": "incident",
        "closed_by": "",
        "follow_up": "",
        "parent_incident": "",
        "sys_id": "0d683588c3a90790b2a63132b40131bc",
        "contact_type": "",
        "reopened_by": "",
        "incident_state": "1",
        "urgency": "2",
        "problem_id": "",
        "company": "",
        "reassignment_count": "0",
        "activity_due": "",
        "assigned_to": "",
        "severity": "3",
        "comments": "",
        "approval": "not requested",
        "sla_due": "",
        "comments_and_work_notes": "",
        "due_date": "",
        "sys_mod_count": "0",
        "reopen_count": "0",
        "sys_tags": "",
        "escalation": "0",
        "upon_approval": "proceed",
        "correlation_id": "",
        "location": "",
        "category": "inquiry"
    }
}

ServiceNowのインシデントも見てみましょう。
さっきのCurlが起票されてれば成功です。
image.png

AWS Secrets Managerでシークレット作成

続いて先ほどの連携ユーザーをAWS Secrets Managerに登録しましょう。
Lambdaの環境変数にそのままユーザー名やパスワード入れても動きますがセキュリティ観点を考慮してSecrets Managerを使用します。

AWSのマネコンでSecrets Managerを開き、新しいシークレットを保存するをクリックして作成していきます。

項目 タイプ
シークレットのタイプ その他のシークレットタイプ

続いてキー値を設定していきます。

キー
instance_url https://devxxxxxx.service-now.com
username ZabbixIncident_Lambda
password 自動生成したユーザーパスワード

最後にシークレット名を入れて保存します。
servicenow/devxxxxxx/api-user-01
名称未設定のデザイン.png

Lambda IAMロールへポリシー付与

次にLambdaのIAMロールにSecretsManagerのポリシーをアタッチします。

image.png

Lambda 環境変数の作成

続いて環境変数を作成します。

設定 → 環境変数

キー
SN_SECRET_ID servicenow/devxxxxx/api-user-01

Secrets Managerを参照させることにより管理もこれだけで済みます。

Lambda コードの改修

続いて前回作った verification-lambda に、ServiceNow連携のコードを追加していきます。

設計の整理

コードを書く前に、何をどうするかを整理しておきます。

PROBLEM(障害発生)のとき

① 重複チェック
   同じ障害の2重起票を防ぐため、correlation_id で既存チケットを検索
        ↓
② なければ ServiceNow にインシデント起票
   correlation_id: "zabbix_{event_id}" を保存しておく

RECOVERY(復旧)のとき

① correlation_id で対応するチケットを検索
        ↓
② チケットを state: 6(解決済み)に更新して自動解決済み

correlation_id というのがポイントで、障害発生時に「zabbix_{event_id}」という値を保存しておいて、復旧時にその値で検索することで同じチケットを特定できる仕組みです。

インシデントの state 値

ServiceNow のインシデントのステータスは数値で管理されています。

state値 意味
1 New(新規)
2 In Progress(対応中)
6 Resolved(解決済み)
7 Closed(クローズ済)

クローズ(state: 6)にするときは close_codeclose_notes の指定も必要です。

コードの実装

Lambda コンソールの コード タブを開いて、以下に変えます。

import json
import urllib.request
import urllib.parse
import os
import base64
import boto3


# ── 設定 ────────────────────────────────────────────────────────────────────
#   Secrets Manager からインスタンスURL・ユーザー・パスワードを取得する
#
#   必須環境変数:
#     SN_SECRET_ID : Secrets Manager のシークレット名
#                    例: servicenow/devxxxxxx/api-user-01
#
#   Secrets Manager に格納するJSONのキー:
#     instance_url : https://devxxxxxx.service-now.com
#     username     : ZabbixIncident_Lambda
#     password     : (パスワード)
#
#   任意環境変数(設定しなければデフォルト値で動く):
#     SN_TABLE              : テーブル名(デフォルト: incident)
#     SN_INCIDENT_URGENCY   : 緊急度(デフォルト: 2)
#     SN_INCIDENT_IMPACT    : 影響度(デフォルト: 2)
#     SN_CLOSE_STATE        : クローズ時のstate値(デフォルト: 6)
#     SN_CLOSE_CODE         : クローズコード(デフォルト: Solved (Permanently))
#     CORRELATION_ID_PREFIX : correlation_idのプレフィックス(デフォルト: zabbix_)
# ────────────────────────────────────────────────────────────────────────────

# Secrets Manager の取得結果をキャッシュ(Lambda コンテナ再利用時に再取得しない)
_secret_cache = None


def get_secret():
    """Secrets Manager からシークレットを取得してdictで返す(キャッシュあり)"""
    global _secret_cache
    if _secret_cache is not None:
        return _secret_cache

    secret_id = os.environ["SN_SECRET_ID"]
    client = boto3.client("secretsmanager", region_name=os.environ.get("AWS_REGION", "ap-northeast-1"))
    response = client.get_secret_value(SecretId=secret_id)

    _secret_cache = json.loads(response["SecretString"])
    return _secret_cache


def get_config():
    secret = get_secret()
    instance_url = secret["instance_url"].rstrip("/")
    table = os.environ.get("SN_TABLE", "incident")
    return {
        "base_url"    : f"{instance_url}/api/now/table/{table}",
        "user"        : secret["username"],
        "password"    : secret["password"],
        "urgency"     : os.environ.get("SN_INCIDENT_URGENCY", "2"),
        "impact"      : os.environ.get("SN_INCIDENT_IMPACT", "2"),
        "close_state" : os.environ.get("SN_CLOSE_STATE", "6"),
        "close_code"  : os.environ.get("SN_CLOSE_CODE", "Solved (Permanently)"),
        "corr_prefix" : os.environ.get("CORRELATION_ID_PREFIX", "zabbix_"),
    }


def get_auth_header(user, password):
    encoded = base64.b64encode(f"{user}:{password}".encode()).decode()
    return f"Basic {encoded}"


def build_headers(cfg):
    return {
        "Content-Type" : "application/json",
        "Accept"       : "application/json",
        "Authorization": get_auth_header(cfg["user"], cfg["password"]),
    }


def build_correlation_id(cfg, event_id):
    return f"{cfg['corr_prefix']}{event_id}"


# ── ServiceNow API 呼び出し ──────────────────────────────────────────────────

def find_active_incident(cfg, event_id):
    """correlation_id でアクティブなインシデントを検索して返す(なければ None)"""
    correlation_id = build_correlation_id(cfg, event_id)
    query = urllib.parse.urlencode({
        "sysparm_query" : f"correlation_id={correlation_id}^active=true",
        "sysparm_fields": "sys_id,number",
        "sysparm_limit" : "1",
    })
    url = f"{cfg['base_url']}?{query}"
    req = urllib.request.Request(url, headers=build_headers(cfg), method="GET")

    with urllib.request.urlopen(req, timeout=10) as res:
        result = json.loads(res.read().decode())

    records = result.get("result", [])
    return records[0] if records else None


def create_incident(cfg, event_id, subject, description, host_name):
    """インシデントを起票して (sys_id, number) を返す"""
    payload = {
        "short_description": subject,
        "description"      : description,
        "cmdb_ci"          : host_name,
        "urgency"          : cfg["urgency"],
        "impact"           : cfg["impact"],
        "caller_id"        : cfg["user"],
        "correlation_id"   : build_correlation_id(cfg, event_id),
    }
    req = urllib.request.Request(
        cfg["base_url"],
        data=json.dumps(payload).encode(),
        headers=build_headers(cfg),
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as res:
        result = json.loads(res.read().decode())

    r = result["result"]
    return r["sys_id"], r["number"]


def resolve_incident(cfg, sys_id, inc_number):
    """インシデントをクローズする"""
    payload = {
        "state"      : cfg["close_state"],
        "close_code" : cfg["close_code"],
        "close_notes": "Zabbix復旧検知により自動クローズ",
    }
    url = f"{cfg['base_url']}/{sys_id}"
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode(),
        headers=build_headers(cfg),
        method="PATCH",
    )
    with urllib.request.urlopen(req, timeout=10) as res:
        res.read()

    print(f"[RESOLVED] {inc_number}")


# ── メインハンドラ ────────────────────────────────────────────────────────────

def lambda_handler(event, context):
    print("受信イベント:", json.dumps(event, ensure_ascii=False))

    # ── body のパース
    try:
        body = event.get("body", "{}")
        message = json.loads(body) if isinstance(body, str) else body
    except json.JSONDecodeError as e:
        print(f"JSON解析エラー: {e}")
        return {"statusCode": 400, "body": json.dumps({"error": "Invalid JSON"})}

    event_type   = message.get("event_type", "")
    event_id     = message.get("event_id", "")
    subject      = message.get("subject", "Zabbix アラート")
    description  = message.get("description", "")
    host_name    = message.get("host_name", "")

    print(f"event_type={event_type}, event_id={event_id}, host={host_name}")

    # ── バリデーション
    if event_type not in ("PROBLEM", "RECOVERY"):
        return {"statusCode": 400, "body": json.dumps({"error": f"Unknown event_type: {event_type}"})}
    if not event_id:
        return {"statusCode": 400, "body": json.dumps({"error": "event_id is required"})}
    if event_type == "PROBLEM" and not host_name:
        return {"statusCode": 400, "body": json.dumps({"error": "host_name is required for PROBLEM"})}

    cfg = get_config()

    # ── PROBLEM:起票
    if event_type == "PROBLEM":
        existing = find_active_incident(cfg, event_id)
        if existing:
            print(f"[SKIP] 既存インシデントあり: {existing['number']}")
            return {
                "statusCode": 200,
                "body": json.dumps({"result": "skipped", "incident": existing["number"]}),
            }

        sys_id, inc_number = create_incident(cfg, event_id, subject, description, host_name)
        print(f"[CREATED] {inc_number}")
        return {
            "statusCode": 200,
            "body": json.dumps({"result": "created", "incident": inc_number}),
        }

    # ── RECOVERY:クローズ
    if event_type == "RECOVERY":
        existing = find_active_incident(cfg, event_id)
        if not existing:
            print(f"[NOT FOUND] correlation_id={build_correlation_id(cfg, event_id)}")
            return {"statusCode": 404, "body": json.dumps({"error": "Incident not found"})}

        resolve_incident(cfg, existing["sys_id"], existing["number"])
        return {
            "statusCode": 200,
            "body": json.dumps({"result": "resolved", "incident": existing["number"]}),
        }

一度Deployしておきます。

単体確認

起票テスト(PROBLEM)
テストイベント名:servicenow-problem-test
それではテストしましょう。以下のJSONを貼り付けます。

{
  "body": "{\"event_type\": \"PROBLEM\", \"event_id\": \"99901\", \"host_name\": \"devnetsandboxiosxec9k.cisco.com\", \"subject\": \"Cisco Interface Down検知\", \"description\": \"動作確認用の起票テスト\"}"
}

保存して実行してみましょう。
実行ログに以下が表示されていれば成功です。

{
  "statusCode": 200,
  "body": "{\"result\": \"created\", \"incident\": \"INC0010106\"}"
}

さらにServiceNow側も起票されてるはずなので確認しましょう。
image.png

起票されてることが確認できました!

クローズテスト(RECOVERY)
続いてクローズしてみます。
テストイベント名:servicenow-recovery-test
以下のJSONを貼り付けましょう。

{
  "body": "{\"event_type\": \"RECOVERY\", \"event_id\": \"99901\", \"host\": \"devnetsandboxiosxec9k.cisco.com\", \"trigger_name\": \"Cisco Interface Down検知\", \"description\": \"動作確認用のクローズテスト\"}"
}

保存して実行してみましょう。
実行ログに以下が表示されていれば成功です。

  "statusCode": 200,
  "body": "{\"result\": \"resolved\", \"incident\": \"INC0010106\"}"
}

ServiceNow 画面でインシデントの状態が「解決済み」になっていて、メモ に「Zabbix復旧検知により自動クローズ」と入っていますね!
image.png
image.png

順番に注意
必ず先にPROBLEMを実行してください。RECOVERYは「同じevent_idのアクティブなチケットを探してクローズする」動きなので、チケットがない状態でRECOVERYを実行すると Incident not found が返ります。

body の値が文字列になっているのは、Lambda Function URL経由で実際にZabbixからWebhookが来たときも同じ形式で届くからです。この形でテストしておくことで本番と同じ経路を再現できます。

動作確認

テストは無事成功したので次は実際にZabbixからトリガーを発火させて動くか確認します。

SSH監視について(2026/06/14追記)
最近CiscoDevNetSandboxのSSHが不安定でZABBIXでうまく値の取得ができない日が続いているので監視方法をHTTPエージェントに切り替えました。

では実際にInterfaceを落としてみましょう。

conf t
interface GigabitEthernet1/0/1
shutdown

障害として検知しました。
アクションもグレーになってるのでWebhookがちゃんと効いてますね。
image.png

ServiceNowも見てみましょう!
image.png

ちゃんと起票されていますね。
ではUPに戻します。

no shut

image.png
image.png

解決済みになりましたね。

まとめ

無事にインシデント発生~自動起票~自動クローズまでできました。
今回、記事用の検証でSSH監視がうまく機能せず、急遽HTTPエージェントで監視するように切り替えたため少し時間かかってしまいました。
せっかくだったのでディスカバリールールを使用してトリガー実装しましたので別途記事書こうと思います。気になる方いたら見てください!

それでは今回やったことをまとめます。

# やったこと
1 ServiceNow PDI の準備・ログイン確認
2 連携用ユーザー(ZabbixIncident_Lambda)の作成・itilロール付与
3 REST API Explorer と curl で疎通確認
4 AWS Secrets Manager にインスタンスURL・ユーザー・パスワードを登録
5 Lambda IAMロールに SecretsManager ポリシーをアタッチ
6 Lambda 環境変数に SN_SECRET_ID を設定
7 Lambda コードを ServiceNow 起票・クローズ処理に改修
8 テストイベントで PROBLEM / RECOVERY の単体確認
9 動作確認

これで「Zabbixが障害を検知したらServiceNowに自動起票 → 復旧したら自動クローズ」というパイプラインが完成しました!

次回は AWX連携編 としてインシデント起票と同時に AWX のジョブを起動してNW機器のログ収集を自動化する部分を実装していきます。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?