Zenn
🤦‍♂️

本番環境でやらかしたので生成AIでChrome拡張を自作して再発防止

2025/02/12に公開

はじめに

この記事でご紹介するコードは、あくまで参考としてご利用ください。実際にご使用される際には、十分にご自身の責任でお願いいたします。万が一問題が発生した場合でも、当方では責任を負いかねますので、何卒ご了承ください。

プロローグ

それは、ある日の夕方のことでした。私は本番環境で動いているサービスへ、軽微な修正を加えたリビジョンをデプロイしようとしていました。具体的には、Cloud Runサービスで動いているサーバーのDockerイメージを、事前にpushしておいた最新リビジョンに差し替えて再デプロイするという簡単な作業です。

前回のデプロイでは比較的大規模な改修を行い、非常に気を張って作業をしていました。問題なく完了したこともあり、「今日の変更はマイナー修正だから大丈夫」という心の余裕が生まれていました。通常であれば、事前にロードバランサのルーティングを変更して、デプロイ作業するサーバはクライアントから切り離しますが、今回はそのまま差し替えても問題ないと判断してしまったのです…。

今思うと、あまりに楽観的で、本番環境を扱うことへの意識が足りなかったと反省しています。

再デプロイは順調に終わり、私はログを確認しました。しかし、そこには大量の赤文字(Error)が本番環境で増え続け、アラートメールがすぐさま飛んできました。焦燥感と共に、心の中で警報が鳴り響いていました。「これはやばい…」と思いながら、再度デプロイしたものを確認すると、なんと今回のCloud Runサーバーとは関係ない別のDockerイメージを登録してしまっていたのです…!

すぐに正しいイメージを再デプロイしました。別サーバのエラーハンドリングのおかげで、幸いにもユーザー様への影響はありませんでした。ですが、この出来事は、デプロイ作業への拭えないトラウマを私に残し、その後の改善へとつながる貴重な教訓となりました。

Cloud Runサービスでの更新作業

GCPのCloud Runの画面は図1のようになっており、更新する際は画面上部「新しいリビジョンの編集とデプロイ」から簡単に行えます。

[図1]
図1

遷移先では図2のように現在このサービスで動いているコンテナイメージが表示されます。赤枠で囲った選択ボタンでイメージの変更をすることができます。

[図2]
図2

選択ボタンを押すとAtifact Registryにあるイメージが一覧でき、GUIで選択することができます。階層化されたイメージに対して、トグルを開きながら選択していくことになります。

図3
図4

▼最終的には同名イメージのタグ違いを選択(表示されているのはハッシュ値)
図5

この手順で新しくしたいイメージを選択し、再度デプロイするというのが更新作業でした。

Chrome拡張

私たちの部署の運用では、1つのGCPプロジェクト内でも部署内の様々なサービスやプロジェクトが実装されています。命名規則は整備されているため乱雑にはなっていないのですが、それでもイメージ選択画面ではずらっと候補が並ぶことになります。エピローグでご紹介したミスはこの選択を間違えたというとても初歩的な内容です。

初歩的ではありますが、注意力の無い私が再発防止をするためにできることは無いかと考えました。新規のCloud Runサービス作成はともかく、更新作業においては同名のイメージのタグ名が異なるコンテナへの差し替えになります。そこで、現在選択されているイメージと同名のイメージのみが候補に表示されていれば間違えようが無いです。GCP自体にそのような仕組みはありませんが、Chrome拡張によって実現できるのではないかと思いついたのです。

少し調べたらChrome拡張は自作でき、一般公開せずともローカルに配置したファイルを読み込むことでオレオレChrome拡張を使えるということがわかりました。Chrome拡張を自作したことはもちろんありませんでしたが、生成AIを使用してコーディングさせてみました。

作成されたコード

試行錯誤をして、最終的にできたコードはこちらになります。
PC内の任意の場所にディレクトリを用意し、以下のmanifest.jsonとcontent.jsを配置します。

  • manifest.json
{  
  "manifest_version": 3,  
  "name": "dockerイメージ絞る君",  
  "version": "1.0",  
  "description": "Filters elements on specific pages based on a pattern.",  
  "permissions": ["activeTab"],  
  "content_scripts": [  
    {  
      "matches": [  
        "https://console.cloud.google.com/run/deploy/asia-northeast1/*"  
      ],  
      "js": ["content.js"]  
    }  
  ]  
}
  • content.js
