はじめに
GMOコネクトの小川です。
「手順書どおりにSQLを手で書いて、cronを手で直して、台帳に記録して……」という"決まりきっているのに自動化されていない作業"、現場にありませんか😇 今回はそれをPythonツール1本に畳み込んだ話です。
システム運用の現場には、こうした手順は決まっているのに自動化されていない作業がよくあります。今回題材にするのも、まさにそういう仕事でした。
具体的には、システムメンテナンス時に「特定の機能を一時停止させる設定」をDBに入れる作業です。流れはこうです。
- メンテナンス対象のシステムから、停止すべき機能のID(以下
external_system_id、略して ext_id)をExcel台帳で特定する - その ext_id を対象にした
UPDATE文(停止期間を書き込むSQL)を作る - そのSQLを定刻に実行する cron を編集する
- 作業記録を別の台帳CSVに残す
1件だけならどうということはありません。ですが現実には、複数の依頼が同じ期間に重なったり、同じ機能に対して長短のメンテナンスが二重に入ったりします。そうなると「期間が重なってるからこっちの設定はどうする?」「この機能だけ別の依頼でもっと長く止める予定があるけど上書きして大丈夫?」と、人間が頭の中で突き合わせる必要が出てきます。ここがミスの温床でした。
この記事では、この作業をPythonツールに落とし込む過程で出会った「業務の暗黙知をコードに翻訳する勘所」を、4つのトピックに絞って共有します。LLM(Claude Code)と対話しながら作ったツールですが、本題はLLMの使い方ではなく、現場の泥臭いルールを破綻しないロジックにどう整理するか、です。
なお、登場するIPアドレス・ホスト名・パス・チケット番号・機能名はすべてダミーに置き換えています。
何を自動化したか(全体像)
作ったのは、対話形式のCLIツール1本(generate_mnt.py)です。入力を受け取ってから、最終成果物を吐くまでを1パイプラインにしました。
[入力] [処理] [出力]
チケット文字列 ──┐ ┌─ 停止SQL (.sql)
または ─────┼→ ①ID特定 → ②期間調整 → ③整合性 ─┼─ 反映手順書 (.txt)
手動の機能選択 ──┘ (Excel) (重複/保護) (cron/hash) └─ タイムライン (.html)
入力は2モードを用意しました。
-
モードA:チケット管理ツールの文字列をそのまま貼り付ける(例:
工事情報共有 #12345: 【03.SYS_X】メンテ・2026/06/24 12:00:00~2026/06/24 14:00:00)。ここからチケット番号・期間・対象システムを正規表現で抜き出す。 - モードB:機能を一覧から番号で選び、期間を直接入力する(チケットがまだ無い/チケットと実態がズレている場合用)。
この記事で扱う「勘所」は、ほぼすべて モードAの①ID特定 と ②③の期間調整・整合性チェック に集中しています。順に見ていきます。
勘所①:Excelの「上方向検索」をどうコードに落とすか
問題:台帳が「人間の目」を前提に作られている
最初の関門が、ID特定でした。台帳のExcelはこういう構造になっています(イメージ)。
| システム/工事名(C列) | … | ext_id(E列) | … | 工事種別(Q列) |
|---|---|---|---|---|
| 03.SYS_X | 03.SYS_X, 45 SYS_X | |||
| 会員向け帳票出力 | 27 | |||
| 申込フォームA | 108 | |||
| 帳票PDF出力 | 120 |
見ての通り、「工事種別が書かれている行」と「ext_idが書かれている行」が違います。工事名は親見出しの行(セル結合されていることも多い)にあり、ext_idはその下にぶら下がる子行に入っています。人間は「SYS_Xの工事だな、じゃあこの塊のIDだ」と目で追えますが、VLOOKUP のような「キーと同じ行の値を返す」関数では、工事種別の行にはIDが無いので拾えません。
解決:マッチした行から「上方向」に最初のIDを探す
そこで、「工事種別がマッチした行を起点に、そこから上へ遡って最初に見つかった有効なext_idを採用する」 というロジックにしました。これが今回のタイトルにもある「上方向検索」です。
for i, row in enumerate(rows):
koji = str(row[16]) if row[16] is not None else "" # Q列=工事種別
tokens = [t.strip() for t in koji.split(",") if t.strip()]
matched = any(_match(tok, system_name) for tok in tokens)
if matched:
# マッチ行 i から上へ遡り、最初に出てくる有効な ext_id を採用
for j in range(i, -1, -1):
eid = rows[j][4] # E列=ext_id
if isinstance(eid, (int, float)) and int(eid) > 0:
found.append(int(eid))
break
ポイントは range(i, -1, -1) の 降順ループ です。i 行目(マッチした行)から 0 行目に向かって走査し、最初にヒットした ext_id だけを取って break します。セル結合で値が空欄になっているセルや、見出しだけで ID を持たない行を、自然にスキップできるのが利点です。
一般化すると:表計算ソフトの台帳は「人間が目で構造を補完する」前提で作られていることが多く、行と行の関係(親子・グルーピング)がデータとして明示されていません。これをコード化するときは、
VLOOKUP的な同一行マッチを早めに諦めて、ヒット地点を起点に決まった方向へ探索する発想に切り替えると、台帳の構造をそのまま扱えます。
勘所②:似て非なるIDの区別(プレフィックス問題)
問題:03.WEDGE と 45 WEDGE は別物
工事種別の列には 03.SYS_X,45 SYS_X,SYS_Z のように、複数の種別がカンマ区切りで入っています。ここで厄介なのが、03.SYS_X と 45 SYS_X は名前が似ているが別の工事種別 という点です。先頭の 03. や 45 は工事番号のプレフィックスで、これが違えば対象も違います。
一方で、チケット文字列の側は表記が揺れます。【03.SYS_X】 とプレフィックス付きで来ることもあれば、単に SYS_X とだけ書かれることもある。「プレフィックスがある時は厳密に、無い時はゆるく」一致させたい、というのが現場の感覚でした。
解決:プレフィックスの有無で一致条件を切り替える
そこで、両者のプレフィックス有無を見て判定モードを変えるようにしました。
_KOJI_PREFIX_RE = re.compile(r"^\d+[.\s]+") # "03." や "45 " にマッチ
def _strip_koji_prefix(s):
return _KOJI_PREFIX_RE.sub("", s).strip() if s else ""
# 比較ロジック(抜粋)
if sn_has_prefix and tok_has_prefix:
# 双方プレフィックス有 → 完全一致でしか通さない(03.SYS_X と 45 SYS_X を区別)
matched = (sn_u == tok_u)
else:
# 少なくとも片方プレフィックス無 → プレフィックスを剥がして比較(ゆるく一致)
matched = (sn_stripped_u == _strip_koji_prefix(tok).upper())
「両方に番号が付いているなら、その番号まで含めて完全一致でなければ別物扱い。どちらかが番号無しなら、番号を無視して名前で合わせる」という、現場の判断をそのまま条件分岐にした形です。
さらに、「このシステム名が来たら、個別ID特定をスキップして全機能を対象にする」 という特例も必要でした。基盤系のメンテ(例では 基盤A / 基盤B とします)は影響範囲が全機能に及ぶため、いちいち上方向検索する意味がないからです。これは特例の集合をひとつ定義して逃がしました。
# このシステム名は上方向検索を介さず全機能を対象にする
ALL_SELECT_SYSTEMS = {"基盤A", "基盤B"}
一般化すると:業務ルールには「原則」と「例外」が必ずあります。例外を
ifの連鎖で本体ロジックに埋め込むと、後から読めなくなります。例外は特例テーブル(集合・辞書)として本体の外に括り出すと、ルールの追加が「テーブルに1行足すだけ」になり、本体の見通しが保てます。
勘所③:期間重複の「合算」と「専用ID分離」
ここからが、このツールで一番頭を使った部分です。
問題:同じ機能に複数のメンテ期間が重なる
例えば、機能A・B・Cを 22:00〜翌6:00 で止める依頼が既にあるとします。そこへ、機能B・C・Dを 23:00〜翌5:00 で止める新しい依頼が来た。BとCは期間が重なっています。このとき、
- B・Cは期間をマージして「より広い方(22:00〜6:00)」で1本にまとめたい(cronを二重に打ちたくない)
- でもDは今回の依頼にしか出てこない。Dを巻き込んで22:00〜6:00にすると、Dを必要以上に長く止めてしまう
つまり「重なるIDは合算、重ならないIDは元の期間のまま」と、1つの依頼を2つの設定に割らないといけないのです。これを手作業でやると、まず間違いなくどこかで取り違えます。
解決1:区間の重複判定とマージ
まず、既存設定との重複を検出します。古典的な区間重複の判定式(開始A <= 終了B かつ 開始B <= 終了A)そのものです。
def detect_overlap(selected_ids, stop_dt, reboot_dt, candidate_records):
sel_set = set(str(e) for e in selected_ids)
ms, mr = stop_dt, reboot_dt # マージ後の停止・再起動
hits, shared_str = [], set()
for r in candidate_records:
common = sel_set & set(r["ext_ids"].split(",")) # 共通するIDがあるか
if not common:
continue
rs = _parse_dt(r["effective_stop_datetime"])
rr = _parse_dt(r["effective_reboot_datetime"])
if rs <= reboot_dt and stop_dt <= rr: # ← 区間が重なる
hits.append(r)
ms, mr = min(ms, rs), max(mr, rr) # 期間を広い方へ拡張
shared_str |= common # 合算対象のIDを記録
shared_ids = [e for e in selected_ids if str(e) in shared_str] # 重なったID
exclusive_ids = [e for e in selected_ids if str(e) not in shared_str] # 重ならないID
return ms, mr, hits, shared_ids, exclusive_ids
肝は、重複した期間(ms〜mr)だけでなく、どのIDが重複に絡んだか(shared_ids)/絡まなかったか(exclusive_ids)を同時に返しているところです。期間のマージだけして全IDに同じ期間を適用してしまうと、上の例のDが巻き添えになります。それを防ぐために、IDを2グループに仕分けます。
解決2:専用IDを別SQLに分離する
exclusive_ids(=今回の依頼にしか出てこないID)があった場合は、SQLを2本に分けて生成します。
if do_split and shared_ids:
# 共有ID → 合算した広い期間で1本
gen_sql(ticket_no, ..., eff_stop, eff_reboot, shared_ids, ...)
# 専用ID → 元の依頼期間のまま、_excl を付けた別ファイルへ
gen_sql(f"{ticket_no}_excl", ..., stop_dt, reboot_dt, exclusive_ids, ...)
ファイル名に _excl(exclusive)を付けて物理的に別ファイルにすることで、後段のcron生成・手順書・タイムラインすべてが「2つの独立した設定」として自然に扱えます。重複していた既存SQLは、自動的に削除対象(手順書の rm リスト)に積みます。
一般化すると:集約(マージ)を実装するときは、集約に参加した要素と、しなかった要素を必ず分けて持つのが事故防止の定石です。「全部まとめて同じ扱い」にした瞬間、巻き込み事故が起きます。
shared/exclusiveのように対象を二分し、出力もファイル単位で分離しておくと、後工程が単純になります。
勘所④:長期メンテを壊さない「保護チェック」
合算ロジックと並んで重要だったのが、「うっかり上書き事故」の防止です。
ある機能に対して、来週から1ヶ月間の長期メンテ設定が既に入っているとします。そこへ「今夜2時間だけ止める」短い依頼が来て、同じ機能を含んでいた場合——何も考えずにSQLを流すと、1ヶ月の設定が2時間に潰されてしまいます。これは期間の「重複」とは別問題で、「新しい設定の終了時刻より後まで続く既存設定があるか」 を見る必要があります。
def check_longer_maintenance(selected_ids, new_reboot_dt, candidate_records):
"""今回の終了時刻より後まで続く既存設定を持つIDを検出する"""
found = []
for r in candidate_records:
hit_ids = set(str(e) for e in selected_ids) & set(r["ext_ids"].split(","))
if not hit_ids:
continue
er = _parse_dt(r["effective_reboot_datetime"])
if er > new_reboot_dt: # 既存の方が後まで続く=長期設定
for eid in hit_ids:
found.append((int(eid), r, er))
return found
検出したら、自動で勝手に処理せず、人間に判断を委ねます。
⚠ 【長期メンテナンス保護】
以下の導線には、今回の終了日時(2026/06/16 02:00:00)より後まで続く
既存のメンテナンス設定があります:
ext_id=108(申込フォームA)
既存設定: #12300 〜 2026/07/15 06:00 まで
対応を選択してください:
1) これらを今回の対象から【除外】する(既存の長期設定を保持)← 推奨
2) 含めて期間を【合算】する(短い方を長い方に合わせる)
3) そのまま含める(既存設定が上書きされる可能性あり)
デフォルト(推奨)を「除外」にしているのがポイントです。1 以外の未定義入力が来た場合も安全側の「除外」に倒す実装にしています。
一般化すると:自動化ツールほど「全自動にしない勇気」が要ります。不可逆かつ判断が割れる操作は、検出までを自動化し、最終決定は人間に返す。そのとき、デフォルトと未定義入力を必ず安全側に倒しておくと、惰性のEnter連打でも事故りません。
安全装置:ハッシュ検証・cron競合・冪等性
主要ロジック以外にも、運用ツールとして外せない仕掛けをいくつか入れました。
① 資材のハッシュ検証:生成したSQLの sha1 を計算して手順書に期待値として埋め込みます。本番サーバーで sha1sum を取り、期待値と突き合わせることで「配置したファイルが生成物と一致しているか」を確認できます。コピペ事故の検知用です。
② cron競合・深夜0時チェック:生成したcron発火時刻が、既存設定と同時刻だったり、深夜0時±20分に入っていたり(他バッチと競合しやすい)すると警告します。
if total <= MIDNIGHT_WARN or total >= 24*60 - MIDNIGHT_WARN:
warns.append(f"[警告] {nh:02d}:{nmi:02d} が深夜0時±{MIDNIGHT_WARN}分以内(他バッチ競合の可能性)")
③ 冪等性(再実行耐性):モードAでチケットを複数件バッチ処理する際、1件処理し終えるごとに processed=1 をCSVへ即保存します。ループ途中で異常終了しても、処理済みの分は二度と再処理されません。
if csv_row is not None:
csv_row["processed"] = "1"
save_tickets(mode_a_tickets_all) # 都度フラッシュ
一般化すると:バッチ処理は「最後にまとめて保存」ではなく「1件処理したら即コミット」が安全です。途中で落ちても、再実行で重複や取りこぼしが出ません。
可視化:タイムラインHTML
ロジックが複雑になると、レビューする側が「本当にこの設定で合ってるの?」を確認しづらくなります。そこで、最終的な設定状態を Ganttチャート風のHTML に吐くようにしました(依存ライブラリなしの素のHTML+JS、1ファイル完結)。
- 横軸が時間、行がext_id(機能)
- 青いバーが「停止している期間」
- オレンジの縦線が「cronの発火時刻」(0系・1系の2サーバー分)
これにより、「機能Aが二重に止まっていないか」「cronが意図した時刻に飛ぶか」が一目で分かります。ロジックの自動チェック(cron競合警告など)と、人間の目視チェック(タイムライン)を両輪にすることで、安心して本番に流せるようになりました。
一般化すると:複雑な計算結果ほど、最終状態を1枚の絵にして「人間が直感で違和感に気づける」状態を作る価値が高いです。検算ロジックを足すより、可視化1枚の方が早くバグに気づけることがあります。
LLMと複雑ロジックをコード化するコツ
最後に、この手の「現場ルールのコード化」をLLMと進めるうえで効いたことを3つ。
-
まず日本語で業務ルールを箇条書きにする。「工事種別がマッチした行の上方向にIDがある」「番号付き同士は完全一致」「重なったIDだけ合算」——こうした暗黙知を先に言語化しておくと、LLMへの指示が安定し、生成コードのレビューもしやすくなります。コードを書く前の「ルールの棚卸し」が一番重要でした。
-
1関数1責務に割る。
detect_overlap(重複検出)、check_longer_maintenance(保護)、check_cron_conflicts(cron)と、ルールごとに関数を独立させました。ルールが追加・変更されたとき、影響範囲がその関数に閉じます。 -
例外は本体の外(特例テーブル)に逃がす。
ALL_SELECT_SYSTEMSのように、原則ロジックと例外データを分けると、本体の見通しが効きます。
まとめ
| Before(手作業) | After(ツール化) | |
|---|---|---|
| ID特定 | Excelを目で追って手で拾う | 工事種別から上方向検索で自動取得 |
| 期間重複 | 頭の中で突き合わせ | 区間判定で自動検出・合算・専用ID分離 |
| 上書き事故 | 気づけるかは運次第 | 長期設定を検出し人間に判断を返す |
| 成果物 | SQL・cron・台帳を個別に手作業 | SQL/手順書/タイムラインを一括生成 |
| 検証 | 目視のみ | sha1・cron競合警告・タイムライン可視化 |
「Excelの上方向検索」も「期間の合算と専用ID分離」も、それ単体は地味なロジックです。ですが、こうした現場の暗黙知こそ、言語化してコードに翻訳する価値が大きいと感じました。複雑な判断を全自動にせず「検出は自動・決定は人間」に切り、最終状態を可視化する——この設計方針は、メンテナンス設定以外の運用自動化にもそのまま流用できます。
Excel台帳が業務の中心になっている現場は多いはずです。同じような自動化に取り組む方の手がかりになれば、と思います。