function onUrlChange(callback) {  
    let currentUrl = window.location.href;  
    
    // 定期的にURLの変更をチェック  
    setInterval(() => {  
      if (currentUrl !== window.location.href) {  
        currentUrl = window.location.href;  
        callback();  // URLが変更されたときにコールバックを実行  
      }  
    }, 1000);  // 1秒ごとにチェック  
    
    // History APIを使った場合の変更を検知  
    window.addEventListener('popstate', () => {  
      callback();  
    });  
  }  
    
  // コールバック関数で実行したい処理を定義  
  function handleUrlChange() {  
    console.log('URLが変更されました:', window.location.href);  
    // 必要な初期化や処理をここで行う  
    // 例えば、initializeElements()を呼び出すなど  
    initializeElements();  
  }  
    
  // URLの変更を監視  
  onUrlChange(handleUrlChange);  
    
  // 初期ロード時にも実行  
  handleUrlChange();  

let dynamicFilterRegex = null;  
let secondaryFilterRegex = null;  
let isInitialized = false;  
  
// 初期化関数を定義  
function initializeElements() {  
  const inputElement = document.querySelector('input.mat-mdc-input-element');  
  const buttonElement = Array.from(document.querySelectorAll('button')).find(button => button.textContent.trim() === '選択');  
  
  if (inputElement && buttonElement) {  
    console.log('Input element and button found');  
  
    setupInputListener(inputElement);  
    setupButtonListener(buttonElement);  
  
    updateFilters(inputElement.value);  
  
    return true; // 初期化が成功したことを示す  
  }  
  return false; // 初期化が未完了  
}  
  
// MutationObserverの設定  
const observer = new MutationObserver((mutationsList) => {  
  if (isInitialized) return;  
  
  for (let mutation of mutationsList) {  
    if (mutation.type === 'childList' && initializeElements()) {  
      isInitialized = true;  
      observer.disconnect(); // 要素が見つかったら監視を停止  
      break;  
    }  
  }  
});  
  
// ページロード時に監視を開始  
observer.observe(document.body, { childList: true, subtree: true });  
  
function setupInputListener(inputElement) {  
  inputElement.addEventListener('input', (event) => {  
    updateFilters(event.target.value);  
  });  
}  
  
function setupButtonListener(buttonElement) {  
  buttonElement.addEventListener('click', () => {  
    console.log('選択ボタンが押されました');  
  
    const listObserver = new MutationObserver((mutationsList) => {  
      for (let mutation of mutationsList) {  
        if (mutation.type === 'childList') {  
          const imageContainer = document.querySelector('div.cfc-loader-content.ng-star-inserted');  
          if (imageContainer) {  
            console.log('リストがロードされました');  
            filterImages(imageContainer);  
            listObserver.disconnect(); // 監視を停止  
          }  
        }  
      }  
    });  
  
    listObserver.observe(document.body, { childList: true, subtree: true });  
  });  
}  
  
function updateFilters(inputValue) {  
  const repoPath = inputValue.split('@sha256')[0].trim();  
  const trimmedRepoPath = repoPath.substring(0, repoPath.lastIndexOf('/'));  
  const repoName = repoPath.substring(repoPath.lastIndexOf('/') + 1);  
  
  const escapedRepoPath = escapeRegExp(trimmedRepoPath);  
  dynamicFilterRegex = new RegExp(`^${escapedRepoPath}`);  
  
  const escapedRepoName = escapeRegExp(repoName);  
  secondaryFilterRegex = new RegExp(`^${escapedRepoName}`);  
  
  console.log(`一次フィルタ用正規表現: ${dynamicFilterRegex}`);  
  console.log(`二次フィルタ用正規表現: ${secondaryFilterRegex}`);  
}  
  
function filterImages(imageContainer) {  
  if (!dynamicFilterRegex && !secondaryFilterRegex) {  
    console.log('フィルタ用の正規表現が設定されていません');  
    return;  
  }  
  
  const images = imageContainer.querySelectorAll('li');  
  images.forEach(image => {  
    const loadMoreButton = image.querySelector('button[data-load-more-button]');  
    const hasLoadMoreText = image.textContent.includes('その他の結果');  
    const isLoadMoreLine = loadMoreButton !== null || hasLoadMoreText;  
  
    const textContent = image.textContent.trim();  
    const shouldDisplay = isLoadMoreLine || dynamicFilterRegex.test(textContent) || secondaryFilterRegex.test(textContent);  
  
    image.style.display = shouldDisplay ? 'block' : 'none';  
  
    if (shouldDisplay && !isLoadMoreLine) {  
      const expanderButton = image.querySelector('button.cfc-tree-node-expander');  
      if (expanderButton) {  
        addToggleEventListener(expanderButton, imageContainer);  
      }  
    }  
  });  
}  
  
function addToggleEventListener(expanderButton, imageContainer) {  
  expanderButton.addEventListener('click', () => {  
    console.log('トグルボタンがクリックされました');  
    observeForGrandchildren(imageContainer);  
  });  
}  
  
function observeForGrandchildren(imageContainer) {  
  console.log('親要素を監視開始:', imageContainer);  
  
  const toggleObserver = new MutationObserver((mutationsList) => {  
    for (let mutation of mutationsList) {  
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {  
        console.log('開きました!');  
        filterGrandchildItems(imageContainer);  
        toggleObserver.disconnect(); // 監視を停止  
        break; // 追加された子要素が見つかったらループを抜ける  
      }  
    }  
  });  
  
  toggleObserver.observe(imageContainer, { childList: true, subtree: true });  
}  
  
function filterGrandchildItems(imageContainer) {  
  const listItems = imageContainer.querySelectorAll('li');  
  listItems.forEach(item => {  
    const loadMoreButton = item.querySelector('button[data-load-more-button]');  
    const contentElement = item.querySelector('.cfc-tree-projected-content');  
    const textContent = contentElement ? contentElement.textContent.trim() : '';  
  
    const hasLoadMoreText = loadMoreButton || textContent.includes('その他の結果');  
  
    if (loadMoreButton) {  
      console.log('「その他の結果」ボタンが見つかりました:', textContent);  
    } else if (textContent.includes('その他の結果')) {  
      console.log('「その他の結果」テキストが見つかりました:', textContent);  
    } else {  
      console.log('「その他の結果」が見つかりませんでした:', textContent);  
    }  
  
    const shouldDisplay = hasLoadMoreText || (secondaryFilterRegex && secondaryFilterRegex.test(textContent)) || dynamicFilterRegex.test(textContent);  
  
    item.style.display = shouldDisplay ? 'block' : 'none';  
  
    if (loadMoreButton) {  
      loadMoreButton.addEventListener('click', () => {  
        console.log('「その他の結果」ボタンがクリックされました');  
        observeDomChanges(imageContainer);  
      });  
    }  
  });  
}  
  
function observeDomChanges(container) {  
  const observer = new MutationObserver((mutationsList, observer) => {  
    for (const mutation of mutationsList) {  
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {  
        console.log('新しい要素が追加されました');  
        filterGrandchildItems(container);  
        observer.disconnect(); // 変更を監視した後、再実行を防ぐために監視を停止  
        break;  
      }  
    }  
  });  
  
  observer.observe(container, { childList: true, subtree: true });  
}  
  
function escapeRegExp(string) {  
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');  
}  

Chromeの右上にある「拡張機能」> 「拡張機能を管理」で、「パッケージ化されていない拡張機能を読み込む」でこれらを配置したディレクトリを選択することで、一般に配布されている拡張機能と同じように扱えます。

拡張機能有効時の挙動

この拡張機能を有効にしたときに、先述のデプロイ作業をしようとすると以下のようになります。

図6

開いたCloud Runサービスに登録されているイメージ名を取得し、動的に選択肢がフィルタされています。これであれば同様の作業でのミスは起こりえません。

さいごに

このように、GUIでのデプロイミス再発防止として生成AIを使用してChrome拡張を自作してみました。CUIでコマンド主体のデプロイにする方針もありますが、GUIをカスタマイズしてみようという思い付きで試してみました。
もっとサクッとできると思いきや、JavaScriptに親しみが無いなか試行錯誤するのは大変でした。
今回の学びとして、以下の3点がポイントだったと思います。

  • 生成されたコードの特にわからない部分は抜き出してどういう処理なのかを聞く
  • 実際に動作させたときに期待と違ったら、期待する振る舞いとこのコードで何が起こったかを丁寧に伝え、修正させる
  • フロント的なふるまいだけでなく、例えばPythonならこういう処理を書きたいです といったアルゴリズムのロジックで伝える

世の中で無限に言われていることではありますが、生成AIを使って出来ることが広がっているので、皆さんも是非新しいことにチャレンジしてみてはいかがでしょうか。

また、現在チームメンバーがこのような手作業デプロイを無くすためCICDの仕組みを構築中です。インフラ自動化に興味ある人はぜひミスミへ来てください。

ミスミ DataTech ブログ

Discussion

ログインするとコメントできます