TECH PLAY

KINTOテクノロジーズ

KINTOテクノロジーズ の技術ブログ

984

SeleniumConf & AppiumConfとは ブラウザ自動化・モバイル自動化のコミュニティを世界中から集める国際カンファレンスです。 Software Freedom Conservancyが運営しており、SeleniumおよびAppiumのコアコントリビューターも登壇します。 Selenium 5に関する今後の展望、WebDriver BiDi、Appium、Playwright、Cypress、AIテスト、セキュリティテスト、アクセシビリティテストなど、幅広いテーマを扱っています。 基調講演・ハンズオンワークショップ・ネットワーキングの3つの形式で構成されています。 全セッションに英語字幕とスペイン語通訳が提供されるなど、グローバルな参加者を意識した運営が特徴です。 今年は 2026年5月6日〜5月8日 にスペインのバレンシア・Veles e Ventsにて開催され、20カ国以上から約350名が参加しました。 1日目はハンズオンワークショップ、2〜3日目がカンファレンス本番という構成でした。 https://seleniumconf.com/ 今回、KINTOテクノロジーズから 呂文佳 と パンヌウェイ の2名が登壇しました。世界の舞台でKINTOテクノロジーズの取り組みを発信できた、非常に貴重な機会となりました。 登壇者 発表タイトル(英語) 発表タイトル(日本語) 呂文佳 From 50% Cost Reduction to 90% Coverage: Playwright × AI for Non-Technical QA Teams コスト50%削減からカバレッジ90%へ〜Playwright × AI:コーディング経験が浅いQAチームの実践 パンヌウェイ Scaling Mobile Test Automation with Appium and AI: Real Lessons from KINTO Technologies モバイルテスト自動化のスケーリング Appium と AI の活用 バレンシアまでの道のり CfPの告知から登壇当日まで、約8ヶ月の期間がありました。最初のきっかけは2025年9月、会社の同僚が社内SlackチャンネルでCfP(Call for Proposals)開始を告知してくれたことです。「ぜひ挑戦してみてください!」というその一言が、すべての始まりでした。 時期 マイルストーン 内容 2025年9月 CfP告知 会社の同僚がSlackチャンネルでCfP開始を告知。「ぜひ挑戦してみてください!」の一言がきっかけ 2025年10〜11月 CfP作成・社内レビュー チーム内でレビューを依頼し、発表内容と構成を確認し、ブラッシュアップ 2025年12月〜2026年2月 CfP提出・当選通知 最終タイトルを確定。SeleniumConfより提出確認メールを受信後、CfP当選の通知を受ける 2026年2〜4月 採択・スライド作成・発表練習 社内のAIファースト勉強会で日本語版の発表練習を実施。KTC室町オフィスのJCT(会議スペース)で英語版の発表練習と発音練習を2回実施 2026年5月 本番登壇 🎉 バレンシア Veles e Vents にて45分登壇 :::details 承認・ビザ手続きについて CfP当選後は社内手続きも必要でした。社長に登壇内容を説明して承認をもらい、その後カンファレンスチームとメールでやり取りしながらビザ申請の手続きを並行して進めました。国際カンファレンスへの参加には、こうした社内外の調整も大切な準備の一部です。 ::: 参加セッション一覧 日時 セッション名 登壇者 05/06 09:00〜 Making Sense of Mobile Automation with Appium and WebdriverIO to turn frustration into understanding Wim Selles, Christian Bromann 05/07 11:20〜 Quantum Automation: Rethinking Selenium & Appium in the Age of AI Baris Sarialioglu 05/07 11:20〜 From 50% Cost Reduction to 90% Coverage: Playwright × AI for Non-Technical QA Teams 呂文佳 05/07 13:20〜 Test Automation Workflows with Cursor Filip Hric 05/08 Scaling Mobile Test Automation with Appium and AI パンヌウェイ 2026/05/06(1日目):ワークショップ 1日目はカンファレンス本番前のワークショップデーです。終日1つのセッションに集中して参加しました。 Making Sense of Mobile Automation with Appium and WebdriverIO 登壇者 Wim Selles — 2025 Tokyo Test Festにも参加した方 Christian Bromann 内容と学び このワークショップでは、Appiumを ゼロからインストールして2分以内にセットアップが完了する ことを実際に確認しました。セットアップの簡単さを体感できたことで、導入ハードルへの認識が変わりました。 ワークショップ後、登壇者のWim Sellesさんと直接Appiumについて相談する機会も得ました。特に「 要素特定にIDを使うかXPathを使うか 」という実務的なテーマについて深く議論し、それぞれのメリット・デメリットを理解することができました。 ID: 高速・安定だが、開発側でIDが付与されていない場合は使えない XPath: 柔軟性が高いが、UI変更に弱くFlaky Testの原因になりやすい :::details ディナーでの交流(1日目夜) 1日目の夜はカンファレンス関係者とのディナーがあり、非常に充実した交流の場となりました。 Kazuaki Matsuo さんと同席し、Appiumの導入経験や現場の課題について情報交換をしました。 Oscar Barrios さん(昨年も登壇された方)とは今年のイベントの印象やコミュニティの動向についてお話ししました。 Ivan del Viso さん(昨年も登壇された方)は、ご自身が開発したアプリを使った自動化テストのデモを見せてくれました。英語で1行のテストシナリオを書くだけで、実行・分析・ダッシュボードレポートの生成まですべてが完結するシステムで、非常に印象的でした。 ::: 2026/05/07(2日目):カンファレンス本番 2日目からいよいよカンファレンス本番です。複数のトラックが並行して開催され、関心のあるセッションを選びながら参加しました。 セッション①:Quantum Automation — AI時代のSelenium & Appium Quantum Automation: Rethinking Selenium & Appium in the Age of AI (登壇者:Baris Sarialioglu) AI時代における自動化テストの在り方を問い直す内容でした。セッション中に聴衆から質問が上がった場面では、登壇者が次のように答えたのが印象に残っています。 セッション②:呂さんの発表(11:20〜40分) From 50% Cost Reduction to 90% Coverage: Playwright × AI for Non-Technical QA Teams KINTOテクノロジーズの同僚・呂文佳さんによる発表です。コーディング経験が浅いQAメンバーでもPlaywright × AIを活用することでテストカバレッジを大幅に向上させた実践事例を紹介しました。同じチームのメンバーが国際カンファレンスで発表する姿は、大きな刺激になりました。 セッション③:Test Automation Workflows with Cursor(13:20〜90分) Test Automation Workflows with Cursor 登壇者: Filip Hric Cursor(AI統合コードエディタ)を活用したテスト自動化ワークフローについて90分間フルで講演されました。ClaudeとGitHub Copilotの基本的な設定・活用方法がメインテーマで、Mobile QAで一緒に作業している岡さんに教えていただいた内容とほぼ同じでした。世界のカンファレンスでも同様のアプローチが注目されていると確認できたことは収穫でした。 この日のセッション終了後、翌日に控えた自分の発表準備のためホテルへ戻り、最終調整を行いました。 2026/05/08(3日目):自分の発表 いよいよ自分の登壇日です。朝から会場でスライドの確認と発音練習を行いました。 発表概要 項目 内容 タイトル(英語) Scaling Mobile Test Automation with Appium and AI タイトル(日本語) モバイルテスト自動化のスケーリング Appium と AI の活用 発表時間 40分 + 質疑応答 会場 Veles e Vents(バレンシア) 参加状況 満席 なぜCfPが採択されたのか :::message 国際カンファレンスで登壇できることは非常に光栄なこと。世界中のテストエンジニアが集まる場でKINTOテクノロジーズの取り組みを発信できる貴重な機会です。 ::: 今回、採択につながったポイントは、単なる成功事例の紹介ではなく 現場で直面した課題と改善の過程を正直に共有した 点にあると考えています。 実際に直面した課題と、改善によって得られた成果を正直に共有 したこと(理想論ではなく現場の実態) 具体的な数値 で課題を提示:128件のテスト実行に12時間かかっていたという課題を可視化 Claude・Copilot・DevinAI の実践的な活用方法と3ツールの比較 聴衆が 持ち帰ってすぐに実践できるチェックリスト を提供したこと 発表構成(45分) # セクション名 内容 1 The Breaking Point 128テスト・実行12時間という限界点と、その背景にある課題 2 Framework Evolution 課題解決のためのフレームワーク再設計と進化の過程 3 AI Integration Claude・Copilot・DevinAIの統合で得られた成果と課題 4 Tools to Culture ツール導入にとどまらない「チーム文化」への変革 5 Visual Regression Test AIを活用したビジュアルリグレッションテストの実践 6 Real Impact & Takeaways 実際の改善数値と、明日から使える実践チェックリスト 当日の会場の様子と反響 20カ国以上から参加者が集まる満席の会場での登壇でした。発表後の質疑応答では予想以上に多くの質問が集まりました。 ドイツ在住のパキスタン出身のエンジニア から、Appiumの社内導入に関する具体的な質問を多数いただきました。自分たちのチームでも同様の課題を抱えており、ぜひ参考にしたいとのことでした。 複数の参加者から「 自分たちの導入方法の参考になった 」と直接声をかけていただきました。 発表がただの情報共有にとどまらず、世界中のエンジニアの実務に役立ったと感じることができ、大変嬉しかったです。 スポンサー企業との交流と自動化テストツールの調査 カンファレンスにはテスト自動化ツールのスポンサー企業がブースを設けており、担当者から直接、各ツールの詳細を聞く貴重な機会がありました。ここでは、カンファレンスの場で実際に収集した情報をもとに、4つのツールを比較・整理します。 各ツールの概要 ツール 特徴 CloudBeat テスト自動化、実行、分析、モニタリングを統合したクラウド型の品質管理プラットフォーム Sauce Labs エンタープライズ向けクラウドテストの先駆的存在。Salesforce、Twitter、Bank of America などの大手企業で採用実績がある BrowserStack 3,500以上のブラウザ/OS組み合わせ・30,000台以上の実機デバイスを持つ業界でも有数の大手 LambdaTest 2026年1月に「TestMu AI」へリブランドしAIネイティブ化。KaneAIによる自然言語からのテスト自動生成が特徴 機能比較マトリクス 評価項目 CloudBeat Sauce Labs BrowserStack LambdaTest Webテスト ◎ ◎ ◎ ◎ モバイルアプリテスト △ ◎ ◎ ◎ コードレステスト ◎ △ △ ○ 並列実行 ◎ ◎ ◎ ◎ CI/CD連携 ◎ ○ ◎ ◎ AI機能 ○ ○ ○ ◎ 実機デバイス数 少 多 最多 多 価格 中 高 高〜中 低〜中 日本語サポート △ △ ○ △ 初心者にとっての導入しやすさ ○ △ △ ○ 凡例:◎ 優秀 ○ 良好 △ 要改善 各ツールの詳細印象 :::details CloudBeat 強み コードレステストが充実しており、プログラミング経験がなくてもテスト作成・実行が可能 Selenium・Appium・Cypress・Playwright等の主要フレームワークと幅広く統合 AIドリブンなテストレポートで根本原因分析(Root Cause Analysis)が容易 テスト実行・管理・モニタリングをすべて1プラットフォームで完結できる 弱み モバイルアプリテスト(ネイティブアプリ)の対応デバイス数がBrowserStackなどに比べて少ない 英語のみの対応で、日本語UIや日本語サポートが提供されていない 他ツールと比べると国内での導入事例や公開情報が少なく、長期利用を前提とする場合は追加調査が必要 ::: :::details Sauce Labs 強み 長年の実績を持つエンタープライズ向けプラットフォーム。信頼性・安定性が高い SOC2 Type II・GDPR・ISO 27001等のセキュリティ・コンプライアンス認証を取得 Webテストもモバイルアプリテストもどちらもカバーできるオールラウンダー 弱み 今回確認した条件では4ツールの中でも価格面の負担が大きく、中小チームや予算が限られた組織には慎重な検討が必要 コードレステスト機能が弱く、プログラミングスキルがないメンバーには難易度が高い 一部CI/CDツール(AWS CodePipeline・GitLab CI等)に非対応 ::: :::details BrowserStack 強み 実機デバイス数・ブラウザ組み合わせ数が 業界最多水準 (30,000台以上)で、網羅的なテストが可能 Accessibility Testing・Percy Visual Testingなど高度な付加機能が充実 カスタマーサポートの評判が良く、ドキュメントが整備されている 弱み 料金が高額で、コスト面での負担が大きい 基本的にSelenium/Appium等の自動化スクリプト記述が必要で、非エンジニアには敷居が高い ネットワーク遅延や実機テストでの偽陽性(誤検知)が報告されることがある ::: :::details LambdaTest(現 TestMu AI) 強み KaneAI により、自然言語でテストケースを記述するだけでスクリプトが自動生成される 今回比較した条件では、4ツールの中でもコストパフォーマンスが高いと感じた Jenkins・GitLab CI・Azure Pipelines・AWS CodePipelineを含む幅広いCI/CDツールに対応 HyperExecuteによる超高速な並列テスト実行が可能 弱み 実機デバイスの実際の可用性がBrowserStackに比べると劣る場合がある テスト分析レポートの詳細度が競合より低く、根本原因分析に限界がある UIのナビゲーションが複雑で、習熟に学習コストがかかる ::: 総合評価と推奨 現状のチーム状況(非エンジニアメンバーでも扱いやすいこと、Web・モバイルアプリの両方に対応できること)を踏まえた評価です。 ツール 評価 推奨優先度 コメント LambdaTest ★★★★☆ 第1候補 AI機能、コスト、幅広いCI/CD連携の観点から、現状のチームに最も適している CloudBeat ★★★★☆ 第2候補 コードレス機能が充実。ただし、モバイル対応やサポート面は追加確認が必要 BrowserStack ★★★☆☆ 将来候補 エンジニア体制が拡充した場合の有力な候補 Sauce Labs ★★☆☆☆ 保留 現状のチーム構成では導入ハードルが高く、コスト面でも慎重な検討が必要 感想・学び 海外カンファレンスならではの気づき :::message 現地ではコミュニケーション手段としてLinkedInが主流で、名刺交換の機会はあまり多くありませんでした。 現地で知り合った方とは、LinkedInで連絡先を交換しました。海外エンジニアとのつながりを作る際は、事前にLinkedInのプロフィールを整えておくことをおすすめします。 ::: 現地での交流から得た、世界のQA事情についての気づきも多くありました。 開発とQAを兼務しているエンジニアが多い — 日本のように専任QAチームが分離している体制は珍しく、開発者自身がテストも担う形が世界的には一般的なようです ノーコードの自動化ツールを利用している人は少数派 — コードを書いてテストを自動化するスタイルが主流で、ノーコードツール利用者は少数派という印象でした 自動化テストの現状と課題 :::message alert 世界のAppiumユーザーの声:「自動化テストをやめるべきか考えている」という方もいました。 理由は Flaky Test (不安定なテスト)の問題です。今回成功しても次回失敗する、という繰り返しによって、テスト自動化そのものへの信頼が揺らぐケースが世界的にも多いようです。 ::: Flaky Testは自動化テストにおけるグローバルな課題であり、その解消こそが現代のテストエンジニアに求められていることを、改めて実感しました。AIツールを活用した根本原因分析や、要素特定用のIDを活用した安定したテスト設計が、この問題への有効なアプローチとなるでしょう。 まとめ 約8ヶ月の準備を経てバレンシアの国際舞台に立ち、世界中のエンジニアとKINTOテクノロジーズの取り組みを共有できたことは、自分にとって大きな経験となりました。セッションで得た知識・現地での人脈・ツール各社との情報交換、そして自分の発表への反響——すべてが今後の業務に活きる財産です。来年のSeleniumConfにも引き続き注目していきたいと思います。
はじめに こんにちは、2026年3月入社の大園です! 本記事では、2026年3月入社のみなさまに入社直後の感想をお伺いし、まとめてみました。 前の方からの質問に次の方が答えるリレー形式でお届けします。 KINTO テクノロジーズ(以下、KTC)に興味のある方、そして、今回参加下さったメンバーへの振り返りとして有益なコンテンツになればいいなと思います! 大園博昭 ![大園のプロフィール画像](/assets/blog/authors/ozono/202603-newcomer/ozono.jpg =300x) 自己紹介 Engineering Officeの大園です! Xは こちら 会社横断での経営・組織課題を解決するという幅広いミッションを受け持つチームの中で、主にソフトウェアテスト周りや生成AI周りの技術部分を担当しています 所属チームの体制は? 上長が名古屋、他チームメンバー3人が東京、私が福岡所属と全国に散らばっています やっていることは全員バラバラの個人商店なチームですが、毎日ZoomやSlackでワイワイやっております KTCへ入社したときの第一印象?ギャップはあった? 入社前からいろんな話を聞かせていただいていたのと、自分がどんなことで貢献できそうかというイメージをしっかり持っていたため、特に大きなギャップはなかったです。 現場の雰囲気はどんな感じ? 福岡オフィスは去年できたばかりというのもあり、人数もまだ10人未満。自分たちで一から作っている感が楽しいです! ブログを書くことになってどう思った? 今後どんどん書いていきたいと思います! ブログではないですが、6/4に福岡オフィスにおいてE2Eテスト x AIというテーマで 勉強会を開催しました 。今後もKINTOテクノロジーズの魅力発信や福岡エンジニアを盛り上げる活動を積極的にしていきたいと思います! 高さん ⇒ 大園さんへの質問 福岡の豚骨らーめんの中で、一番美味しい店について教えてください これは戦争が起きかねない非常にセンシティブな質問ですね…私は昔ながらのとんこつラーメンが好きなので 元祖長浜屋 です! 森重香一 ![森重さんのプロフィール画像](/assets/blog/authors/ozono/202603-newcomer/morishige.jpg =300x) 自己紹介 業務システム開発部業務システムGの森重です。 KINTO事業のシステムのうち、債権領域のビジネスアナリスト(BA)を担当しています。 BAは開発チームと業務部門の橋渡し役で、要件整理や関係者調整が主な仕事です。 大阪オフィス勤務で、前職は業務システム開発に携わっていました。 所属チームの体制は? 直属チームは4名(東京3名・大阪1名)で、同期入社の田平さんも同じチームです。 田平さんの紹介と合わせて読んでいただけると体制がより伝わるかと思います。 離れていても Slack やオンラインミーティングで日々連携できています。 KTCへ入社したときの第一印象?ギャップはあった? 「テックベンチャー」という言葉から、技術中心で少し距離感のある組織をイメージしていました。 実際に入社してみると、勉強会や交流イベントが多く、所属チーム外の方とも自然に話せる機会がたくさんありました。 良い意味でイメージとのギャップを感じています。 現場の雰囲気はどんな感じ? 落ち着いた雰囲気の中で、それぞれが主体的に動いている印象です。 困ったときは気軽に相談できますし、Confluence や Slack を活用した情報共有が活発で部門を超えて動きやすいです。 個人で進める部分とチームで議論する部分のバランスが良いと感じています。 ブログを書くことになってどう思った? これまで読む側だったので、書くのは少し緊張しました。 ただ振り返ってみると、入社後の気づきを言語化する良い機会になりました。 入社を検討されている方に少しでも雰囲気が伝わればうれしいです! 大園さん ⇒ 森重さんへの質問 最近行った旅行でオススメの観光地などあれば教えてください! 昨年末に友人と犬山城へ行きました。 今年の大河ドラマ「豊臣兄弟!」が始まることもあり、放送前に戦国ゆかりの地を訪れてみようと思ったのがきっかけです。 実際に訪れてみると、テレビや写真で見るよりも天守はコンパクトな印象でしたが、その分、現存天守ならではの歴史をぐっと身近に感じることができました。 黒い外観も印象的で、少し離れて見ると存在感がありました。 城下町は大規模な観光地というより歩きながらゆっくり楽しめる雰囲気で、食べ歩きや散策をしながら半日ほど過ごせます。名古屋からのアクセスもよく、週末の小旅行としておすすめです。 佐藤誠 ![佐藤さんのプロフィール画像](/assets/blog/authors/ozono/202603-newcomer/sato.jpg =300x) 自己紹介 プラットフォーム開発部xREグループDBREチーム 所属の佐藤誠です。 KTCのDBRE業務全般を担当してます。AWS Aurora MySQLをたくさん使っていますが、PostgreSQLも社内で安全に使えるように整備を進めています。 所属チームの体制は? DBREチームは5名体制で、全員神保町所属です。 KTCへ入社したときの第一印象?ギャップはあった? 入社前にオフィス見学などもさせてもらっており、印象通りでした。 現場の雰囲気はどんな感じ? 神保町オフィスはいわゆる古書街から少し南に下ったところにあり、オフィス周りを歩くだけで発見がたくさんありますね。ラーメン、カレー、中華、洋食も名店がたくさんあり、ランチは毎日の楽しみです。オフィス内はまだ知り合いが少ないのでこれからですが、皆さんシゴデキな雰囲気があります。 ブログを書くことになってどう思った? いつ来るかと思ってたら入社して3か月経ってました。 森重さん ⇒ 佐藤さんへの質問 散歩が趣味とのことですが、歩いていて起きた忘れられない出来事やハプニングがあれば教えてください! 道を聞かれたり、駅近だと寸借詐欺っぽいことはよくあるくらいですかね。。。散歩の醍醐味としては駅間の移動が点の移動に対して、徒歩は線・面の移動になるので、いろいろな気づきがありますね。雰囲気の良い公園とか地元の小さな雑貨屋が見つかったり。河川敷とか線路沿いをよく歩きますが、人の営みを感じられて良いです。 田平亨斗 ![田平さんのプロフィール画像](/assets/blog/authors/ozono/202603-newcomer/tahira.jpg =300x) 自己紹介 業務システム開発部業務システムGの田平です。 KINTO事業のシステムのうち、与信領域の開発・運用を担当します。 東京の室町オフィス勤務ですが、最近は神保町にいることも多いです。 所属チームの体制は? 直属のチームは4名体制で、東京3名大阪1名です。 加えて、他の部や協力会社の方に入っていただいている形です。 KTCへ入社したときの第一印象?ギャップはあった? 入る前はもっとトヨタ色が強いと思っていました。実際は逆で、そういった物に縛られない集団を目指していると認識しています。 案外車に乗ってない方も多い印象。サーキットを走られているような方もいて多様性があると思います。 現場の雰囲気はどんな感じ? 中途採用で経験豊富な方ばかりで、落ち着いた印象です。 オフィスごとに結構雰囲気も違いそうで、色々行ってみたくはあります。 ブログを書くことになってどう思った? 選考時から存在は知っていたのですが、入社後無事こうしてブログを書けているのがまずは良かったという感じです。 佐藤さん ⇒ 田平さんへの質問 明治大正の建物のおすすめがあれば教えてください(迎賓館赤坂離宮は自分も大好きです) なんといっても上野・湯島の旧岩崎邸庭園がオススメです。都会の喧騒を離れた落ち着いた環境で、装飾やタイル、金唐革紙など見どころが多い所です。 昭和初期にはなりますが、白金台の庭園美術館(旧朝香宮邸)も赤坂離宮と同様に豪奢な印象、アールデコの装飾が非常に多く見応えがあるかと思います。 ついでに大阪あたりだと芦屋のヨドコウ迎賓館が非常にオススメ。福岡はめちゃくちゃ現代ですが、天神地下街が素晴らしい。名古屋には何と言っても明治村があります! 高斯 ![高さんのプロフィール画像](/assets/blog/authors/ozono/202603-newcomer/gao.png =300x) 自己紹介 デジタル戦略部 データサイエンスGの高(si gao)です。 データ分析を担当しています。 東京オフィス勤務です。 趣味:サッカー観戦 / プレー / 漫画アニメ鑑賞 / ゲーム 所属チームの体制は? デジタル戦略部のデータサイエンスGに所属し、データ分析を担当しています。 チームは3名体制です。 分析に使っている言語・ツール例:Python / SQL / BIツール など。 KTCへ入社したときの第一印象?ギャップはあった? 入社前はもっとお堅い雰囲気を想像していました。実際はフラットで風通しがよく、データやテクノロジーで事業を前に進めようという空気の強い組織だと感じました。 エンジニアやデータ分野のスペシャリストが多く、それぞれの専門性を持ち寄って課題に取り組んでいる点が良い意味でのギャップでした。 現場の雰囲気はどんな感じ? 落ち着いていて相談しやすい雰囲気で、分からないことを聞きやすい環境です。 東京オフィスは設備も整っていて働きやすく、チームを越えた交流の機会もあります。 ブログを書くことになってどう思った? 入社して間もないタイミングで、自分の言葉で会社や仕事を振り返る良い機会になりました。 リレー形式で前の方からの質問に答えるのが新鮮で、楽しく書けました。 田平さん ⇒ 高さんへの質問 サッカーかなりお好きなのではないかと思いますが、どういった所に魅力を感じていますか? 大きく3つあります。 どんな体型・体格の人でも活躍できるところ 。背が高くなくても、足が特別速くなくても、技術や判断力、ポジショニングで輝ける選手がたくさんいます。多様な個性が同じピッチで成立するのがサッカーの面白さだと思います。 想像力が問われるところ 。次のプレーを読み、スペースを作り、味方の動きを予測する——決まった正解のない中で発揮される創造性に毎回ワクワクします。 チームワーク 。一人のスター選手だけでは勝てず、全員が役割を全うして初めて勝利に近づく。個と組織のバランスが取れた瞬間の美しさが一番の魅力です。 さいごに みなさま、入社後の感想を教えてくださり、ありがとうございました! KINTOテクノロジーズでは日々、新たなメンバーが増えています! 今後もいろんな部署のいろんな方々の入社エントリが増えていきますので、楽しみにしていただけましたら幸いです。 そして、KINTOテクノロジーズでは、まだまださまざまな部署・職種で一緒に働ける仲間を募集しています! 詳しくは こちら からご確認ください!
1. はじめに Quality Engineering Gのとみよしです。 私が所属するQAチームではオープンソースのテスト管理ツールであるTestLinkを採用しています。 :::message TestLinkとは テストケースの作成・管理からテスト計画の立案、実行結果の記録まで一元管理できるオープンソースのテスト管理ツールです。 ::: TestLinkへの結果入力は工数がかかるため、Excelマクロで結果を一括入力できるXMLファイルを生成する仕組みを運用していました。 しかしこのExcelマクロには2つの課題がありました。 チーム内にMac・Windowsユーザーが混在しており、OS差分を考慮した 2本のコードを管理 しなければならなかった ローカル環境でのみ動作するため、 更新のたびにファイル共有が必要 だった これらを解決するために、Microsoft 365のCopilotを活用してGoogle Apps ScriptでWebアプリを作ってみました。 コードは一行も自分で書いていません。 その過程を紹介します。 2. 解決策としてGAS Webアプリを選んだ理由 課題の本質は「 環境に依存している 」ことでした。ブラウザで動くWebアプリにすれば、OSの違いもローカル管理の問題も一度に解決できます。 その中でGAS(Google Apps Script)を選んだ理由は主に3つです。 ① OS差分がなくなる ブラウザで動くため、MacでもWindowsでも同じように使えます。 ② Google スプレッドシート(以下、GSSと表記)と連携できる プロジェクトコードや担当者などのマスタデータをGSSで管理し、Webアプリからそのまま参照できます。更新もGSS上で行うだけなので、誰でもマスタ更新が可能になります。 ③ 無料で使える Google アカウントがあれば追加コストなしで開発・運用できます。 3. 完成したWebアプリの紹介 主な機能は3つです。 ① 一括生成 テストケースが連番になっている場合に使います。開始番号と終了番号を入力するだけで、その範囲をまとめたXMLファイルを出力できます。 例)No.1〜100をまとめて一括出力 ② 個別生成 テストケースを個別に指定して出力します。飛び番号のケースや、特定のケースだけ再テストしたい場合に便利です。 例)No.1, 3, 5, 7, 10を個別に出力 ③ マスタ管理 プロジェクトコードや担当者名をGSSで管理しています。画面右上の「マスタを編集」ボタンからGSSに直接遷移して編集できます。 4. 開発の進め方 〜Copilotと約15往復した話〜 「WebアプリはGASで作ろう」と決めたものの、私は簡単なGASのコードしか書けず、Webアプリに関する知識はほとんどありませんでした。そこで活用したのが Microsoft 365のCopilot です。 Copilotへの最初の一手 まず既存のExcelマクロのコードをそのままCopilotに貼り付け、以下のように依頼しました。 「このExcelマクロと同じ機能をGAS(Google Apps Script)で書いてください」 たったこれだけです。Copilotは既存コードの意図を読み取り、GAS向けのコードを出力してくれました。 Excelマクロという既存の資産がそのまま設計書代わりになった わけです。 機能を1つずつ育てていった 最初から全機能を一度に作ろうとせず、以下の順番で1つずつ機能を追加していきました。 一括生成機能の作成 個別生成機能の追加 マスタ機能の追加 細かい仕様の追加 Webアプリ化 1ステップずつCopilotに依頼し、動作を確認してから次に進む流れです。結果的に 10〜20往復 のやり取りになりました。 エラーが出たらそのまま貼り付ける 開発中にエラーが発生することも何度かありましたが、対処法はシンプルです。 エラーメッセージをそのままCopilotに貼り付ける それだけで原因の説明と修正コードを返してくれました。エラー対応は 1〜2往復で解消 できました。 5. やってみて気づいたこと Copilotへの伝え方のコツ 一度に複数のことを依頼するより、 1つの依頼につき1つの機能追加 に絞ったほうがスムーズでした。欲張って「あれもこれも」と依頼すると、意図が伝わりにくくなることがありました。 QAエンジニアでもWebアプリが作れた 着手前は「自分にWebアプリなんて作れるのか」という不安がありました。しかし Copilotに既存のコードを渡してやり取りを繰り返すだけで、気づけば動くWebアプリができあがっていました。 プログラミングの専門知識がなくても、「何をしたいか」を言葉で伝える力があれば十分です。 Before / After Excelマクロ GAS Webアプリ OS対応 Mac/Windows別管理 ブラウザで統一 コード管理 実質2本 1本 マスタ管理 Excelファイル Google スプレッドシート 共有のしやすさ ファイル共有が必要 URLを共有するだけ 「はじめに」で挙げた2つの課題が、どちらもきれいに解消されました。 6. おわりに 「QAエンジニアにはコードが書けない」なんてことはありません。 AIを活用すれば、 既存の資産を渡すだけで新しい環境向けのコードを生成してもらえます。 私自身、コードを一行も書かずにWebアプリを完成させることができました。 同じようにExcelマクロの管理に悩んでいるQAエンジニアの方がいれば、ぜひ一度試してみてください。まず手元にあるマクロのコードをCopilotに貼り付けるところから始めれば大丈夫です。 この記事が、AIを活用した業務改善の第一歩を踏み出すきっかけになれば嬉しいです。
はじめに QE(Quality Engineering)グループのMobileチームのokapiです。 Claude Codeがあれば、もうAIはこれ一本でいいよね? そう思ってました。 仕様整理、テスト設計、業務効率化アプリ(GASでWebアプリ)の開発も、Claude Codeがあると進めやすくなりましたし、 社内で話していても「Claude Code便利」という話をいろんな方から聞いてました。 そんなある日、社内で「Gemini CLIを検証できる」タイミングがありました。 最初は、一緒に作業しているメンバーが使いたいと言っているので、ついでに試そうくらいな感じでした。 :::message 「Claude Codeで困ってないし、Geminiは必要?」 ::: ふと思ったんですが、 :::message 「Claude Codeを毎日使っているからこそ、できなかった所をGeminiで解消できたり……?」 ::: そんな好奇心から、QEグループとして「Claude Code × Gemini CLI」の併用効果を検証してきました。 結果、当初の予想を超える発見がありました。本記事では、その結果をお伝えします。 Gemini CLIとは? :::message alert 【公開時点での重要なお知らせ】 Gemini CLIは「2026年6月18日」でリクエスト受付が終了し、後継の Antigravity CLI に移行されました。 ※法人向けは引き続き利用可能です。 ::: Gemini CLIは、Googleが提供する生成AI「Gemini」をコマンドライン(CLI)から使えるツールです。 普段Webブラウザで触るチャットUIとは違い、ターミナル上で対話できるのが特徴です。 Claude Codeとの主な違いを整理するとこんな感じです。 ツール 強み 弱み Claude Code コード生成・リファクタ・レビューが得意。仕様理解や論理的な推論に強い 動画解析ができない。20ページ超のPDFは苦手。Web検索の鮮度がやや弱い Gemini CLI 動画・画像・大容量PDFを一括解析できる( analyzeFile )。Google検索で最新情報を取得( googleSearch ) コード生成はClaude Codeで十分なケースが多い こちらの「Claude Code」の弱みを「Gemini CLI」で解消して、 「Claude Code」をさらに使いやすくするのが、今回の検証の狙いです。 graph LR A[Claude Code] B[Gemini CLI] A -.-> X[動画解析] A -.-> Y[50ページ超PDF] A -.-> Z[最新Web情報] B ==> X B ==> Y B ==> Z X --> R[Claude Code × Gemini CLI<br/>QA業務の幅が広がる] Y --> R Z --> R style A fill:#dae8fc,stroke:#6c8ebf,stroke-width:2px style B fill:#ffe6cc,stroke:#d79b00,stroke-width:2px style R fill:#d5e8d4,stroke:#82b366,stroke-width:2px linkStyle 0 stroke:#6c8ebf,stroke-width:1.5px linkStyle 1 stroke:#6c8ebf,stroke-width:1.5px linkStyle 2 stroke:#6c8ebf,stroke-width:1.5px linkStyle 3 stroke:#d79b00,stroke-width:3px linkStyle 4 stroke:#d79b00,stroke-width:3px linkStyle 5 stroke:#d79b00,stroke-width:3px linkStyle 6 stroke:#aaa,stroke-width:1px linkStyle 7 stroke:#aaa,stroke-width:1px linkStyle 8 stroke:#aaa,stroke-width:1px 🔵 点線:Claude Codeの弱み / 🟠 太線:Gemini CLIの強み 検証の進め方 社内のAI検証プロジェクトの一環として、以下の流れで進めました。 項目 内容 検証期間 3週間(2026年4/20〜5/8) 検証ユースケース Claude Codeとの併用(Claude Code × Gemini CLI) 検証方法 同じQA作業を「Claude Code単独」と「Claude Code × Gemini」の両方で実施し、結果を比較 記録方法 Confluenceに検証レポートとして記録 :::message Claude Code内にGemini CLIのMCP(外部ツール連携)を構築することで、 ツールを切り替えずにClaude Code内でGeminiが使えるようになります。 ::: 検証結果まとめ :::message Claude Code単体で使うよりも、Geminiを併用した方がQA作業を効率化できました。 ::: 検証した7つのユースケースを、効果が大きかったものから紹介していきます。 No. ユースケース Claude Codeのみ Claude Code × Gemini併用 コメント 1 動画解析 ❌ ✅ Claude Codeでは不可。Geminiを入れることで可能に 2 ダブルチェック ❌ ✅ Claude CodeとGeminiの視点でレビューできる 3 QA設計(大容量PDF) ⚠️ ✅ 50ページ超の仕様書も一括解析可能に 4 画像解析 ⚠️ ✅ 大容量・複数画像の一括処理が可能 5 Web検索 ⚠️ ✅ 最新情報を効率よく収集 6 並列処理 ⚠️ ✅ 重い処理をバックグラウンドに逃せる 7 コード生成 ✅ ✅ ここはClaude Codeのみで十分 それぞれを少し詳しく紹介します。 効果が大きかった併用パターン 1. 動画解析:Claude Codeのみでは不可。Geminiを入れることで可能に これが一番大きいポイントです。 Claude Code単独では、動画ファイルを直接解析することはできませんが、 Gemini CLIの analyzeFile を使うと、「音声・映像をまとめて解析」できます。 QA業務では「動画」を扱う場面が意外と多いです。 たとえば、 登壇練習動画を見て、話し方や資料の流れをレビュー JIRAの不具合チケットのエビデンスから内容を確認してもらい手順をテキスト化 新しいツールの使い方などの手順を聞くときに動画から詳細手順を確認 が新たにできるようになりました。 :::message 補足:Claude Code単独では動画解析はできませんが、Skills(拡張機能)を使えばClaude側でも対応自体は可能です。 Claude Code:Claude Skills( claude-video-vision など)を使い、「動画をフレーム画像に分解してから解析する」という形で対応 Gemini CLI:動画ファイルをそのまま読み込んで、映像と音声を同時に理解。操作の因果関係や正確なタイムスタンプの特定も可能 そのため、動画解析はGeminiで行うのがおすすめです! ::: 2. ダブルチェック:Claude CodeとGeminiの視点でレビューできる Claude Codeに「Confluence資料のレビューして」とお願いしても、同じセッション内では同じ視点でしかチェックできません。 つまり、Claude Codeが見落とした観点はそのまま見落とされてしまうんです。 そこで、Geminiの chat を併用すると、独立した第三者の視点でレビューしてもらえるようになりますので、 テスト設計やドキュメント修正をする時に、役立ちました。 Claude Codeで作成 Claude CodeとGemini CLIでレビュー 指摘内容をClaude Codeで反映 という流れにすると、Claude CodeのハルシネーションをGeminiが指摘してくれるので、より正確になります。 「人にレビュー依頼する前に」Claude Code × Geminiが代わりにダブルチェックしてくれるので精度が上がりました。 3. QA設計(大容量PDF):50ページ超でも一気に解析 QA設計では、操作手順書やイベント仕様書など、ページ数が多いPDFを読むことが頻繁にあります。 Claude Codeのみだと、20ページのPDFが上限で、それ以上だと読んでいなかったりすることがあるので、 分割して渡したり、md形式に変換したりと、ひと手間必要でした。 Gemini CLIの analyzeFile を使うと、50ページ超のPDFも一括で解析できます。 案件の仕様整理でも、必要な観点を抽出できました。 4. 画像解析:大容量・複数枚の一括処理 Figmaのデザイン画像やスクリーンショットを、AIに見せて確認してもらうケースは多いです。 Claude Codeのみでも画像は読めるのですが、大容量や複数枚をまとめて処理するのは少し苦手でした。 Geminiの analyzeFile を使うと、複数の画像を一気に渡してまとめて分析できます。 QAの表示確認テスト設計では、複数画面のFigmaデザインをまとめて確認した上、設計できるようになりました。 5. Web検索:最新情報を効率よく収集 Claude Codeにも組み込みのWeb検索機能はありますが、確認範囲が狭めで、たまに古い情報を返してくることがありました。 Geminiの googleSearch は、Google検索を直接使うので、最新情報を効率よく収集できます。 6. 並列処理:重い処理をバックグラウンドに逃せる 動画解析や大容量PDF解析は、どうしても時間がかかります。 Claude Codeのみで重い処理を回している間、メイン作業が止まってしまうのが地味にストレスでした。 Claude Codeにもサブエージェント機能はありますが、Geminiをサブエージェントとして併用すると、Claudeが苦手な動画や大容量PDFを任せられるだけでなく、Claudeのコンテキストを圧迫せずに重い処理を進められるメリットがあります。 また、別のAIモデルが裏で動いてくれるので、Geminiが動画解析している間もClaude側でテスト設計を進められるなど、メイン作業を止めずに並行作業できる(スピード早い)点も便利でした。 ここでは「Claude Code → Geminiサブエージェントに重い処理を委譲」という役割分担で活用してます。 効果が変わらなかったパターン コード生成:Claude Codeのみで十分 コード生成についてはClaude Codeのみで困りませんでした。 Webアプリ作成や自動化コードのレビューは、Claude Codeだけでも問題なく進められており、Geminiを足しても大きな差は感じませんでした。 「全部Geminiに頼った方がいい」というわけではなく、 得意分野を見極めて使い分けるのが大事だと感じます。 検証してわかったこと 「単独 → 併用」で何が変わる? 3週間使ってみた感想を、表に整理しました。 観点 Claude Codeのみ Claude Code × Gemini併用 扱える資料の範囲 コード・テキスト・画像・短いPDF 動画・大容量PDF・複数画像 レビューの質 自己レビュー(同一視点) 独立した第三者視点 情報の鮮度 やや古い情報を含むことあり 最新情報を効率よく収集 作業の並列性 サブエージェント並列は可能 重い処理をバックグラウンド処理 初心者が始めるなら、どう使い分ければいい? 「全部のユースケースで併用」となると、最初はハードルが高いので、 まずはClaude Codeを日常使いの主軸にして、以下のシーンでだけGeminiを呼び出すスタイルがおすすめです。 シーン 使うツール 普段のコード生成・テスト設計 Claude Code 動画を扱う時 Gemini CLI( analyzeFile ) 50ページ超のPDFを扱う時 Gemini CLI( analyzeFile ) 重要な変更のダブルチェック Gemini CLI( chat ) 最新情報の調査 Gemini CLI( googleSearch ) 上記で使ってみて、慣れてきたら使う場面を広げていきましょう。 おわりに 「Gemini CLIを社内検証で使ってみた!」というテーマで、 Claude Codeとの併用効果を紹介しました。 今後もAIを活用して、QA業務の効率化を進めていきたいと思います。 これからGemini CLIを試してみる方の参考になれば嬉しいです!
はじめに こんにちは。KINTOテクノロジーズ(以下、KTC)の AIファーストG に所属している、野村宏樹です。 このたび、2026年6月1日付で Microsoft MVP(Most Valuable Professional)を Microsoft Foundry カテゴリ で受賞しました。Azure 上での生成AI/AIエージェント開発を中心に発信してきた活動を評価いただいたもので、とても光栄に思っています。本記事では、受賞のご報告と、その背景にあったKTCでの活動についてご紹介します。 ![Microsoft MVP として届いたトロフィー](/assets/blog/authors/nomura/2026-06-11-microsoft-mvp-foundry/MVP-trophy.jpg =400x) Microsoft MVP として届いたトロフィー 受賞の証として、MVPプロフィールも公開されています。 https://mvp.microsoft.com/ja-JP/mvp/profile/93ebd9f1-a8c0-492c-8cc2-adc7f5f980e9 Microsoft MVP プロフィールページ Microsoft MVP とは Microsoft MVP は、Microsoft 製品・技術に関する深い知見と、技術コミュニティへの継続的な貢献を称えて Microsoft 社が「個人」に授与するアワードです。直近およそ1年間の活動が審査対象で、毎年の更新審査があります。技術領域ごとにカテゴリが分かれており、世界で約 3,000 名が受賞しています。受賞すると、製品の早期アクセスや製品チームと直接つながれるチャネル、年次の Global MVP Summit への招待などの機会があります。 私が受賞した Microsoft Foundry カテゴリ は、比較的新しいカテゴリ名です。 :::message Microsoft Foundry のカテゴリーは、Azure 上で生成AIアプリやAIエージェントを開発するための基盤に関する領域です。Azure AI Foundry がリブランディングされたもので、モデルやエージェントの開発・運用をまとめて扱うエコシステムを指します。 ::: 受賞につながった活動:KTCのカルチャーに支えられて 今回の受賞は、KTC の Output文化・登壇文化 、そして全社的に生成AIの業務活用を進めている環境に、大きく後押ししていただいたものでした。2025年8月にKTCへ入社して以来、「やってみたことを外に出す」「登壇して共有する」が当たり前にある雰囲気のなかで、自然と活動を続けることができました。 発信は、大きく2つの軸で行ってきました。 ① KTC事例の共有 KTCでは2023年から社内向けの生成AIチャットツールを導入し、全社で生成AIの活用を進めています。その現場で得た学びを、たとえば「社内で使うチャットアプリを自分たちで内製する意義」といったテーマで共有してきました。このテーマは、NoMaps 2025(札幌)で当時のチームリーダーである和田颯馬さんと共同で登壇しています。 https://no-maps.jp/program/tech/121500/ 社内で使うものを自分たちの手で作るからこそ、現場のフィードバックを素早く反映でき、業務に本当に必要な形へ磨き込んでいけます。そうした内製ならではの価値を、実体験ベースでお話ししました。 ② ユースケースを軸とした技術活用の共有 個人的に「面白い」「使えそう」と感じた技術を PoC で試し、ブログにまとめ、少し抽象化したものを登壇してOutputする、というサイクルで発信してきました。テーマは MCP(Model Context Protocol)、マルチエージェント(AutoGen / Microsoft Agent Framework)、Azure AI Foundry エコシステム、ローカルLLM/SLM(Foundry Local・phi-4)など。単に「何ができるか」だけでなく、 どんなユースケースで、どう使うか まで踏み込むことを意識しています。 コミュニティ活動としては、KTCが主催する 名古屋LLM MeetUp をはじめ、なごあず(JAZUG 名古屋支部)、JAZUG、すきやねん Azure など各地のコミュニティで登壇させていただきました。2025年度は個人として、ブログ31本・登壇11回ほどの活動になりました。 2025年度の登壇は以下のとおりです。 日付 イベント タイトル 2025/6/21 なごあず(JAZUG 名古屋支部) AzureでMCPサーバ!!どう活用する? 2025/7/23 名古屋LLM MeetUp(KTC主催) チャットアプリ失敗談!製造業業務への生成AI導入 2025/8/16 JAZUG×なんでもCopilot #jaznancopa Azure AI Foundry Portal デモ 2025/9/15 NoMaps 2025(札幌) 生成AI最前線:最新トレンドと活用事例(和田颯馬さんと共同) 2025/11/21 名古屋LLM MeetUp(KTC主催) AzureでのAIエージェントはここから!Azure Functions × AI 2025/11/27 YonaAz AzureでAIエージェント、さて何から始める? 2025/11/29 JAZUG Shizuoka リアルタイム音声モデル gpt-realtime を使った音声対話ツール 2025/12/6 なごあず(JAZUG 名古屋支部) ローカルとクラウドLLMのハイブリッドAI活用 2025/12/26 すきやねんAzure ハイブリッド構成 Queue Polling 2026/2/28 AgentCon Tokyo エージェント開発とライフサイクル管理 ~構築から AgentStore 基盤まで~ 2026/3/14 なごあず(JAZUG 名古屋支部) gpt-realtime-1.5 モデルでスタックチャン これから これからも、 「どう使うか(ユースケース)」を大事にしながら、技術のOutputを続けていきたい と思っています。AIにより技術のキャッチアップや開発は非常にしやすくなっています。大事なのはその手段をどこにどう使うと価値がでるのか?だと思っています。引き続きAzure・生成AI・AIエージェント領域の技術を突き詰めながら、ユースケースを軸にした発信を続けていきます。 改めて、日々の活動を支えてくれているKTCの環境と、関わってくださったみなさまに感謝します。 発信内容は、個人のZennにもまとめています。よろしければこちらもご覧ください。 https://zenn.dev/nomhiro 最後に KINTOテクノロジーズでは、まだまださまざまな部署・職種で一緒に働ける仲間を募集しています! 詳しくは こちら からご確認ください! 最後までお読みいただき、ありがとうございました。
Flutter SDK 3.29 → 3.38 へのアップグレード中に遭遇した retrofit / analyzer / custom_lint の依存衝突を解いた記録です。 「なぜ pub solver が答えを見つけられないのか」から順を追って説明します。 はじめに はじめまして、KINTOテクノロジーズ(KTC)でモバイルアプリ(Flutter)の開発を担当しているHand-Tomiです。 Flutter SDK のメジャーアップグレードを進めていたある日、 dart pub get が突然失敗するようになりました。エラーメッセージを読み解くと、 retrofit_generator と custom_lint がそれぞれ別々の analyzer バージョンを要求していて、両者が要求する analyzer のバージョン差はわずか 1 パッチ。けれど pub solver ではどうやっても解けない デッドロック でした。 本記事では、その原因と解決方法、そしてなぜ dependency_overrides が罠になるのかを順を追って解説します。同じ Flutter プロジェクトで似た衝突に遭遇した方の参考になれば幸いです。 :::message pub solver は dart pub get の内部で動く依存解決エンジンです。すべての制約を同時に満たすバージョンの組み合わせを探すのが役割で、本記事ではこの後も繰り返し登場します。 ::: :::message バージョン管理でよく耳にする SemVer (Semantic Versioning) は、バージョン番号を MAJOR.MINOR.PATCH の 3 桁で表す規約です。本記事では以降、それぞれ メジャー / マイナー / パッチ と表記します。 MAJOR (メジャー):互換性のない変更(壊れる) MINOR (マイナー):後方互換のある機能追加 PATCH (パッチ):後方互換のあるバグ修正 たとえば analyzer 8.4.0 → 8.4.1 は パッチ リリースなので、本来なら「コードを変えずに上げても安全」なはずです。本記事の 3 節で、この「はず」が崩れる仕組みを掘り下げます。 ::: TL;DR Flutter のメジャーアップグレード中に dart pub get が失敗。原因は retrofit_generator と custom_lint_visitor が同じ analyzer に対して別々のバージョンを要求していたこと。 最終的な解: retrofit: ^4.9.2 + retrofit_generator: ^10.2.1 。 pubspec のピン 2 行ですっきり解決します。 dependency_overrides には罠があり、推奨しません。 pub get は通っても dart_style が知らぬ間に昇格してビルドが壊れます。 この衝突は 構造的な問題 です。 analyzer のメジャーが上がるたびに再発します。 1. 始まり — 止まってしまったビルド Flutter SDK 3.29.2 から 3.38.10 へのメジャーアップグレードを進めていました。 flutter_riverpod 2 → 3、 freezed 2 → 3、 analyzer 6 → 8 といった大きな変更が立て続けに来ていて、いつもなら flutter upgrade のあと dart pub get で済む作業のはずでした。 ところがビルドが止まりました。要点だけ抜き出すと、こういうメッセージです。 And because retrofit_generator >=10.2.4 depends on analyzer >=8.4.1 <13.0.0 and custom_lint_core >=0.7.0 depends on custom_lint_visitor ^1.0.0, if retrofit_generator >=10.2.4 and custom_lint_core >=0.7.0 then analyzer 9.0.0. And because custom_lint >=0.8.1 depends on both analyzer ^8.0.0 and custom_lint_core 0.8.1, custom_lint >=0.8.1 is incompatible with retrofit_generator >=10.2.4. So, because app depends on both retrofit_generator ^10.2.5 and custom_lint ^0.8.1, version solving failed. pub solver が答えを見つけられなかったのです。片方を上げればもう片方が壊れ、下げればまた別のところが壊れる。普通のバージョン衝突ではなく、 デッドロック でした。 2. 誰と誰が戦っているのか 主な登場人物は次のとおりです。 analyzer — Dart コードの静的解析エンジン(共有資源) retrofit_generator — .g.dart を生成するコードジェネレータ custom_lint / custom_lint_core / custom_lint_builder — lint プラグインのランナーと、その builder custom_lint_visitor — analyzer の AST を訪問する visitor 実装 問題の核心は、 analyzer という共有資源 です。両陣営が同じ analyzer に対して別々のバージョンを要求しています。 retrofit_generator 10.2.3+ → 「 analyzer 8.4.1 以上が必要」 custom_lint_visitor 1.0.0+8.4.0 → 「 analyzer 8.4.0 ちょうど」 差は 8.4.0 と 8.4.1、 わずか 1 パッチ 。これだけでビルドが止まるのです。 なお、1 節のエラーメッセージ末尾には analyzer 9.0.0 も登場しますが、これは custom_lint_visitor に 1.0.0+8.4.0 のほかに 1.0.0+9.0.0 ビルドも存在し、 custom_lint_visitor: ^1.0.0 を介した solver が両方を順に試した結果です。どちらも analyzer をバージョン固定で要求する点は同じなので、本質的な対立点は変わりません。 3. なぜ 1 パッチ差で壊れるのか ここで 2 つの事実が噛み合います。 事実 1. analyzer の 内部 API はパッチリリースでも変わる SemVer の約束は「パッチリリースでは 公開 API は後方互換 を保つ」です。ところが custom_lint_visitor が使っているのは analyzer の公開 API ではなく、 内部 API (AST ノードの型など、パッケージの内部実装に属するもの)です。SemVer の保護範囲外なので、メジャー・パッチを問わず、型が消えたり、シグネチャが変わったりするのは珍しくありません。 後ほど引用するメンテナ自身の言葉を借りれば "some more unique APIs" — SemVer の通常のセーフティネットの外側で扱う必要のある API です。本記事の 5 節で扱う dart_style 3.1.9 の LabelReference / NamedArgument 欠落も、「内部 API は SemVer 保護外」という同じ構造から生じる事例の 1 つです(こちらは analyzer のメジャー間で起きたケースで、3 節でいうパッチ単位の例ではありません)。 事実 2. custom_lint_visitor はそれゆえ バージョンを完全に固定 する これを知っているからこそ、 custom_lint_visitor のメンテナは意図的に analyzer を完全に固定しています。パッケージ名そのものがその証拠です。 custom_lint_visitor 1.0.0+8.4.0 ^^^^^ analyzer のバージョン pubspec.yaml の中でも analyzer: 8.4.0 ( ^ (caret) なしのバージョン固定)になっています。 これがミスならば PR 一本で解決する話ですが、これは 関連する GitHub issue でメンテナ自身が明言した 意図的な方針 です。 "Custom_lint depends on some more unique APIs. I'll probably stick to requiring 8.0 for it." — invertase/dart_custom_lint#345 発言の直接の意図は「メジャー( 8.0 )単位で範囲を狭めて require する」ですが、その方針が実際のリリースにも反映されており、リリースされる custom_lint_visitor の各バージョンでは analyzer: 8.4.0 のように バージョンが完全に固定 されています( 1.0.0+8.4.0 → analyzer: 8.4.0 、 ^ (caret) なし)。つまり 意図された決定 の結果としてバージョン固定が生まれており、両者が同じ analyzer バージョンを要求するビルドが揃うまでは pub solver だけでは解けません。 4. 効果のなかった試みリスト 問題が難しく見えると、人は迂回路を探したくなります。しかし直感的に思いつく次の試みはどれも徒労でした。 試み なぜ失敗するのか retrofit_generator を最新(10.2.5)に上げる analyzer 8.4.1 を要求するため custom_lint_visitor と衝突 retrofit の上限を狭めてみる( <4.9.1 ) generator 10.2.1 を選びたい動機は 6 節で詳述しますが、retrofit を 4.9.0 系に下げると今度は generator 10.2.1 のソースが retrofit 4.9.2 の新 enum 値( Parser.DartMappable )を参照しているため、generator 自体の AOT コンパイルが Member not found で失敗します custom_lint_builder のダウングレード analyzer のメジャーが 7.x まで引きずり下ろされ、今度は retrofit_generator (analyzer 8.x 依存)と別の衝突を起こす — freezed / riverpod など analyzer 8 に依存するパッケージがある環境でも同様 analysis_options.yaml の lint を切る ( dependency_overrides で solver を通した後でも) lint を切って exclude: '**/*.g.dart' を両方適用しても、generator AOT 段階で発生する Member not found 系のコンパイルエラーはそのまま発生する dependency_overrides で強制固定 pub get は通るがビルド段階で dart_style が壊れる(5 節を参照) 特に最後の項目、 dependency_overrides は罠が深いので、別途取り上げる価値があります。 上の表の各行は、本記事と同じリポジトリの検証成果物( reports/01-reproduction.md 、 reports/03-overrides-fallback.md )で実際のコマンド出力として再現されています。 5. dependency_overrides という罠 最初の発想は単純です。「2 つのパッケージが争うなら、こちらで強制的に片方のバージョンを打ち込もう」。 dependency_overrides: retrofit: ^4.9.2 retrofit_generator: ^10.2.5 analyzer: ^8.4.1 驚くことに dart pub get は通ります。なぜなら dependency_overrides は オーバーライドした依存に対する他パッケージからの制約を黙らせ 、solver の選択肢を広げるからです。 ところが dart run build_runner build の段階で、突然ビルドが壊れます。 Failed to build build_runner:build_runner: .../dart_style-3.1.9/lib/src/front_end/ast_node_visitor.dart:1279:28: Error: Type 'LabelReference' not found. dart_style です。私たちが明示的に依存もしていないパッケージです。 理由を辿ってみると、次のようになっています。 dependency_overrides がオーバーライドした依存( retrofit 、 retrofit_generator 、 analyzer )に対する他パッケージからの制約を黙らせ、solver の選択肢が広がる その結果、solver は transitive で dart_style の最新版( 3.1.9 )を自動的に選ぶ dart_style 3.1.9 は analyzer の最新メジャーで導入された AST 型( LabelReference 、 NamedArgument 、 BlockEnumBody など)を参照している しかし私たちは override で analyzer ^8.4.1 (解決範囲は >=8.4.1 <9.0.0 )を強制している → その範囲には存在しない型を参照しようとしてコンパイル失敗 要するに dependency_overrides は制約を黙らせるだけで、互換性を保証しません。 一箇所を押さえるとまた別の場所から噴き出します。これを抑え込もうとすると dart_style もピン、 custom_lint_visitor も確認…… と際限なく増えていきます。 6. 結局解けた方法 — シンプルなピン調整 2 行 問題を逆から見ると答えが見えます。 私たちが変えられないもの: custom_lint_visitor 1.0.0+8.4.0 → analyzer 8.4.0 (正確には custom_lint_visitor 自体は 1.0.0+9.0.0 ビルドも存在しますが、それを選ぶと custom_lint 本体が要求する analyzer ^8.0.0 と衝突するため、 custom_lint を使う限り 8.4.0 ピン側に寄せるしかありません。1 節のエラーメッセージにも custom_lint >=0.8.1 depends on ... analyzer ^8.0.0 として現れています) 私たちが変えられるもの: retrofit_generator のバージョン であれば「 analyzer 8.4.0 でも動く最新の retrofit_generator 」を探せばよいわけです。 retrofit_generator のバージョン別要求を表にまとめると: retrofit_generator analyzer 要求 logError の呼び出し形式 10.2.0 >=7.7.1 <10.0.0 positional 4 個 10.2.1 >=8.0.0 <10.0.0 named ( response: _result ) 10.2.3 >=8.4.1 <11.0.0 named 10.2.4 / 10.2.5 >=8.4.1 <13.0.0 named 補足: retrofit.dart は monorepo で、 retrofit_generator (タグ v10.x.x )と retrofit (タグ retrofit-vX.Y.Z )を別系統で管理しています。本記事のリンクで prefix が混在するのはそのためです。なお 10.2.2 はリリースが存在しますが、本記事の議論には影響しないため上の表では省略しています。 答えが見えます。 10.2.1 です。 analyzer 8.4.0 と互換 ✓( >=8.0.0 なので) retrofit 4.9.2 の {Response? response} named optional シグネチャと互換 ✓ dependency_overrides 不要 ✓ dependencies: retrofit: ^4.9.2 dev_dependencies: retrofit_generator: ^10.2.1 これだけです。 ^10.2.1 というキャレット範囲を書いても、10.2.3+ は analyzer 8.4.1 を要求してくるので自動的に候補から外れ、実効的に 10.2.1 が選ばれます。 ちなみに retrofit_generator 10.2.1 と 10.2.5 の logError の呼び出しシグネチャは同一 です。generator のソース( lib/src/generator.dart )を直接比較しても、両バージョンとも '$_errorLoggerVar?.logError(e, s, $_optionsVar, response: $_resultVar);' という同一の出力テンプレートを使っています( v10.2.1#L3777 / v10.2.5#L3849 )。10.2.5 には Stream<Uint8List> / Stream<String> 処理の検証など別の機能が追加されていますが、本記事が扱う retrofit ↔ analyzer インターフェイスそのものは変更されていません。つまり 10.2.1 に留まることは、コア機能面で損ではありません。 7. それで私たちが学んだこと この件が片付いたとき、最初に浮かんだ考えは 「次のメジャーアップグレードでまた出くわすだろうな」 でした。 理由は 2 つです。 analyzer の内部 API は今後もパッチで変わり続ける。 それが AST を扱う解析器パッケージの本質です。 custom_lint_visitor は今後もバージョンを完全に固定し続ける。 メンテナが意図的に取っている方針だからです。 つまりこの衝突は 構造的 です。本記事を書いている 2026 年春の時点で、 analyzer はすでに 13.0.0 までリリースされており、 custom_lint_visitor のピンラインは 1.0.0+9.0.0 までしか追いついていません。 custom_lint_visitor がメジャーごとに 1 〜 2 個のビルドだけ追いつくこのまばらなパターンが続く限り、 analyzer がさらに一段上がるたびに同じ形で再発します。実際、 retrofit.dart の issue tracker を見ると analyzer 10.0 の段階でも同じシグネチャミスマッチが報告されています。 であれば、私たちにできることは: 自然な解決を先に試す。 ピン 1 つの調整で解けるかをまず確認する。シンプルな答えがあるのに dependency_overrides を最初に持ち出さない。 dependency_overrides は最後の手段。 黙らせるだけでは解決にならない。一箇所を押さえると別の場所から噴き出す。 プレイブックを残す。 次の人(あるいは 6 か月後の自分)が同じ罠にはまらないように。メカニズムと意思決定ツリーを一緒に書き残しておく(本記事自体がそのプレイブックの 1 つです)。 最後に ここまで読んでいただき、ありがとうございます。 analyzer のような共有依存をめぐる衝突は、一見すると「2 つのパッケージのバグ」に見えますが、実際にはエコシステム側の構造的な制約が背景にあります。同じ罠に出会ったときに「最初に何を疑い、何を試し、どこで止まるか」を整理できれば、次は数時間で解けるはずです。 皆さんの参考になれば幸いです。 参考 dart_custom_lint #345 — Support analyzer 8 retrofit.dart #911 — analyzer 10.0.0+ compatibility pub.dev — retrofit_generator バージョン別依存関係 retrofit 4.9.2 — Parser.DartMappable enum 追加箇所
※本記事は Claude Code との協働で執筆し、人間がレビューの上投稿しています。 1. はじめに こんにちは、共通サービス開発グループの鳥居( @yu_torii )です。 前回の記事では、Slack 上で LLM を活用する社内チャットボットの実装事例を紹介しました。 @ card 今回は、このテックブログの「関連する記事」と「関連する求人」機能をゼロから再構築した話をします。 「関連する記事」「関連する求人」とは 各記事ページの下部に、2つのレコメンドセクションがあります。 関連する記事: 現在読んでいる記事と内容が近い記事を最大12件表示 関連する求人: 記事の技術領域に関連する KINTO Technologies の求人情報を最大8件表示 読者が興味のある技術領域を深掘りする導線であり、過去の記事の発見にもつながります。採用への接点でもあります。 仕組みの基本:Embedding とコサイン類似度 この機能の核は Embedding (埋め込みベクトル)です。Embedding モデルにテキストを入力すると、その意味を表す数百〜数千次元の数値ベクトルが返ってきます。意味的に近いテキスト同士は、ベクトル空間上で近い位置に配置されます。 2 つのベクトルの「近さ」を測る指標が コサイン類似度 です。値が 1 に近いほど意味が近く、0 に近いほど無関係(直交)です。すべての記事を Embedding し、ペアごとにコサイン類似度を計算してスコアの高い順に並べれば、「関連する記事」のランキングが得られます。 旧システムの課題 この機能は以前、Python + Azure OpenAI の Embedding API で実装されていました。運用を続ける中で 3 つの問題が出てきました。 差分更新が無い。毎回全記事を再 Embed CI が走るたびに全記事(当時 900 件超)を Azure OpenAI に送って Embedding していました。1 記事の追加でも全件再処理が走り、ビルド時間の大半を占めていました。 Azure OpenAI の 429 (Rate Limit) エラーが頻発 900 件超の記事を一気に送ると、Azure OpenAI のレート制限に頻繁にヒットしていました。リトライロジックを入れてもタイミング次第で CI が失敗し、再実行が必要になることも珍しくありませんでした。 外部 API 依存 = コスト増加 Embedding API の呼び出し回数がビルドのたびに積み上がり、コストが増え続けていました。記事数が増えるほど状況は悪化する構造です。 今回やったこと これらの問題を解決するため、Go + Ollama(ローカル Embedding)でシステムを一から再構築しました。 SHA-256 ハッシュで変更記事だけ再 Embed する差分更新と、Ollama による CI ランナー上でのローカル実行(外部 API 呼び出しゼロ)で、旧システムの 3 つの課題を解消しました。 PoC でのモデル選定からパフォーマンス最適化、CI/CD パイプラインの構築まで、実装の全体像を書きます。開発には Claude Code を使いました(おまけで触れます)。 この記事で得られること Go + Ollama + Qwen3-Embedding でローカル Embedding による類似度計算を組む方法 Ollama num_ctx のサイレントトランケーション(無警告の文字切り詰め)問題 事前正規化と min-heap Top-K によるコサイン類似度ランキングの効率化 SHA-256 差分キャッシュで変更記事だけ再 Embed する仕組み :::message この記事の内容は執筆時点(2026年4月)の実装に基づいています。Ollama や Qwen3-Embedding のバージョンアップにより、API の仕様やパフォーマンス特性が変わる可能性があります。また、記事中のベンチマーク値は GitHub Actions ランナーでの計測結果であり、環境によって異なります。 ::: 2. PoC 検証とモデル選定 旧システムの課題(セクション 1 で述べた 429 エラー・全量実行・コスト増加)を解決するため、ローカル Embedding への移行を決めました。Go で使える Embedding ライブラリを 3 つの方式で PoC 検証しました。 3 つの PoC アプローチ 方式 1: hugot(Pure Go ONNX ランタイム) knights-analytics/hugot は Go ネイティブの ONNX ランタイムで、bge-m3 や Qwen3 の ONNX モデルを直接実行できます。cgo 不要ですが、ONNX モデルファイルのサイズが巨大(bge-m3 で約 2.2GB)で、CI 環境でのダウンロードとメモリ管理に課題がありました。 方式 2: kelindar/search(llama.cpp via purego) kelindar/search は一見 Pure Go に見えますが、内部では purego 経由で llama.cpp のバイナリを呼び出しています。cgo は使っていませんが、実質的に llama.cpp バイナリへの外部依存がありました。「cgo 不要」の表面的な特徴に惑わされかけた案件です。 方式 3: Ollama API(HTTP クライアント) 選んだのは Ollama の HTTP API を Go クライアントから呼ぶ方式です。 client, err := api.ClientFromEnvironment() if err != nil { slog.Error("Ollama クライアント作成失敗", "error", err) os.Exit(1) } resp, err := client.Embed(ctx, &api.EmbedRequest{ Model: model, Input: testTexts, }) 比較表 方式 cgo モデル管理 バッチ対応 コンテキスト制御 判定 hugot (ONNX) 不要 手動 ○ × △ モデルサイズ問題 kelindar (llama.cpp) purego 経由で不要に見えるが llama.cpp バイナリ依存 手動 × × × 実質外部依存 Ollama API 不要 自動 ○ ○ ( num_ctx ) ◎ 選定の決め手 cgo 不要で GOOS=linux GOARCH=arm64 go build 一発のクロスコンパイルが壊れない。Ollama がモデルのダウンロードからライフサイクル管理まで担う。バッチ Embed API で複数テキストを一度に送信できる。 num_ctx でコンテキストウィンドウを明示制御できる。 なぜ Qwen3-Embedding-0.6B か Qwen3-Embedding-0.6B を選んだ理由は、2025 年リリースの最新モデルで、量子化後 639MB と CI ランナーのメモリに収まるサイズだったこと。1024 次元ベクトルで表現力と計算量のバランスが良い。日本語・英語のバイリンガルサポートは、当ブログの運用上の必須要件でした。RAG の検索精度が求められるタスクではなく関連記事の推薦用途なので、最高精度モデルは不要です。 :::details 量子化とは 量子化(Quantization)は、モデルの重み(パラメータ)を元の精度(通常 float16 = 16bit)からより少ないビット数(8bit、4bit など)に変換する手法です。精度はわずかに低下しますが、モデルサイズとメモリ使用量を大幅に削減できます。 Qwen3-Embedding-0.6B は Ollama で Q8_0(8bit 量子化) として配布されており、595M パラメータで 639MB。一方、bge-m3 は F16(16bit)配布のため、パラメータ数はほぼ同じ(568M)でもサイズが 1.2GB と約 2 倍になります。 ::: :::message PoC の段階では bge-m3 も候補でしたが、モデルサイズだけでなくベンチマークでも Qwen3 が優位でした。 MTEB ベンチマーク の英語検索(61.82 vs 57.03)、多言語検索(64.64 vs 58.36)、コード検索(75.41 vs 41.38)で Qwen3-Embedding-0.6B が上回っています。bge-m3 が優位なのは長文検索(MLDR: 59.51 vs 50.26)ですが、先頭 4000 文字に切り詰める本システムでは該当しません。Ollama でのモデルサイズも約半分(639MB vs 1.2GB)で、CI キャッシュの効率も含めて総合的に Qwen3 を選択しました。 ::: 3. アーキテクチャの全体像 パイプライン flowchart LR A["_posts/*.md"] --> B["Markdown<br>クリーニング"] B --> C["Ollama Embed API<br>(Qwen3-Embedding)"] C --> D["SHA-256<br>キャッシュ"] D --> E["コサイン類似度<br>ランキング"] E --> F["related_posts.json"] Markdown をクリーニングして Ollama で Embedding を取得し、コサイン類似度でランキングして JSON を出力します。 パッケージ構成 cmd/related-content-gen/ ├── main.go # CLI エントリポイント ├── internal/ │ ├── markdown/ # Markdown パース・クリーニング │ │ ├── cleaner.go # frontmatter 除去、URL/assets 除去 │ │ └── parser.go # _posts/*.md の読み込み │ ├── embedding/ # Ollama クライアント・キャッシュ │ │ ├── client.go # Embed API ラッパー(num_ctx 制御) │ │ └── cache.go # SHA-256 ハッシュベースの差分更新 │ ├── similarity/ # 類似度計算・ランキング │ │ ├── cosine.go # コサイン類似度(テスト用) │ │ └── ranking.go # L2正規化 + dotProduct、min-heap Top-K │ └── output/ # JSON 出力 │ └── json.go # UTF-8、4スペースインデント、HTMLエスケープなし └── go.mod internal パッケージに分離することで、各パッケージが単一責任を持ち、独立してテスト可能になっています。 run() 関数のパイプライン メイン処理は run() 関数に集約されています。 func run(...) error { // 1. 記事の読み込みとクリーニング posts, err := markdown.ParsePosts(postsDir) // 2. Ollama クライアント作成 client, err := embedding.NewClient(ollamaURL, model, numCtx) // 3. キャッシュ読み込み → 不要エントリ削除 → 変更記事検出 cache, err := embedding.LoadCache(cacheFile) cache.Prune(posts) dirty := cache.FindDirty(posts, model) // 4. 変更分のみ Embed(1件ずつ処理して都度キャッシュ保存) for _, p := range dirty { vectors, err := client.Embed(ctx, []string{text}) cache.Entries[p.Slug] = embedding.CacheEntry{...} cache.Save(cacheFile) // 中断耐性のため毎回保存 } // 5. コサイン類似度でランキング rankings := similarity.RankRelatedPosts(postVectors, 12) // 6. JSON 出力 output.WriteJSON(outPath, postsOutput) } Next.js フロントエンドとの連携 出力される JSON は Next.js の getStaticProps でビルド時に読み込まれます。 static/related_posts/related_posts.json → lib/related_posts.ts が読み込み フロントエンド側では、JSON に関連記事データがあればそれを使い、無ければカテゴリベースのフォールバックに切り替わります。Go CLI とフロントエンドの間の契約は、この JSON スキーマだけです。 4. Markdown のクリーニングと前処理 当ブログの記事は Zenn Markdown ( :::message 、 :::details 、 @[card]() など)で書かれています。各記事ファイルの先頭には YAML frontmatter(タイトル、著者、公開日、カテゴリなどのメタ情報)があり、これらをそのまま Embed するとノイズになります。 クリーニングパイプライン frontmatter の分離: --- で囲まれた YAML ヘッダーからタイトルだけ抽出し、残りのメタ情報(author, date, category 等)は除去 URL の除去: http:// / https:// で始まるすべての URL を除去 アセットリンクの除去: /assets/ を含むリンク(画像パスなど)を除去 クリーニングのエントリポイントは CleanMarkdown 関数で、frontmatter からタイトルを抽出しつつ、本文のノイズを除去します。frontmatter パースには strings.Cut を使い、 --- デリミタ間の YAML を gopkg.in/yaml.v3 で解析しています。 :::details コードの詳細(cleaner.go / parser.go) var ( reURL = regexp.MustCompile(`https?://[^\s)\]>]+`) reAsset = regexp.MustCompile(`!?\[[^\]]*\]\(/assets/[^)]+\)|/assets/[^\s)]+`) ) func CleanMarkdown(raw []byte) (title, content string) { s := string(raw) if len(s) == 0 { return "", "" } title, body := splitFrontmatter(s) body = removeURLs(body) body = removeAssetLinks(body) return title, body } func splitFrontmatter(s string) (title, body string) { const delimiter = "---" _, after, ok := strings.Cut(s, delimiter) if !ok { return "", s } before, after, ok := strings.Cut(after, delimiter) if !ok { return "", s } var fm frontmatter if err := yaml.Unmarshal([]byte(before), &fm); err == nil { title = fm.Title } return title, after } type Post struct { Slug string // ファイル名から .md を除去 Title string // frontmatter の title フィールド Content string // クリーニング済み本文 } func ParsePosts(dir string) ([]Post, error) { entries, err := os.ReadDir(dir) // ... *.md ファイルを読み込み、CleanMarkdown で処理 return posts, nil } ::: ポイントは、Embedding 時にタイトルをテキストの先頭に結合すること( title + "\n" + content )。セクション 5.1 で述べますが、Embedding モデルはテキストの先頭部分を重視する傾向があるため、タイトルの情報がベクトルに強く反映されます。 5. Embedding の最適化 Embedding 処理の高速化で 2 つの工夫をしました。 5.1 : テキストを先頭 4,000 文字に切り詰めて処理時間を約 1/8 に短縮 5.2 : 実装中に踏んだ Ollama num_ctx の無警告切り詰め問題 5.1 テキスト切り詰めの最適化 最初は記事の全文をそのまま Ollama に送っていました。CI で実行すると、全記事の Embedding に数十時間かかる計算です。全文が本当に必要なのか、検証しました。 まず、全記事のクリーニング済みテキスト長の分布を調べました。 平均: 約 8,000 文字 中央値: 約 6,300 文字 上位 10%: 14,600 文字以上 最大: 53,000 文字超 大半の記事は 10,000 文字以内に収まりますが、一部の長文記事は 40,000 文字を超えます。長い記事の後半には参考文献リストや補足情報が多く、記事のテーマを表す情報は先頭に集中する傾向がありました。 そこで「先頭 N 文字に切り詰めても品質を維持できるか?」を検証するため、長文の上位 5 記事で 全文 Embedding( num_ctx=8192 明示指定)をベースライン として、切り詰め文字数を変えて類似度と速度を比較しました。 切り詰め ベースラインとの類似度 平均速度 高速化 全文 1.000 229 秒 1.0x 2,000 文字 0.868 13 秒 17.6x 4,000 文字 0.887 29 秒 7.9x 6,000 文字 0.902 42 秒 5.5x 8,000 文字 0.909 53 秒 4.3x 4,000 → 8,000 文字に増やしても類似度の改善は +2.2 ポイント (0.887 → 0.909)に留まりますが、速度は 1.8 倍遅くなります。関連記事のランキング品質に影響が出ないことを本番データで確認した上で、 先頭 4,000 文字 + num_ctx=8192 を採用しました。 :::message なぜ先頭の切り詰めが有効か? 2 つの要因が相乗しています。 モデルの位置バイアス : Transformer ベースの Embedding モデルでは、テキスト先頭への撹乱がベクトルに与える影響が末尾より約 15% 大きいことが報告されています( arXiv:2412.15241 )。Qwen3-Embedding も RoPE を採用した Transformer モデルであり、同様の傾向があると考えられます。 コンテンツの構造バイアス : 技術ブログは「タイトル→導入→概要→詳細」の逆ピラミッド構造を持ち、テーマ情報が冒頭に集中します(いわゆる Lead Bias )。 ::: 5.2 Ollama の num_ctx に潜む落とし穴 5.1 の検証に入る前に、 num_ctx 周りで罠を踏みました。Ollama で Embedding を扱う人は全員引っかかりうる問題です。 何が起きたか 切り詰めを検証する前に、まず num_ctx の効果を確認しようと次の 2 パターンで全文 Embedding を比較しました。 A: 全文 + num_ctx=4096 B: 全文 + num_ctx=8192 A と B のコサイン類似度が全記事で 1.000 でした。完全に同一のベクトルです。処理時間も平均約 70 秒で差がない。35,000 文字超の記事でコンテキスト長を倍にしたのに、結果が変わっていません。 記事 文字数 平均処理時間(秒) A-B 類似度 torii-ai_tool_slack 35,417 68 1.000 Android-Compose-OO-Nav 37,803 76 1.000 aurora-mysql-stats 32,648 71 1.000 Jetpack-Compose-Anim 34,621 65 1.000 SecureDBPassword 38,978 69 1.000 平均 約 70 秒 1.000 原因: Options に入れないと num_ctx は効かない num_ctx を EmbedRequest.Options で 明示的に渡さない限り 、Ollama は VRAM に応じたデフォルト値 (24GiB 未満で 4k、24-48GiB で 32k、48GiB 以上で 256k。 OLLAMA_CONTEXT_LENGTH 環境変数で変更可能)を使い、超過分を無警告で切り詰めます。 パターン B で num_ctx=8192 を設定したつもりが、API の Options に渡されておらず、A と同じ 4096 トークンで処理されていました。類似度 1.000 は、両方とも同じ入力を処理していた証拠です。 :::message alert 注意: Ollama は入力テキストがコンテキスト長を超えてもエラーを返しません。API レスポンスにも切り詰めの有無を示すフィールドがありません。意図せず不完全な Embedding が生成される可能性があります。これは Ollama の Issue #14259 でも報告されています。 ::: 修正と効果の確認 num_ctx を EmbedRequest.Options で明示的に渡すよう修正したのが、次の実装です。 func (c *Client) Embed(ctx context.Context, texts []string) ([][]float32, error) { req := &api.EmbedRequest{ Model: c.model, Input: texts, } if c.numCtx > 0 { req.Options = map[string]any{"num_ctx": c.numCtx} } resp, err := c.api.Embed(ctx, req) if err != nil { return nil, fmt.Errorf("Ollama Embed API エラー: %w", err) } // レスポンスのバリデーション(件数・空ベクトルチェック) if len(resp.Embeddings) != len(texts) { return nil, fmt.Errorf("レスポンス数が不一致: %d embeddings / %d texts", len(resp.Embeddings), len(texts)) } return resp.Embeddings, nil } 修正後は A-B 類似度が 0.947 に下がり、B の処理時間は A の約 3 倍(229 秒 vs 78 秒)になりました。8192 トークン分を処理していることが時間からも裏付けられます。 記事 文字数 A(秒) B(秒) A-B 類似度 torii-ai_tool_slack 35,417 77 224 0.969 Android-Compose-OO-Nav 37,803 81 218 0.920 aurora-mysql-stats 32,648 84 224 0.919 Jetpack-Compose-Anim 34,621 74 243 0.947 SecureDBPassword 38,978 73 238 0.977 平均 78 229 0.947 CLI のデフォルト値は --num-ctx=8192 に設定し、4000 文字切り詰めと組み合わせることで無警告の文字切り詰めが発生しないことを保証しています。 Ollama 利用者への教訓 Ollama で Embedding や LLM を扱うなら: num_ctx は Modelfile の PARAMETER か、API の Options.num_ctx で 明示的に設定する 入力のトークン数を事前に把握し、コンテキスト長に収まるか確認する 類似度や品質が「なぜか変わらない」ときは、無警告切り詰めを疑う 5.3 コードブロックは残すべきか? 先頭 4000 文字のうち、コードブロックが大量に含まれる記事があります。Android Compose のナビゲーション記事では 2,213 文字(55%超)がコードでした。コードを除去して本文を増やす方が良さそうに思えます。 日英翻訳ペア(同じ postId で locale が異なる記事)のコサイン類似度で検証しました。 コードブロックあり: 0.893 コードブロック除去: 0.868 コードブロックを除去すると類似度が下がりました。 クラス名、関数名、ライブラリ名( NavHost 、 Composable 、 goroutine など)は言語に依存しません。日本語の記事でも英語の記事でも、同じ技術ならコード中に同じキーワードが出現します。コードブロックはクリーニング対象から除外(残す)としました。 切り詰めの実装 const maxEmbedRunes = 4000 for _, p := range dirty { text := p.Title + "\n" + p.Content if p.Content == "" { text = p.Title } if runes := []rune(text); len(runes) > maxEmbedRunes { text = string(runes[:maxEmbedRunes]) } vectors, err := client.Embed(ctx, []string{text}) // ... } []rune に変換してからスライスすることで、マルチバイト文字(日本語)の途中で切れることを防いでいます。 6. SHA-256 差分キャッシュによる効率化 セクション 1 で述べた「毎回全量実行」の問題を解決するため、差分キャッシュを導入しました。「前回から何が変わったか」を高速に判定する必要がありますが、ファイルの更新日時(mtime)は Git のチェックアウトでリセットされるため CI 環境では使えません。そこで、コンテンツ自体の SHA-256 ハッシュで変更を検知する方式を採用しました。 キャッシュの設計 type Cache struct { Version int `json:"version"` ModelName string `json:"model_name"` Entries map[string]CacheEntry `json:"entries"` } type CacheEntry struct { ContentHash string `json:"content_hash"` Vector []float32 `json:"vector"` } SHA-256 による変更検知 記事のタイトルと本文を結合して SHA-256 ハッシュを計算し、前回のキャッシュと比較します。 func ContentHash(title, content string) string { h := sha256.New() h.Write([]byte(title + "\n" + content)) return hex.EncodeToString(h.Sum(nil)) } func (c *Cache) FindDirty(posts []markdown.Post, modelName string) []markdown.Post { if c.ModelName != modelName { return posts // モデル変更 → 全記事を再Embed } var dirty []markdown.Post for _, p := range posts { entry, ok := c.Entries[p.Slug] if !ok || entry.ContentHash != ContentHash(p.Title, p.Content) { dirty = append(dirty, p) } } return dirty } モデル名が変わると全記事が dirty になります。Embedding モデルが変われば次元数やベクトル空間が異なるため、古いキャッシュは無効です。 キャッシュフロー flowchart TB A["記事読み込み<br>(956件)"] --> B["キャッシュ読み込み"] B --> C{"モデル変更?"} C -->|Yes| D["全記事をEmbed"] C -->|No| E["SHA-256比較"] E --> F{"変更あり?"} F -->|Yes| G["変更分のみEmbed"] F -->|No| H["スキップ"] D --> I["1件ずつ保存<br>(中断耐性)"] G --> I Embed のたびにキャッシュファイルを保存します。CI のタイムアウトや中断が起きても、それまで処理した分はキャッシュに残ります。次回実行時は中断箇所から再開できるため、初回の全量 Embedding を複数回に分けて進められます。 初回構築で効いた「中断耐性」 この「1 記事ごとに cache ファイルへ保存」という設計が、初回構築で実際に役に立ちました。 当時 956 件あった全記事の初回全量ビルドでは、Ollama での Embedding 処理が GitHub Actions の job timeout( timeout-minutes: 60 )に収まらず、5 回連続で 60 分 timeout に到達しました。それでも 6 回目の run で完走できたのは、各 cancelled run で完了していた分の Embedding が次の run に引き継がれたからです。 run 結果 Generate related content 1 〜 5 回目 timeout 各 60 分 6 回目 success 55 分 累計 約 6 時間 これを成立させたのは 2 つの噛み合わせです。 アプリ側 : 1 記事 Embed するごとに output/embeddings_cache.json へ保存 CI 側 : actions/cache/save@v5 を if: always() で走らせる - name: Save embeddings cache if: always() # timeout/cancel 時も cache save を走らせる uses: actions/cache/save@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} if: always() を付けておくと、job が timeout/cancel で終わるときにも cache save ステップが走ります。結果、途中まで処理した Embedding は cache に残り、次 run は restore-keys のフォールバックで前 run の cache を拾って残り分から続行できる。 この仕組みがなければ、60 分 timeout で毎回 Embedding が巻き戻り、6 時間で完走することはなかったはずです。 7. コサイン類似度ランキングの最適化 Embedding ベクトルが得られたら、記事間の類似度を計算してランキングを生成します。956 記事の各記事が他の 955 件と比較するため、約 91 万回の内積計算が走ります。この規模なら FAISS 等の ANN(近似最近傍探索)ライブラリを導入するよりも、brute-force の方がシンプルで依存も増えません。 最初の実装(毎回ノルム計算 + 全件ソート)ではテストで約 1.6 秒かかっていました。事前正規化 + min-heap への変更と、ループアンローリングの 2 段階で 730ms まで改善しました。 :::::details 最適化の詳細 1. 事前正規化 (Pre-normalization) コサイン類似度の式は以下です。 $$ \cos(a, b) = \frac{a \cdot b}{|a| \times |b|} $$ 毎回 2 つのベクトルの長さ(ノルム $|a|$)を計算するのは無駄なので、全ベクトルの長さを事前に 1 に揃えておきます(正規化)。すると分母が $1 \times 1 = 1$ になり、コサイン類似度は内積 $a \cdot b$(各要素を掛けて足すだけ)と等しくなります。正規化は記事数分(956回)だけ。その後の 91 万回のペア比較では掛け算と足し算だけで済みます。 :::details コサイン類似度の補足 内積 $a \cdot b$ は 2 つのベクトルの各要素を掛けて足した値です。意味が近い記事同士は内積が大きくなりますが、長い記事のベクトルは値が大きくなりがちで、内積だけだと「ベクトルの長さ」に引っ張られます。ノルム $|a|$ で割ることで長さの影響を消し、純粋に「向き」(意味の近さ)だけを比較するのがコサイン類似度です。結果は $-1$ 〜 $1$ の範囲で、1 に近いほど意味が近い。 正規化とは、各要素をノルムで割ってベクトルの長さを 1 にする処理です。向きはそのまま、長さだけ揃えます。 元: a = [3, 4] → 長さ = √(9+16) = 5 正規化: a' = [0.6, 0.8] → 長さ = √(0.36+0.64) = 1 ::: 2. min-heap Top-K 全 955 件のスコアを sort.Slice でソートしていましたが、実際に必要なのは上位 12 件だけ。サイズ 12 の min-heap(Go 標準ライブラリの container/heap )を使い、スコアが最小値より大きければ入れ替える方式に変更。計算量は $O(N \log N)$ から $O(N \log K)$ に改善します。 3. ループアンローリング 内積計算のホットパス(約 91 万回 × 1024 次元)に 4-way ループアンローリングを適用。4 つの独立したアキュムレータ変数を使うことで、前のループ結果への依存を断ち切り、CPU が乗算と加算を並列実行できるようになります。 :::details ループアンローリングの補足 通常のループでは 1 つの変数 sum に順番に足していきます。 sum += a[0]*b[0] の結果が出るまで次の sum += a[1]*b[1] が始められません(データ依存)。 4-way では 4 つの変数 s0, s1, s2, s3 に分けて、それぞれ独立に計算します。CPU は依存関係のない命令を同時に実行できるため(命令レベル並列性)、4 つの乗算・加算が並列に走ります。最後に s0 + s1 + s2 + s3 で合計するだけです。 通常: sum += a[0]*b[0] → sum += a[1]*b[1] → sum += a[2]*b[2] → sum += a[3]*b[3] (前の結果を待ってから次へ) 4-way: s0 += a[0]*b[0] s1 += a[1]*b[1] s2 += a[2]*b[2] s3 += a[3]*b[3] (4つ同時に実行) → s0 + s1 + s2 + s3 ::: // 事前正規化: 全ベクトルのノルムを 1 にする normalized := normalizeAll(slugs, vectors) // min-heap Top-K: 上位 maxResults 件だけを効率的に抽出 h := &minHeap{} for j, other := range slugs { if i == j { continue } score := dotProduct(vi, normalized[j]) if h.Len() < maxResults { heap.Push(h, ScoredItem{Key: other, Score: score}) } else if score > (*h)[0].Score { (*h)[0] = ScoredItem{Key: other, Score: score} heap.Fix(h, 0) } } // 4-way ループアンローリング func dotProduct(a, b []float32) float32 { var s0, s1, s2, s3 float32 n := len(a) i := 0 for ; i <= n-4; i += 4 { s0 += a[i]*b[i]; s1 += a[i+1]*b[i+1] s2 += a[i+2]*b[i+2]; s3 += a[i+3]*b[i+3] } for ; i < n; i++ { s0 += a[i] * b[i] } return s0 + s1 + s2 + s3 } ::::: パフォーマンス推移 段階 手法 ランキング処理時間(956記事) 初期 毎回ノルム計算 + sort.Slice ~1.58s 1 事前正規化 + min-heap Top-K ~1.18s 2 + ループアンローリング(4-way) 730ms 最終的なスペック: 指標 値 記事数 956 件 ベクトル次元数 1024 類似度計算回数 約 912,980 回(956 × 955) ランキング処理時間 730ms なお、Go の map はイテレーション順序が非決定的です。同じ入力に対して常に同じ JSON 出力を得るため、 slices.Sort でスラッグをソートしてから処理しています。これを忘れると CI のたびに diff が発生し、不要なコミットが生まれてしまいます。 8. GitHub Actions での CI/CD ワークフロー全体像 flowchart LR A["create-branch"] --> B["generate-related-content<br>(ARM runner + Ollama)"] A --> C["generate-metadata"] A --> D["generate-search-index"] B --> E["create-pull-request"] C --> E D --> E E --> F["auto-merge"] create-branch でブランチを作成した後、3 つのジョブが並列実行されます。 ARM ランナーの選択 Embedding 処理には arm-ubuntu-latest-4 ランナーを使用しています。GitHub の ARM ランナーは x86 の約半額(1分あたり $0.004 vs $0.008)で、初回の全量 Embedding のように数時間かかるジョブではコスト差が大きくなります。 Ollama モデルキャッシュ 639MB のモデルファイルを毎回ダウンロードしないため、 actions/cache でキャッシュします。 cache/restore + cache/save パターン :::message actions/cache@v5 の save-always オプションは非推奨になりました。代わりに cache/restore と cache/save を分離し、 cache/save に if: always() を付けるパターンを使います。 ::: - name: Restore embeddings cache uses: actions/cache/restore@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} restore-keys: embeddings-cache- # ... Embedding 実行 ... - name: Save embeddings cache if: always() uses: actions/cache/save@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} if: always() により、タイムアウト時でもキャッシュを保存します。セクション 6 の「1 件ずつ保存」と組み合わせて、中断と再実行を繰り返してもキャッシュが蓄積されます。 キャッシュキーに run_id を付ける理由 GitHub Actions のキャッシュは同じキーで上書きできません(イミュータブル)。これはタイムアウト→再実行のパターンで問題になります。 run_id なしの場合: key: embeddings-cache-abc123 1回目: save "abc123" → ✅ 200記事分保存 2回目: restore "abc123" → 200記事復元 → 追加200記事 → save "abc123" → ❌ キーが既に存在 3回目: restore "abc123" → 1回目の200記事分しかない(2回目の成果が消えた) run_id ありの場合: save key: embeddings-cache-abc123-{run_id} ← 毎回ユニーク restore-keys: embeddings-cache-abc123- ← プレフィックス一致で最新を取得 1回目: save "abc123-100" → ✅ 200記事分 2回目: restore "abc123-" → run100から200記事復元 → 追加200記事 → save "abc123-200" → ✅ 400記事分 3回目: restore "abc123-" → run200から400記事復元 → 続きから push のリトライロジック 3 つのジョブが並列でブランチに push するため、競合が発生します。指数バックオフ付きのリトライで対処します。 pushed=false for i in 1 2 3 4 5; do git pull --rebase origin "$BRANCH_NAME" && git push origin "$BRANCH_NAME" && pushed=true && break echo "Push failed (attempt $i), retrying..." sleep $((i * 2)) done [ "$pushed" = "true" ] || { echo "ERROR: All push attempts failed"; exit 1; } 古いブランチの問題 自動生成用ブランチが前回の実行から残っている場合、古いコードがベースになります。 git reset --hard ${{ github.sha }} で毎回トリガー元の最新コミットにリセットします。 workflow_dispatch でのテスト実行 main にマージ前の動作確認では workflow_dispatch トリガーを一時的に追加しました。ただし、GUI の Actions タブにはデフォルトブランチのワークフローしか表示されないため、feature ブランチの workflow_dispatch は GUI から実行できません。 CLI 経由であれば --ref でブランチを指定して実行可能です。 gh workflow run "Auto Create Related Data" --ref feat/related-content-gen-go-rewrite 9. 実運用で見えた効果 旧システム(Python + Azure OpenAI)から新システム(Go + Ollama)への移行で、セクション 1 で挙げた 3 つの課題はそれぞれ次のように変わりました。 課題 旧(Python + Azure OpenAI) 新(Go + Ollama) 実行戦略 毎回全量 Embed(900+ 件) 差分のみ Embed(SHA-256 ハッシュ比較) Rate Limit (429) 頻発・リトライで不安定 構造的に発生しない(外部 API なし) 推論コスト 従量課金(Azure OpenAI) ゼロ(CI ランナー内完結) 比較すべきは単発の処理秒数ではなく、「記事追加のたびに全量再計算が必要か」「外部 API 制約に運用が振り回されるか」という運用特性です。旧は Azure のマネージド並列推論、新は self-hosted CI ランナー 1 台のシーケンシャル処理で、そもそも尺度が違います。 差分更新時の実測例 959 記事中 49 件(5%)が dirty だった run では、 19 分 21 秒で完走 しました(self-hosted runner 1 台・逐次処理で 1 記事あたり約 22〜24 秒)。差分ゼロなら Embed はスキップされ、ランキング計算と出力だけで 1〜2 分で完了します。 残課題 dirty が 150 件を超える状況(cache eviction 直後や cron が長期間失敗していたあとなど)では timeout-minutes: 60 に収まらないことがあります。現状は複数 run に分けて進捗を積み上げる設計でカバーしていますが、次の打ち手として timeout 延長と output/embeddings_cache.json の git 管理化が候補です GitHub Actions cache は 7 日アクセスなしで自動 eviction されるため、週次 cron(月曜 9 時)で Restore を触って keep-warm しています。より確実にするなら git 管理化か、S3 などの外部 storage に寄せる手もあります 10. まとめ 本記事では、関連記事のレコメンドシステムを Go + Ollama(ローカル Embedding)で再構築した過程を紹介しました。なお、関連求人についても同様の Embedding + コサイン類似度の仕組みで生成しています。 項目 結果 対象記事数 960 件前後(執筆時点) ランキング計算 730ms(Embedding 生成は含まず、測定時点 956 件) テキスト切り詰め 先頭 4000 文字で全文比 88.7% の類似度を維持 差分キャッシュ 差分ゼロなら 1〜2 分、少数差分なら数分〜十数分 外部依存 Ollama + Qwen3-Embedding(API キー不要) SHA-256 差分キャッシュで変更記事だけを再 Embed し、ランキングは事前正規化と min-heap Top-K で 730ms(956記事のペアワイズ計算)。外部 API 依存を排除して、429 エラーとコストの問題を解消しました。 初回の全量 Embedding は CPU ランナーで数時間かかり、モデル変更や初期導入時にも同じコストを払うことになります。扱い方はセクション 6 と 9 に書いた通りで、GPU ランナーが使えれば改善しますが、現時点では CI の制約です。 もう 1 つ、推薦品質の定量評価がまだありません。「Embedding の類似度が 88.7% 保たれている」ことと「関連記事の推薦が妥当である」ことは別の問題です。旧システムとの Top-K 一致率や、クリックスルー率の計測が残っています。 テキスト切り詰めも改善の余地があります。現在は先頭 4000 文字をルーン単位でカットしていますが、文の途中で切れる可能性があります。句点( 。 )や改行の位置で切る方が、Embedding の入力としてはクリーンです。今回のユースケースでは影響は軽微ですが、精度を追求する場合は検討に値します。 11. この仕組みの応用可能性 「ローカル Embedding + コサイン類似度 + 差分キャッシュ」の仕組みは、ブログの関連記事に限りません。Confluence や Notion の社内ドキュメントを同じパイプラインで Embedding すれば、「この仕様書に関連するドキュメント」を自動提示できます。Ollama はローカル実行なので、社外に送信できない社内文書でも扱えます。 SHA-256 差分キャッシュと 1 件ずつ保存の中断耐性パターンはそのまま流用できます。Ollama + 軽量モデルなら API キー不要で CI でもローカルでも動きます。 おまけ: Claude Code との開発プロセス 今回の開発は Claude Code とのペアプログラミングで進めました。 kairo による開発ワークフロー 開発ワークフローにはクラスメソッド社の tsumiki の kairo を使いました。kairo は Claude Code 向けのスキルで、4 つのコマンドでソフトウェア開発を進めます。 kairo-requirements : EARS 記法で機能・非機能要件を定義。今回は 3 方式の PoC 比較(ONNX / llama.cpp / Ollama)もこのフェーズで実行しました kairo-design : 要件からアーキテクチャ図、データフロー、型定義を生成 kairo-tasks : 設計を実装タスクに分割。依存関係とテストケースも定義。今回は 10 タスク・3 フェーズに分解 kairo-loop : タスクを 1 つずつ Red → Green → Refactor の TDD サイクルで実装。7 タスクをこのコマンドで回しました PR レビュー 実装後の PR レビューでは、Claude Code に以下のように指示しました。 /pr-review-toolkit:review-pr all pr-review-toolkit は Anthropic 公式の Claude Code プラグインで、6 種のレビューエージェント(コード品質、エラーハンドリング、テストカバレッジ、コメント整合性、型設計、コード簡素化)が並列にレビューします。セクション 5.2 のレスポンスバリデーション(件数・空ベクトルチェック)は、このレビューで指摘された問題への対応です。 Go 1.26 での最適化 Claude Code に「Go 1.26 で最適化して」と指示しました。 go fix による自動変換( strings.Index → strings.Cut 、 sort.Strings → slices.Sort 、 context.Background() → t.Context() など)に加え、新しい言語機能やライブラリ API を活用したリファクタリングも実施されました。 記事の執筆・校正 この記事自体も Claude Code で執筆しています。校正には 3 つのツールを使いました。 textlint + ja-technical-writing : 冗長表現や接続詞の重複など、日本語の技術文書向け校正 skill-deslop : AI 生成文章に特有の冗長パターン(回りくどい前置き、受動態の多用など)の検出・除去 Codex plugin for Claude Code : OpenAI 公式の Claude Code プラグインで、Codex CLI をサブエージェントとして呼び出します。記事全体の論理破綻や数値矛盾のチェックに使いました。実験データ更新に伴う数値の不整合やコードスニペットの変数名不一致など、人間のレビューでは見落としやすい問題を検出できました ここまで読んでいただきありがとうございました。何かの参考になれば幸いです。なお、この記事の下部に表示されている「関連する記事」と「関連する求人」が、本記事で紹介した仕組みで生成された実物です。
はじめに こんにちは、 Cloud Infrastructure G の山中です! 「拠点のグローバル IP が変わったので、AWS WAF の allowlist を更新してください」というよくある作業を引き受けたところ、半日以上溶かしました。 原因は 同じシステムの中に WAF が 2 系統 存在しており、片方を更新しても、もう片方が古い IP リストを握っていたためです。さらに Amplify Console の Firewall UI が「Firewall: 無効」と表示しているのに、API で確認すると WebACL がしっかり attach されている という UI と実態の乖離まで重なり、見えている情報をそのまま信じてよいか判断がつかない状況でした。 この記事では、その 2 系統 WAF の全体像と、UI を信用できないときに API で実態を確認する手順を共有します。同じような構成(Amplify Hosting + CDK 管理の WAF)を運用している方の参考になれば幸いです。 TL;DR 拠点 IP 変更で全環境 403 が発生 Backend API 側の WAF(CDK 管理)は Amplify の環境変数 + rebuild で解消できた しかしフロントエンド側は Amplify Hosting が自動管理する別 WAF ( AmplifyIPSet-* )が原因で 403 が残った さらに Amplify Console の Firewall UI は「Firewall: 無効」表示なのに、API では WebACL が attach 済み・ default action が Block だった aws wafv2 list-resources-for-web-acl の --resource-type を AMPLIFY にしないと、attach 状態が見えない罠も踏んだ 最終的に aws wafv2 update-ip-set で直接 IPSet を書き換えて解決 教訓: Amplify Hosting の WAF は UI を信用せず、API で実態を確認すべし 背景 サービス構成 ユーザー向けに提供予定のフロントエンド + バックエンド API のシステムで、構成は以下の通りです。 バックエンド: AWS Amplify Gen 2(CDK で WAF や CloudFront を含むインフラを定義) フロントエンド: AWS Amplify Hosting Gen 1(platform=WEB) CDN: CloudFront WAF: AWS WAFv2 環境: dev / stage / prod(それぞれ別 AWS アカウント) アクセス制御の方針 まだリリース前のシステムのため、複数拠点からのオフィス IP のみを許可しています。 拠点が増減したり、回線変更で IP が変わったりすると、各環境の WAF の IPSet を更新する必要があります。 出来事 ある日「拠点 A が新しい IP に切り替わるので、X 日までに各環境の allowlist を入れ替えてほしい」という依頼が来ました。 作業手順は社内に整備されていたので、淡々と進めていたつもりだったのですが、ここから泥沼に入っていきます。 WAF が 2 系統に分かれている全体像 最初に結論を絵にしておきます。後の章で何度もこの絵に戻ってきます。 ポイントは、 同じ「拠点 IP 許可」という概念を、別々のリソースとして 2 箇所で独立に管理している ことです。 Backend API 側: CDK / CloudFormation で IPSet を定義 → Amplify の環境変数 WAF_ALLOWED_IP_LIST を更新して rebuild すれば IPSet が書き換わる、という仕組みを自前で組んでいる Frontend 側: Amplify Hosting の「Firewall(AWS WAF 統合)」機能を有効にすると、Amplify サービス側で勝手に IPSet と WebACL を作成し、Amplify app にくっつける 片方しか更新しないと、当然、もう片方の経路で 403 が出ます。今回まさにそこにハマりました。 タイムライン 実際に起きた流れを表にすると、こうなります。 タイミング 出来事 Day 0 社内ドキュメントで「新しい拠点 IP 一覧」が共有される Day 1 dev の Backend API WAF を Amplify の環境変数経由で更新 → IPSet 反映確認 Day 2 朝 stage の Backend API WAF を更新 → IPSet 反映確認 Day 2 昼 prod の Backend API WAF を更新 → IPSet 反映確認 Day 2 昼 動作確認のためフロントエンド URL にアクセス → まだ 403 Day 2 昼 「Backend は更新したのに何で?」と調査開始 Day 2 午後 フロントエンドは別 WAF ( AmplifyIPSet-* ) で守られていることに気付く Day 2 午後 Amplify Console の Firewall UI を見る → 「Firewall: 無効」と表示 Day 2 午後 API で確認 → WebACL は attach 済み、IPSet は旧 IP のままで Block Day 2 夕方 aws wafv2 update-ip-set で 3 環境の AmplifyIPSet-* を直接更新 → 全環境 200 OK 「Backend は更新したのにフロントだけ 403」となった時点で、Amplify Hosting 側に別 WAF があると気付くまでが一番遠回りでした。 最初の対応 — Backend API WAF はあっさり直る Backend API 側の更新手順は、すでに社内で整備されていました。 Amplify Console で対象 app の環境変数 WAF_ALLOWED_IP_LIST を新しい IP の CSV に書き換える Amplify の build を回す CDK で定義された CfnIPSet のリソースが新しい IP で更新される この仕組みのおかげで、dev / stage / prod の Backend API は Day 1 から Day 2 にかけて順次切り替え、いずれも更新自体は数分で完了しました。 WAFv2 のコンソールで IPSet を覗いて、新しい IP が並んでいるのを確認 → よし、終わったな、と思ったのが甘かったです。 念のため、フロントエンドの URL にも curl を投げて確認しました。 $ curl -i https://<env>.example.internal/ HTTP/1.1 403 Forbidden content-type: text/html ... <HTML><HEAD><TITLE>Request blocked.</TITLE></HEAD> <BODY>Request blocked.</BODY></HTML> Request blocked. というレスポンスボディは、AWS WAF の Block ルールが返す典型的な文字列です。S3 オリジンの「Access Denied」とは別物なので、これが見えた時点でほぼ WAF を疑って間違いありません。 :::message ちょっとした見分け方 CloudFront 経由の 403 で、ボディに Request blocked. が入っていれば、ほぼ WAF が原因です。 オリジン(S3 など)の Access Denied であれば、ボディは Access Denied という別の文字列になります。 ::: つまり Backend は直ったが、フロントエンドの経路では別の何かが Block している、という状況です。 真犯人を探す — フロントエンドは別 WAF で守られていた 「フロントエンドも CloudFront → S3 のはず。WAF はどこに付いているんだろう?」と探したところ、Amplify Hosting には AWS WAF 統合(Firewall) という機能があり、これを有効化していたことを思い出しました。 :::message 用語の整理 Amplify Hosting には紛らわしい 2 つの保護機能があります。 Access control : ベーシック認証(ユーザー名 + パスワード)でブランチを保護する機能。IP 制限ではありません。 Firewall(AWS WAF 統合) : AWS WAF v2 と統合し、IP allowlist / レートリミット / マネージドルールなどを適用する機能。 今回 IP allowlist として利用していたのは後者の Firewall 機能のほうです。 ::: Amplify Hosting の Firewall を有効化すると、Amplify サービス側で以下を自動的に作成・関連付けします。 AmplifyIPSet-<guid> という名前の IPSet(us-east-1, scope=CLOUDFRONT) CreatedByAmplify-<appId>-<guid> という名前の WebACL 上記 WebACL を Amplify app のリソース ARN に AssociateWebACL で紐付け そして これらのリソースは CloudFormation / CDK の管理外 です。Amplify サービスが直接 WAF API を叩いて作成しています。 そのため、CDK 側でいくら WAF を更新しても、フロントエンドの WAF は変わりません。 WAFv2 のコンソールから IPSet の一覧を眺めると、確かに AmplifyIPSet-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX のような IPSet が、CDK 由来の my-app-api-allowed-ips-<env> とは別に存在していました。中身を見ると、まさに 旧拠点 IP のみが入っている 状態。これが原因です。 :::message Tips: CloudFront scope の WAF は us-east-1 にしかない CloudFront は Global サービスですが、CloudFront に付けるための WAFv2 リソース(scope=CLOUDFRONT)は us-east-1 のエンドポイントからしか触れません 。 東京リージョン ( ap-northeast-1 ) でいくら aws wafv2 list-ip-sets を叩いても、CloudFront scope の IPSet は出てきません。「IPSet が見当たらない!」と焦ったときの 9 割はこれが原因です。 ::: # 正しい(CloudFront scope は us-east-1 で見る) aws wafv2 list-ip-sets --scope CLOUDFRONT --region us-east-1 # 間違い(REGIONAL scope のものしか返ってこない) aws wafv2 list-ip-sets --scope REGIONAL --region ap-northeast-1 UI と API が一致しない問題 「じゃあ Amplify Console の Firewall 画面で IP を入れ替えればいいか」と思って画面を開いたところ、目を疑う表示が出ていました。 Firewall: 無効(このアプリは Web Application Firewall で保護されていません) つまり「WAF はかかっていません」という意味の表示です。 しかし curl を打つと、明らかに 403 ( Request blocked. ) が返ってくる。どちらを信じればよいのか分からない状況です。 ここで AWS CLI を使って、API レベルで実態を確認します。 1. Amplify app に WebACL が attach されているかを確認 list-resources-for-web-acl を使うと、ある WebACL がどのリソースに attach されているかが分かります。 aws wafv2 list-resources-for-web-acl \ --region us-east-1 \ --web-acl-arn arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/webacl/CreatedByAmplify-XXXXXXXXXX-XXXXXXXX/XXXXXXXX これだけだと 空の配列が返ってきます 。 「あれ、attach されていない?じゃあ何が Block しているの?」と一瞬混乱しました。 ですがこれは罠で、 --resource-type を指定していないとデフォルト値 APPLICATION_LOAD_BALANCER で検索されます。Amplify Hosting の場合、resource-type は AMPLIFY なので、デフォルトでは見えません。 さらに list-resources-for-web-acl は そもそも CloudFront Distribution には使えません (CloudFront の関連付けを調べたいときは aws cloudfront list-distributions-by-web-acl-id を使うのが正解です)。 # 正しい呼び方 aws wafv2 list-resources-for-web-acl \ --region us-east-1 \ --web-acl-arn arn:aws:wafv2:us-east-1:XXXXXXXXXXXX:global/webacl/CreatedByAmplify-XXXXXXXXXX-XXXXXXXX/XXXXXXXX \ --resource-type AMPLIFY これでようやく、Amplify app の ARN が返ってきました。 UI は「Firewall: 無効」と言っていたが、実態としては WebACL がしっかり attach されていた わけです。 :::message ハマりポイント: --resource-type のデフォルト aws wafv2 list-resources-for-web-acl は --resource-type を省略すると APPLICATION_LOAD_BALANCER で検索されます (CloudFront Distribution はこの API では扱えず、 aws cloudfront list-distributions-by-web-acl-id を使う必要があります)。 Amplify Hosting を疑うときは、必ず --resource-type AMPLIFY を明示しましょう。これに気付かないと、「attach されていない」と誤認して別の方向の調査に走ってしまいます。 ::: 2. WebACL のデフォルトアクションを確認 get-web-acl でデフォルトアクションを覗くと、default は Block 、その上で IPSet ベースの allow ルールが乗っかっている構成でした。 aws wafv2 get-web-acl \ --scope CLOUDFRONT --region us-east-1 \ --name CreatedByAmplify-XXXXXXXXXX-XXXXXXXX --id XXXXXXXX \ --query 'WebACL.{DefaultAction:DefaultAction, Rules:Rules[].Name}' つまり、 IPSet に載っていない IP からのアクセスは全部 Block 。 UI 上は「Firewall: 無効」と表示していても、API レベルでは「Block ベース + 古い IPSet で allow」になっており、見事に食い違っていました。 3. なぜ乖離したのか — CloudTrail で犯人探し UI と API がここまで一致しないのは流石におかしいので、CloudTrail で履歴を漁ってみました。 aws cloudtrail lookup-events \ --max-items 50 \ --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateIPSet \ --output json AssociateWebACL や DisassociateWebACL も同じように引いて、時系列で並べると、過去にある担当者が 何度も Associate / Disassociate を繰り返しており、最終的に Associate で終わっていた ことが分かりました。 時系列を追っても、UI が「Firewall: 無効」を表示し続けるに至った直接的な原因までは特定できませんでした。 ただし「Amplify Console の Firewall UI と、WAFv2 API が示す実態とが食い違うケースが起こりうる」という事実だけは、今回の調査で確認できた ことになります。 ともあれ、API の実態を信じるしかないことが確定したので、次は IPSet を直接書き換えに行きます。 解決手順 — IPSet を直接更新 すでに AmplifyIPSet-* がどこにあり、どの WebACL に紐付いているかは分かっているので、あとは WAFv2 の IPSet を直接更新 すれば終わりです。 ただし WAFv2 には楽観的排他制御の仕組みがあって、 LockToken を毎回取り直す必要があります。 :::message WAFv2 の LockToken(楽観的排他制御) Get* 系 API のレスポンスに LockToken が含まれており、 Update* 系の API ではこの LockToken を渡す必要があります。 他のプロセスが先に更新していると WAFOptimisticLockException で失敗します。 毎回 get → update をセットで実行する のが安全です。 ::: 実際に流したコマンドの雛形がこれです。 # 環境変数で接続先を切り替え(dev/stage/prod ごとに AWS_PROFILE を変える) export AWS_PROFILE=my-app-prod IPSET_NAME=AmplifyIPSet-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX IPSET_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX # 1) 現在の LockToken を取得 LOCK=$(aws wafv2 get-ip-set \ --scope CLOUDFRONT --region us-east-1 \ --name "$IPSET_NAME" --id "$IPSET_ID" \ --query "LockToken" --output text) # 2) IPSet の中身を新しい IP リストで上書き aws wafv2 update-ip-set \ --scope CLOUDFRONT --region us-east-1 \ --name "$IPSET_NAME" --id "$IPSET_ID" \ --lock-token "$LOCK" \ --addresses 192.0.2.10/32 192.0.2.11/32 198.51.100.0/24 203.0.113.0/24 これを dev / stage / prod の 3 環境で順に実行し、それぞれの環境で curl を打って 200 OK を確認しました。 :::message ポイント update-ip-set の --addresses は 上書き です(既存に追加ではなく差し替え)。誤って一部の IP を漏らすと、その IP からのアクセスが全部止まるので、現在の中身を一度ファイルに保存してから差分を取り、新しいリストとして渡すのが安全です。 ::: 学んだこと 1. 「同じ目的のリソースが複数経路で管理されている」状態を疑う 今回は、 my-app-api-allowed-ips-<env> と AmplifyIPSet-* という、 同じ「拠点 IP 許可リスト」を別々の場所で独立に管理している 構造が根本原因でした。 インフラを段階的に作っていくと、こうした「二重管理状態」がいつのまにか出来上がっていることがあります。今回のような全社的な IP 変更のタイミングは、その整理の絶好の機会でもあるな、と感じました。 2. UI を信用せず、API で実態を確認する癖を付ける Amplify Console のような上位のマネジメントコンソールは、内部で何かをキャッシュしていたり、過去の状態を表示し続けていたりすることがあります。 今回のように UI 表示 (「Firewall: 無効」) と API が示す実態 (Block ルール有り) が一致しないパターンも、十分起こり得ます。 3. list-resources-for-web-acl の --resource-type を忘れない WAFv2 の list-resources-for-web-acl は、 --resource-type を省略すると デフォルト値 APPLICATION_LOAD_BALANCER で検索されます 。Amplify Hosting の場合は AMPLIFY を明示する必要があります。 また、この API は CloudFront Distribution を対象に取れません 。CloudFront の関連付けは aws cloudfront list-distributions-by-web-acl-id という別の API を使うことになっており、これを知らないと「該当 WebACL がどこにも attach されていない」と誤認してしまいます。 今回も、ここに気付くまでが一番大きく時間を溶かしたポイントでした。 4. CloudFront scope の WAF は us-east-1 にしかない これは基礎中の基礎なのですが、改めて。 WAFv2 で CloudFront に付けるリソースは 必ず us-east-1 にあります。CLI なら --region us-east-1 必須、コンソールなら左上のリージョンを Global (CloudFront) に切り替える必要があります。 5. 「Request blocked.」というレスポンスボディは WAF Block の典型 CloudFront 経由の 403 で、ボディに Request blocked. の文字列があれば、ほぼ WAF の block ルールが原因です。S3 の Access Denied とは見た目で区別できるので、最初の切り分けに便利です。 使ったコマンドまとめ トラブルシュート中に何度も叩いたコマンドを、ここに集めておきます。 IPSet 周り # IPSet 一覧(CloudFront scope) aws wafv2 list-ip-sets --scope CLOUDFRONT --region us-east-1 # IPSet の中身を確認 aws wafv2 get-ip-set \ --scope CLOUDFRONT --region us-east-1 \ --name <name> --id <id> \ --query "IPSet.Addresses" --output json # IPSet の更新(LockToken 必須) LOCK=$(aws wafv2 get-ip-set \ --scope CLOUDFRONT --region us-east-1 \ --name <name> --id <id> \ --query "LockToken" --output text) aws wafv2 update-ip-set \ --scope CLOUDFRONT --region us-east-1 \ --name <name> --id <id> \ --lock-token "$LOCK" \ --addresses 192.0.2.0/24 198.51.100.0/24 WebACL の attach 先確認 # Amplify Hosting に付いているかを確認(--resource-type を忘れない!) aws wafv2 list-resources-for-web-acl \ --region us-east-1 \ --web-acl-arn <arn> \ --resource-type AMPLIFY CloudTrail で履歴を追う # 特定の API イベントを引く aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateIPSet \ --max-items 20 --output json # 特定リソースに対する操作履歴 aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=ResourceName,AttributeValue=<arn> まとめ Request blocked. だけが手がかりの 403 から、Amplify Hosting 裏の WAF の存在に気付き、UI と API の乖離まで辿り着いた、というやや遠回りなトラブルシュート記でした。 整理すると、今回の学びは次の通りです。 同じ目的のリソースが 複数経路 で管理されていないかを疑う マネジメントコンソールの UI は実態と一致しないことがある。 最後の真実は API レスポンス WAFv2 の list-resources-for-web-acl は --resource-type を忘れずに CloudFront scope の WAF は 必ず us-east-1 WAFv2 の更新は LockToken をセットで 扱う Amplify Hosting の Firewall (AWS WAF 統合) は便利ですが、「いつのまにか UI と実態が乖離する」リスクがあることは覚えておいて損はないと思います。 同じような構成の運用に関わる方の参考になれば嬉しいです。最後まで読んでいただきありがとうございました! 参考リンク AWS WAF Developer Guide — How AWS WAF works AWS Amplify Hosting User Guide — Firewall support for Amplify hosted sites AWS Blog — Firewall support for AWS Amplify hosted sites list-resources-for-web-acl API リファレンス(WAFv2) list-distributions-by-web-acl-id API リファレンス(CloudFront) lookup-events API リファレンス(CloudTrail) aws/aws-cli #5417 — wafv2 list-resources-for-web-acl only fetches load balancers by default (本記事で触れた --resource-type デフォルト挙動の罠について、CLI リポジトリで「ドキュメントに記載がない」と指摘された Issue)
こんにちは。 KINTO テクノロジーズの DBRE チーム所属の @hoshino です。 はじめに Aurora MySQL 2系(MySQL 5.7互換)から3系(MySQL 8.0互換)へのメジャーバージョンアップを、19クラスタ・46スキーマ規模のメインシステムで実施しました。 このバージョンアップで最も苦労したのが COLLATION の問題です。 Aurora MySQL 3系ではデフォルト COLLATION が utf8mb4_0900_ai_ci に変わりますが、既存システムでは、検索条件、ORDER BY、ユニーク制約、JOIN、帳票、バッチ処理などが utf8mb4_general_ci の比較・ソート挙動を前提に動いています。 utf8mb4_0900_ai_ci への変更は単なる DB 設定変更ではなく、アプリケーション仕様の変更に近いため、今回は互換性維持を優先し、 utf8mb4_general_ci を維持したまま移行する方針を取りました。 しかし、Aurora MySQL 3系では default_collation_for_utf8mb4 が utf8mb4_0900_ai_ci 固定で、サーバー側で変更する手段が用意されておらず、明示的に指定しないとセッションのデフォルトが utf8mb4_0900_ai_ci になってしまいます。そのため、 utf8mb4_general_ci を維持するために以下の対策を実施しました。 今回実施した対策 SCHEMA / TABLE / COLUMN / VIEW / ROUTINE / TRIGGER / EVENT の COLLATION を統一 接続設定・SQL クエリで COLLATION を明示指定することで COLLATION を制御 意図しない COLLATION が設定されないように information_schema を使った Slack 自動通知によるチェック体制の整備 本記事では、これらの対策の詳細について説明します。 背景 KINTO テクノロジーズの DBRE チームでは、Aurora MySQL 2系(MySQL 5.7互換)から3系(MySQL 8.0互換)へのメジャーバージョンアップを進めてきました。 弊社では多数のクラスタを運用していますが、今回対象となったのは複数プロダクトが共有するメインシステムの DB です。 このメインシステムは少し特殊な構成になっています。 1つの環境に対して 2つの Aurora クラスタが存在しており、複数プロダクトがこの2クラスタを共有して利用しています。 両クラスタは密接に連携しているため、片方だけバージョンアップするわけにはいかず、同時に移行する必要がありました。 対象規模は dev・stg・prod などの全環境を合計して 19クラスタ・46スキーマ・56ユーザー にのぼります。 構成を図にすると以下のようになります。 この移行で最も苦労したのが COLLATION の問題でした。 Aurora MySQL 3系(MySQL 8.0)のデフォルト COLLATION は utf8mb4_0900_ai_ci です。 一方、既存のデータベースは utf8mb4_general_ci で運用されていました。 システム全体を utf8mb4_0900_ai_ci に切り替えるという選択肢もゼロではありませんでしたが、COLLATION の変更はアプリケーションの挙動に直接影響します。 utf8mb4_general_ci と utf8mb4_0900_ai_ci は、どちらも大文字・小文字を区別しない COLLATION ですが、内部のソートアルゴリズムが異なります。 utf8mb4_0900_ai_ci は Unicode Collation Algorithm(UCA 9.0.0)に準拠しており、 = 演算子による比較結果や ORDER BY のソート順が utf8mb4_general_ci とは異なるケースがあります。 既存のアプリケーションが utf8mb4_general_ci の挙動を前提としている場合、COLLATION を切り替えただけで検索結果やソート順が変わり、意図しない不具合につながる可能性があります。 そうなると各プロダクト側でも影響調査や改修が必要になります。 複数プロダクトが共有しているデータベースであるため、その改修範囲は広く、プロダクト側の開発コストも大きくなります。 プロダクト側の負担を最小限にするためにも、 utf8mb4_general_ci を維持したままバージョンアップするという方針を選択しました。 Illegal mix of collations に対する対応 utf8mb4_general_ci を維持する方針で進めるにあたって直面したのが、 Illegal mix of collations というエラーです。 このエラーは、テーブル側の COLLATION とセッション側の COLLATION が混在した状態でクエリを実行したときに発生します。Aurora MySQL 3系では、サーバー側でデフォルト COLLATION を変更する手段がないため、何も対策しないとこのエラーが発生しやすい構造になっています。 MySQL 8.0 には default_collation_for_utf8mb4 というシステム変数があります( MySQL 公式: Server System Variables )。 これは CHARACTER SET utf8mb4 を指定して COLLATE を省略したとき、どの COLLATION がデフォルトで使われるかを決める変数で、デフォルト値は utf8mb4_0900_ai_ci です。 通常の MySQL であれば、 SET PERSIST default_collation_for_utf8mb4='utf8mb4_general_ci'; を実行することでこの値を変更できますが、Aurora MySQL ではこの変数を変更する手段がありません。 理由としては SET PERSIST は Aurora では使えず、パラメータグループにもこの設定項目が存在しないためです。 この制約により、 collation_connection を指定せずに接続した場合、セッションのデフォルトが utf8mb4_0900_ai_ci になってしまいます。 影響は実行するクエリだけではありませんでした。 VIEW や ROUTINE(ストアドプロシージャ・ファンクション)は、作成時のセッションの character_set_client や collation_connection が定義に依存するため、 utf8mb4_0900_ai_ci のセッションで VIEW を作成すると、その VIEW 自体が utf8mb4_0900_ai_ci を持ってしまいます。 後からセッションの COLLATION を変えても、すでに作成された VIEW の定義は変わりません。 さらに、クエリの中で COLLATION が動的に決まる箇所にも影響します。 たとえば UNION や CAST 関数を含むクエリでは、TABLE 側の COLLATION( utf8mb4_general_ci )とセッション側の COLLATION( utf8mb4_0900_ai_ci )が混在してエラーが発生します。 例1:CAST 関数を使った JOIN SELECT * FROM table_a AS t1 JOIN table_b AS t2 ON CAST(t1.id AS CHAR) = t2.code; -- ^^^^^^^^^^^^^^^^^^ ^^^^^^^ -- utf8mb4_0900_ai_ci utf8mb4_general_ci -- (セッションのデフォルト)(テーブルの COLLATION) CAST(t1.id AS CHAR) はセッションの collation_connection に従うため、Aurora のデフォルトである utf8mb4_0900_ai_ci になります。一方、 t2.code はテーブル定義の utf8mb4_general_ci のままです。この2つを = で比較するため、COLLATION の不一致が発生します。 例2:UNION で異なる COLLATION が混在 SELECT name FROM table_a -- ^^^^ -- utf8mb4_general_ci(テーブルの COLLATION) UNION SELECT CAST(id AS CHAR) FROM table_b; -- ^^^^^^^^^^^^^^^^ -- utf8mb4_0900_ai_ci(セッションのデフォルト) UNION は各 SELECT の COLLATION を統一する必要がありますが、上記のように一方が utf8mb4_general_ci 、もう一方が utf8mb4_0900_ai_ci になると統一できず、エラーになります。 どちらのクエリも、最終的には以下のエラーになります。 ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_0900_ai_ci,IMPLICIT) for operation '=' これを防ぐには、接続時に COLLATION を明示的に指定するか、SQL 文の中で COLLATE 句を明示する方法があります。 方法1:接続時に COLLATION を指定する -- MySQL クライアントから接続する場合 SET NAMES utf8mb4 COLLATE utf8mb4_general_ci; # JDBC URL での指定例 jdbc:mysql://host:3306/mydb?connectionCollation=utf8mb4_general_ci 方法2:SQL 文の中で COLLATE 句を明示する UNION や CAST など動的に COLLATION が決まる箇所に、直接 COLLATE 句を付与する方法です。 -- UNION での指定例 SELECT name COLLATE utf8mb4_general_ci FROM table_a UNION SELECT name COLLATE utf8mb4_general_ci FROM table_b; -- CAST での指定例 SELECT CAST(column AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_general_ci FROM table_a; しかし、接続時の COLLATION 指定やクエリへの COLLATE 句付与は、あくまで移行後の運用で問題を防ぐための対策です。 移行するにあたり、移行前に Aurora 2系側の COLLATION を統一しておく必要がありました。 注意事項 SET NAMES utf8mb4 (COLLATE 句を省略)を実行すると、それまでに設定していた collation_connection が破棄され、Aurora MySQL 3系のデフォルトである utf8mb4_0900_ai_ci に戻ってしまいます。 SET SESSION collation_connection = 'utf8mb4_general_ci'; -- ↑ ここで utf8mb4_general_ci になる SET NAMES utf8mb4; -- ↑ COLLATE 句がないため utf8mb4_0900_ai_ci に戻ってしまう ORM やアプリケーションフレームワークが内部で SET NAMES utf8mb4 を発行する実装も存在するため、実際に発行されるクエリのログを確認し、暗黙の SET NAMES が含まれていないかを把握しておく必要があります。 SET NAMES を使う場合は、必ず COLLATE 句までセットで指定するのが確実です。 移行手順と事前準備 移行方法 今回の移行は mysqldump などで論理ダンプを取得し、それをインポートする方式を採用しました。 Aurora MySQL 2系から3系への移行方式としては、Blue/Green デプロイやインプレースアップグレードといった選択肢もありますが、今回は以下の理由からダンプ・インポートを採用しました。 COLLATION の事前調整で DDL 変更が必要だった VIEW や ROUTINE の定義を書き換えて再作成する必要がありましたが、Blue/Green デプロイでは DDL 変更が Green 環境へのレプリケーション中断を引き起こすリスクがあり、レプリケーションとの互換性検証コストが高いと判断しました ダンプ・インポート方式の社内実績が豊富だった 弊社では全環境で数百の DB クラスタが存在しており、そのほとんどをダンプ・インポート方式で移行しました そのため、今回のような複数プロダクトが共有する大規模システムの移行において、Blue/Green デプロイなどの実績のない手法を採用するリスクは取れませんでした 安全を最優先に考えた結果、確実にコントロールできるダンプ・インポート方式を選択しました。 ダンプ・インポート時の COLLATION エラー ダンプ・インポート方式で移行を進めたところ、COLLATION の不整合によるエラーが発生しました。 Aurora 2系側の COLLATION が utf8mb4_general_ci に統一されていない状態でダンプを取ってインポートすると、VIEW の作成時に Illegal mix of collations エラーとなり、移行そのものが失敗します。 そのため、以下の手順で移行を実施しました。 Aurora 2系側で COLLATION を utf8mb4_general_ci に統一する その状態でダンプを取得する Aurora 3系にインポートする 事前作業の内容 事前作業では SCHEMA / TABLE / COLUMN / VIEW / ROUTINE のすべてに手を入れる必要がありました。 対象となるのは2クラスタ × 全環境(dev・stg・prod 等)にまたがる数十スキーマです。複数プロダクトが共有しているため、各スキーマの VIEW や ROUTINE がどのプロダクトに属するかを把握し、プロダクトチームと調整しながら進める必要がありました。 調整箇所は全環境合計で数千にのぼり、環境ごとにリストを作成し、プロダクトチームにレビューを依頼し、反映前に最終チェックを行うというサイクルを、すべての環境に対して繰り返し実施しました。 以下、具体的な調整方法をオブジェクトの種類ごとに説明します。 SCHEMA / TABLE / COLUMN の調整 SCHEMA・TABLE・COLUMN は ALTER 文で COLLATION を utf8mb4_general_ci に変更しました。 -- SCHEMA ALTER DATABASE ${schema} CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- TABLE ALTER TABLE ${table} CHARACTER SET utf8mb4 COLLATE 'utf8mb4_general_ci'; -- COLUMN ALTER TABLE ${table} CONVERT TO CHARACTER SET utf8mb4 COLLATE 'utf8mb4_general_ci'; VIEW / ROUTINE / TRIGGER / EVENT の調整 一方、VIEW・ROUTINE・TRIGGER・EVENT は ALTER では対応できないため、定義を書き換えて再作成する必要がありました。 定義内の文字コード・COLLATION を一括で置換してから CREATE OR REPLACE VIEW で再作成するアプローチを取りました。主な置換パターンは以下の通りです。 "utf8 " → "utf8mb4 " "utf8_general_ci" → "utf8mb4_general_ci" "utf8mb4_0900_ai_ci" → "utf8mb4_general_ci" "utf8mb4_unicode_ci" → "utf8mb4_general_ci" "charset utf8mb4) AS" → "charset utf8mb4) COLLATE utf8mb4_general_ci AS" 最後のパターンは CAST 関数の末尾に該当します。 CAST(column AS CHAR) のような式では COLLATION が動的に決まるため、明示的に COLLATE を付与する必要がありました。 ただし、文字列置換だけでは対応しきれないケースも存在しました。 CAST 関数の使い方が複雑であったり、置換パターンに収まらない定義を持つ VIEW がいくつかありました。 こうした箇所は information_schema で COLLATION の状態を一つひとつ確認しながら、手動で定義を修正して再作成しました。 -- 置換漏れがないか確認するクエリ SELECT table_schema, table_name, character_set_client, collation_connection FROM information_schema.views WHERE collation_connection != 'utf8mb4_general_ci' AND table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'); この確認を怠ると、一見すると置換が完了しているように見えても utf8mb4_0900_ai_ci が残ったままの定義が存在する場合インポート時にエラーが発生するか、移行後に Illegal mix of collations となってしまいます。 移行後に発生したインシデント 事前作業で Aurora 2系の COLLATION を統一し、Aurora 3系への移行を完了しました。 しかし移行後、プロダクトから Illegal mix of collations のエラーが発生したとの報告がありました。 発生したエラー エラーの内容は以下の通りです。 1267 (HY000): Illegal mix of collations (utf8mb4_0900_ai_ci,IMPLICIT) and (utf8mb4_general_ci,IMPLICIT) for operation '=' Aurora 2系では問題なく動作していた機能が、Aurora 3系への移行後にエラーとなっていました。 原因の調査 まず、エラーが発生しているクエリの調査を行いました。 問題のクエリには CAST 関数を使った JOIN が含まれていました。 以下は同様の構造を持つ例です。 -- 例:CAST 関数を使った JOIN で COLLATION の不一致が発生するケース SELECT * FROM table_a AS t1 LEFT JOIN table_b AS t2 ON CAST(t1.id AS CHAR) = t2.code; CAST(... AS CHAR) の結果にはセッションの collation_connection が適用されます。 該当のアプリケーションでは collation_connection が指定されておらず、Aurora のデフォルトである utf8mb4_0900_ai_ci が適用されていました。 その結果、CAST 関数の結果は utf8mb4_0900_ai_ci となり、テーブル側の utf8mb4_general_ci と混在して Illegal mix of collations が発生していました。 対処方法 対処として、アプリケーションの DB 接続設定に collation_connection=utf8mb4_general_ci を追加しました。 # 接続文字列に COLLATION 設定を追加 mysql+mysqlconnector://user:password@host/dbname ?init_command=SET SESSION collation_connection=utf8mb4_general_ci init_command は接続確立直後に実行されるため、以降のクエリでは collation_connection が utf8mb4_general_ci の状態で処理されます。 collation_connection が utf8mb4_general_ci になることで、 CAST や UNION のようにセッションの COLLATION 値で動的に COLLATION が決まる箇所も utf8mb4_general_ci に揃えられ、テーブル側との不一致を防げます。 この変更をリリースした後、エラーは解消し、現在は安定稼働しています。 COLLATION の定期チェックと自動通知 一度問題を修正しても、新しい VIEW が作成されたりアプリケーションが更新されたりすると、同様の問題が再発する可能性があります。 本番環境でエラーが発生してから気づくのではなく、開発段階で COLLATION の不一致を早期に検知するために、全環境の COLLATION の状態を定期的にチェックし、意図しない COLLATION が設定された場合に自動で通知する仕組みと、手動で現状のCOLLATIONの状態を確認できる仕組みを構築しました。 自動化の仕組み 仕組みの全体像は以下の通りです。 1. 日次で COLLATION 情報を自動取得 COLLATION をチェックするクエリを CLI コマンドとして実装しました。 このコマンドを全クラスタに対して日次で自動実行し、取得結果を JSON 形式で S3 に保存しています。 2. 期待する COLLATION との照合と Slack 通知 EventBridge で決まった時間に、S3 上の JSON データを精査します。 DynamoDB にあらかじめ登録してある「期待する COLLATION」と照合し、意図しない COLLATION が検出された場合は Slack の専用チャンネルに自動通知します。 3. CLI による手動チェック CLI コマンドは手動でも実行できます。 新規 TABLE 作成後やトラブルシューティング時など、任意のタイミングで特定のクラスタの状態を確認したい場合に使用しています。 COLLATION チェックで実行しているクエリ 自動化の仕組みの中で各クラスタに対して実行しているクエリは、 information_schema を使って utf8mb4_general_ci 以外の COLLATION が混入していないかを検出するものです。対象が SCHEMA・TABLE・COLUMN・VIEW・ROUTINE・TRIGGER の6種類です。 -- SCHEMA の COLLATION 確認 SELECT schema_name, default_character_set_name, default_collation_name FROM information_schema.schemata WHERE schema_name NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'); -- TABLE の COLLATION 確認 SELECT table_schema, table_name, table_collation FROM information_schema.tables WHERE table_collation != 'utf8mb4_general_ci' AND table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'); -- COLUMN の COLLATION 確認 SELECT table_schema, table_name, column_name, collation_name FROM information_schema.columns WHERE collation_name IS NOT NULL AND collation_name != 'utf8mb4_general_ci' AND table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'); -- VIEW の collation_connection 確認 SELECT table_schema, table_name, character_set_client, collation_connection FROM information_schema.views WHERE collation_connection != 'utf8mb4_general_ci'; -- ROUTINE の COLLATION 確認 SELECT routine_schema, routine_name, routine_type, collation_connection, database_collation FROM information_schema.routines WHERE collation_connection != 'utf8mb4_general_ci'; -- TRIGGER の COLLATION 確認 SELECT trigger_schema, trigger_name, collation_connection, database_collation FROM information_schema.triggers WHERE collation_connection != 'utf8mb4_general_ci'; 今後のAuroraバージョンアップ(Aurora MySQL 4)に向けて MySQL 8.4 で default_collation_for_utf8mb4 を SET PERSIST で変更すると、以下の deprecated 警告が表示されます。 mysql> SET PERSIST default_collation_for_utf8mb4='utf8mb4_general_ci'; Query OK, 0 rows affected, 1 warning (0.00 sec) mysql> SHOW WARNINGS; +---------+------+--------------------------------------------------------------------------------------------------------+ | Level | Code | Message | +---------+------+--------------------------------------------------------------------------------------------------------+ | Warning | 1681 | Updating 'default_collation_for_utf8mb4' is deprecated. It will be made read-only in a future release. | +---------+------+--------------------------------------------------------------------------------------------------------+ 「将来のリリースで read-only にする」と警告されていることから、今後この変数による COLLATION の制御はさらに難しくなる可能性があります。COLLATION を確実に制御するためには、SCHEMA・TABLE・COLUMN・VIEW・ROUTINE のすべてで明示指定し、 information_schema で定期的にチェックするアプローチが引き続き有効です。 Aurora MySQL のリリースカレンダー によると、Aurora MySQL 3 のメジャーバージョン標準サポートは 2028年4月30日 までとなっています。その後は次のメジャーバージョンへの移行が必要になるため、今回整備した定期チェックの仕組みや CLI コマンドを次のバージョンアップでもそのまま活用できるようにしておくことが重要だと考えています。 まとめ Aurora MySQL 3系では MySQL 8.0 互換となり、デフォルト COLLATION が utf8mb4_0900_ai_ci に変わったことで、既存 DB の utf8mb4_general_ci と混在しやすくなった。これが今回の苦労の根本原因 Aurora MySQL では SET PERSIST で default_collation_for_utf8mb4 を変更できないため、サーバー側で utf8mb4 の デフォルト COLLATION を制御できない 接続時に collation_connection を明示指定しないと、セッションのデフォルトが utf8mb4_0900_ai_ci となり Illegal mix of collations が発生する可能性がある ダンプ・インポートで移行する場合、移行前に Aurora 2系側の SCHEMA / TABLE / COLUMN / VIEW / ROUTINE の COLLATION を統一しておく必要がある SET NAMES utf8mb4; (COLLATE 省略)は直前の COLLATE 指定を破棄するため、接続文字列の init_command で指定するのが確実 移行後も information_schema を使った COLLATION の定期チェックと自動通知の仕組みが有効 今回整備した COLLATION チェックの仕組みや CLI コマンドは、次のバージョンアップでもそのまま活用できる 本記事の内容が、同じ課題に取り組んでいる方々の参考になれば幸いです。 参考文献 Changes in MySQL 8.0 collation_server のデフォルトが utf8mb4_0900_ai_ci に変更 Server System Variables default_collation_for_utf8mb4 パラメータの補足 接続に関するパラメータの理解 character_set_* / collation_* 各パラメータの関係 SET NAMES の補足 セッションの COLLATION 指定方法
はじめに こんにちは、2026年2月入社の岩月です! 本記事では、2026年2月入社のみなさまに入社直後の感想をお伺いし、まとめてみました。 KINTO テクノロジーズ(以下、KTC)に興味のある方、そして、今回参加下さったメンバーへの振り返りとして有益なコンテンツになればいいなと思います! 森田和明 ![森田和明さんのプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/morita.jpg =300x) 自己紹介 コーポレートIT部AIファーストGの森田です。 社内の生成AI活用の推進やトヨタグループにおけるAI活用支援を担当しています。 奈良に住んでます。 最近書籍を執筆しました! AWSではじめるMCP実践ガイド 所属チームの体制は? AIファーストGは「AI Transformation 」「AI Engineering」「AI Development」の3チーム体制で、私はAI Engineeringに所属です。 「アイデア生成」→「実現可能性の検証」→「実施とデリバリー」→「ケース展開」→「アイデア生成」とループを回し、AI活用の活性化に取り組んでいます KTCへ入社したときの第一印象?ギャップはあった? 入社前のカジュアル面談などを通じて思っていた通りでした。 エンジニアが多い会社ではありますが、技術スタックが様々で、各自がそれぞれの分野でスペシャリストという印象です。 AIファーストGも全員バックグラウンドが違うので、それぞれの得意分野とAIを掛け合わせて専門性を発揮しています。 現場の雰囲気はどんな感じ? 私はOsaka Tech Labで勤務していまして、まず、オフィスが綺麗です。 所属は様々ですが「大阪を盛り上げていこう!」という雰囲気があり、技術交流イベントなど一致団結できる取り組みがあります。 ブログを書くことになってどう思った? 趣味として技術ブログをやっているので、すんなり書けました! 岩月 ⇒ 森田さんへの質問 森田さんは技術系の書籍をいくつか執筆されていますが、執筆のきっかけや苦労話があったら教えてください! 私が執筆した書籍は、執筆メンバーが共著者を探している中で、声をかけてもらって参加したというのが経緯です。 苦労はたくさんありますが(笑)、扱うテーマがAWSや生成AIなので執筆している最中にアップデートがあり、その度に原稿の更新や画面キャプチャの取り直しを行っています 成島大介 ![成島大介さんのプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/narushima.jpg =300x) 自己紹介 新サービス開発部 プロジェクト推進Gに所属しています。 名古屋オフィス勤務です。 トヨタグループ向け案件のプロジェクトマネジメントを担当しています。 所属チームの体制は? プロジェクト推進Gは兼務除くと5名体制で名古屋3名、東京1名、福岡1名です。 プロジェクトマネージャだけの組織なので、開発メンバーは状況に応じて他部署から参画してもらい開発体制を作ります。 KTCへ入社したときの第一印象?ギャップはあった? 名古屋オフィスに開発者が少ないことが入社前の印象とのギャップです。 KINTOとKTCが同じフロアなので、入社前のオフィス見学では気づきませんでした。 現場の雰囲気はどんな感じ? 仕事については、問題課題がない限り任されていると感じます。 一緒にランチに行く機会が多くいろいろ情報収集できて助かってます。 ブログを書くことになってどう思った? 個人(匿名)で技術ブログは書いてましたが、最近はさっぱりです。 一番読んでもらえた記事が専門外のC++ネタで何を書くと良いのか分かってません。 森田さん ⇒ 成島さんへの質問 最近の猫ちゃんの面白エピソードを教えてください! 猫がドアノブに飛びついてドアを開けることをマスターしました。 娘(中3)の部屋にも問答無用で侵入します(ドア全開)。 娘も親には怒るが、猫には怒りません。 きゅーじ ![きゅーじさんのプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/kyuji.jpg =300x) 自己紹介 my route開発部ビジネス開発支援グループに所属しています。 勤務場所は福岡のFukuoka Tech Labです。 所属チームの体制は? 主にトヨタファイナンシャルサービス株式会社のmyroute業務支援を行っています。主に営業、マーケティング業務をサポートしており、2~5名のチームで動いています。 KTCへ入社したときの第一印象?ギャップはあった? Fukuoka Tech Labからの景色が最高に良い!※入社後初めて入りました。 オンボーディングがしっかりあり、安心して業務が開始できました。 現場の雰囲気はどんな感じ? 良いプレッシャーの中で、和やかな雰囲気かなと思います。 それぞれの個性と強みを生かしながら、どんどん仕事を作っていく感じが良いなと思っています。 ブログを書くことになってどう思った? テックブログは、書いたことなかったかつ、非エンジニアの私が書けるのか不安でした。 成島さん ⇒ きゅーじさんへの質問 ミシンで最近作った作品教えてください! 2月にミシンを買っていろいろ作ろうと息巻いておりましたが、現状、カーテンの裾上げ、布団カバーの修理等々が私の作品ですかね。クッションカバーを今度作ろうと布屋さんに行こうと思います。 かわちゃん ![かわちゃんさんのプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/kawachan.jpg =300x) 自己紹介 my route開発部のプロダクト推進グループに所属しています。 神保町オフィス勤務です。 所属チームの体制は? プロダクト開発チームにいますが、私を含め4名です。うち3名はプロデューサーとして、うち1名は他部署から分析部分のみお手伝いいただいています。 KTCへ入社したときの第一印象?ギャップはあった? オンボーディングの研修が手厚くて驚きました。 フリーアドレスかなと思ったのですが、固定だったのが新鮮でした。 現場の雰囲気はどんな感じ? 大人数ですが、思った以上に静かな部署です。 外国籍の方が多いので最初ドキドキしましたが今は慣れました。 ブログを書くことになってどう思った? テックブログは書いたことがないのでちょっと焦りました。 きゅーじさん ⇒ かわちゃんさんへの質問 国内旅行でおすすめの場所はありますか? あまり国内も海外も旅行に行っておらずおすすめできる場所がありませんが、高知は2回ほど行っていて居心地が良かったです。桂浜がとても素敵でした。 SHN ![SHNさんのプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/shn.jpg =300x) 自己紹介 KTC 業務システム開発部に所属しつつ、現在は KINTO 業務部に出向(兼務)しています。 名古屋市在住で、桜通オフィスに勤務しています。 バックオフィス業務の改善や効率化を担当しています。 所属チームの体制は? 出向先の KINTO 業務部では、IT 推進チームに所属しており、4 人体制で業務にあたっています。 バックオフィス業務を IT 目線で改善するチームとして、KTC の開発編成部や業務委託先などの関係者と連携しながら仕事を進めています。 KTCへ入社したときの第一印象?ギャップはあった? 良い意味で「トヨタっぽくない」ところがギャップでした。 トヨタ系列の会社ということで、縦割りな組織・慎重な意思決定・多重な申請フローなどがある程度あるだろうと想像していましたが、実際にそんなことはなく、オープンでフランク、かつスピード感のある職場だと感じています。 現場の雰囲気はどんな感じ? KINTO 業務部はサービスを円滑に運営するため、販売店様との架電対応を担うメンバーも多く、適度な緊張感があります。 IT スキルだけでなく、リースや保険を含む KINTO サービスへの深い理解がなければ対応しきれない場面も多く、日々多くのことを学んでいます。 ブログを書くことになってどう思った? KTC への応募・入社を検討する前からこのブログを読んでいたので、「とうとう自分の番が来たか」という感慨がありました。 応募・入社を検討されている方にとって有益な情報を発信できる、良い機会だと思っています。 かわちゃんさん ⇒ SHNさんへの質問 ランニングするときのこだわりや、自分だけのルールはありますか? ランニングは習慣的に続けているのですが、「今日は走りたくないな」と感じる日も正直よくあります。 そんな日は、走り終わった後にコンビニへ直行してアイスやスイーツを買うことを自分へのご褒美にして、モチベーションを保つようにしています。 岩月 ![岩月のプロフィール画像](/assets/blog/authors/iwatsuki/2026-05-15-newcomer-202602/iwatsuki.jpg =300x) 自己紹介 コーポレートIT部コーポレートIT Gの岩月です。 社内IT業務の改善や効率化のために座席表システムをはじめとするいくつかのツールを開発しています。 所属チームの体制は? 東京と名古屋合わせて11名が在籍しています。 人数もそれなりに多く業務も多種多様なチームなので、これから業務範囲を広げていく中で、少しずつ全体像を理解していきたいと思っています。 KTCへ入社したときの第一印象?ギャップはあった? 生成AIを活用した、スピード感のある社内IT改善に取り組めると感じて入社しました。 前々職での上司や前職の同僚が在籍していて、入社前から社内の様子を伺えていたこともあり、大きなギャップは感じていません。 現場の雰囲気はどんな感じ? 打ち合わせで積極的に発言が飛び交い、現場からボトムアップに課題を挙げて改善していく雰囲気があると感じています。 ブログを書くことになってどう思った? ここしばらく書く機会がなかったのですが、こうした機会をいただけるのであれば、今後は積極的に情報発信していきたいと思います。 SHNさん ⇒ 岩月への質問 デスクワークで手放せない or 仕事が捗るガジェットはありますか? 業務端末にはセキュリティを考慮して個人所有のデバイスは接続していませんが、その分ソフトウェアで工夫しています。 業務効率化のために自作しているmacOS用のアプリで、メニューバーに次の予定の時刻を常時表示しつつ、当日のスケジュール確認や、ワンクリックでZoom・Teams・Slackハドルへの参加、会議資料へのアクセスができるようにしています。 これまでも似たアプリを個人で作って使い続けていたこともあり、今やこれがないと会議の時間を忘れてしまう体になってしまいました。 さいごに みなさま、入社後の感想を教えてくださり、ありがとうございました! KINTOテクノロジーズでは日々、新たなメンバーが増えています! 今後もいろんな部署のいろんな方々の入社エントリが増えていきますので、楽しみにしていただけましたら幸いです。 そして、KINTOテクノロジーズでは、まだまださまざまな部署・職種で一緒に働ける仲間を募集しています! 詳しくは こちら からご確認ください!
はじめに KINTOテクノロジーズ Osaka Tech Lab所属のひがしです。 2026年3月末に、年度末イベント【O-KINI FY2026】を開催しました! Osaka Tech Labでは、メンバーそれぞれが異なるプロジェクトを担当することも多く、普段はなかなか横のつながりが生まれにくいこともあります。そのため、拠点全体で交流を深める文化を大切にしており、今回のイベントもその一環です。 2025年度の活躍をメンバー同士で労いながら、一体感をさらに高めることを目的に、有志メンバーが企画したOsaka Tech Lab初の取り組みとなりました。 企画チーム7名のうち5名は入社1年以内のメンバー。先輩社員2名からOsaka Tech Labの雰囲気や文化を吸収しながら、一緒にイベントを作り上げました。そんな新入り5名で、当日の様子をブログにまとめます! ノベルティ 執筆者:m イベントを行うにあたり今回様々なノベルティを用意しました! 参加してくれたみなさん全員に、Osaka Tech Labらしさをぎゅっと詰め込んだネックストラップとステッカーを用意しました。 また、受賞者の皆さんにはデスクに飾るとふと目に入るたびに「今年もがんばろう」と前向きな気持ちになれる、そんな"日常の中でふりかえれる記念品"になることを目指しました。 全員向け ネックストラップ ステッカー 表彰者 アクリルスタンド トロフィー これらのノベルティのデザインは、すべてOsaka Tech Lab所属のデザイナーが担当しました。 Osaka Tech Labらしさを大切にしながら、日常使いもしやすい世界観に仕上げています。 ![](/assets/blog/authors/higashiji/20260501/image2_trophy.JPG =600x) FY2026 振り返り 執筆者:S.N イベントのトップバッターを飾ったのは、FY2026(2025年度)の拠点振り返りコンテンツです! 出来事の報告にとどまらず、メンバー個人の「色」を引き出すために事前アンケートを実施。「今年がんばった仕事」や「将来の野望」など、共に働くメンバーの意外な一面や熱い想いを共有する時間となりました。 もちろん拠点としてのトピックスも盛りだくさんで、オフィスの引っ越しや新たな仲間の採用、数々のイベント開催など、濃い1年を振り返りました。 笑いあり、涙あり、そして愛のある「メンバーいじり」あり。小気味良くOsaka Tech Labの1年を共有するコンテンツになりました。 参加者からも「プレゼンがYouTubeみたい!」「データの見せ方がおもしろい」「入社直後だが理解が深まった」などといった声をいただきました。 スライドの一部抜粋「今年の印象に残った出来事は?」 ![](/assets/blog/authors/higashiji/20260501/image3_furikaeri1.jpeg =600x) スライドの一部抜粋「来年大阪でなにがしたい?」 ![](/assets/blog/authors/higashiji/20260501/image4_furikaeri2.png =600x) 表彰 執筆者:ひがし 続いて、表彰イベントに移りました!賞は全部で4つ設けました。 HONMA ARIGATO賞 Osaka Tech Labに多大な貢献をされた方へ感謝を伝える賞 MECCHYA TECH賞 技術面で印象的な活躍や貢献をされた方へ贈る賞 BARI NINKIMON賞 部署問わず、多くの方と積極的にコミュニケーションを取った方へ贈る賞 O-KINI AWARD FY2026賞 "めっちゃブレイクスルーするラボ"・"集GO!発SHIN!Co-LAB"というOsaka Tech Labの共通指針・あいことばを最も体現した年間MVPへ贈る賞 各受賞者は、事前に実施したアンケートでの投票数をもとに選定しました。 また、受賞者には景品として、お名前と賞名を記載したアクリルスタンドを贈呈し、そして【O-KINI AWARD FY2026賞】の受賞者にはあわせてトロフィーも贈呈しました! ![](/assets/blog/authors/higashiji/20260501/image5_award.jpg =600x) さらに、贈呈する側のメンバーにもひと工夫を加え、"その受賞者に投票したメンバーの中から1名"が景品を渡す形式にすることで、「1年間の活躍をメンバー同士で労い合う」という本イベントの目的を達成することができました! ST大会 執筆者:さやま 続いてはST大会を開催しました。 STは「ソニックトーク」の略で、LT(ライトニングトーク)よりもさらに短く、気軽に話してもらうことを目的とした発表形式です! STには決まった運用がないため、今回は以下のルールで実施しました。 発表時間は1人3分まで スライド枚数は自由 テーマはOsaka Tech Labに関する内容なら何でもOK 発表者は応募形式とし、11名の方にご応募いただきました。 最新技術の話や採用の話、個人開発の話、Osaka Tech Labにまつわる話まで、かなり幅広いテーマが集まりました。 3分という短い持ち時間での発表は今回が初めてでしたが、そのぶん一人ひとりの個性がしっかり伝わる、濃い内容になりました。 またイベント終了後のアンケートでも、「今まで知らなかった一面を知ることができてよかった」「面白かった」といった声を多数いただきました。 ![](/assets/blog/authors/higashiji/20260501/image6_st.jpg =600x) 懇親会 執筆者:M.K イベントの締めくくりは、Osaka Tech Labらしいカジュアルな懇親会でした。 会場は立食形式とし、「お花見」をコンセプトに飾り付けを実施。ケータリングのオードブルやアルコールを囲みながら、部署や職種を越えて交流できる時間になりました。 ![](/assets/blog/authors/higashiji/20260501/image7_hanami.jpg =600x) 乾杯の挨拶は、Osaka Tech Labメンバー全員の中からルーレットでランダムに選出する方式に。結果として、最年長者が当選し、会場が笑いに包まれるスタートとなりました。 表彰パートとも連動し、社長の小寺が持参してくださったワインが、受賞者への特別な一杯として振る舞われました。また、ちょうど小寺のお誕生月だったこともあり、ギター演奏に合わせた「ハッピーバースデー」の合唱と、名物の豚まんをバースデーケーキに見立てたサプライズでお祝いしました。 ![](/assets/blog/authors/higashiji/20260501/image8_cake.jpg =600x) 懇親会の中盤では、最近Osaka Tech Labに加入された方や、今後異動予定の方にもマイクをお渡しし、イベントや拠点に対する率直な感想を共有していただきました。新しいメンバーを自然と巻き込み、拠点全体で歓迎するOsaka Tech Labの文化が表れた時間になったと感じています。 最後は、小寺から本日の総括と、来年のOsaka Tech Labに期待することについて一言をいただき、一本締めならぬ「おおきに!」の掛け声でクロージングしました。 最後に 【O-KINI FY2026】は、年度の締めくくりとして、Osaka Tech Labのメンバーが互いの頑張りを称え合い、気持ちを1つにする大切な時間となりました。 また、Osaka Tech Labには「自分たちの手で楽しみを共創しよう」という文化があります。今回、企画メンバーとしてその文化を実際に経験することができました。 今後も、Osaka Tech Labの雰囲気や文化を大切にしながら、拠点が大きくなっても、人が増えても、その良さを保ちつつ成長していきたいと思います。 📢 KINTOテクノロジーズ Osaka Tech Lab 積極採用中! 最後までお読みいただき、ありがとうございました!KINTOテクノロジーズでは、Osaka Tech Labを共に創り上げ、一緒に楽しんでくれる仲間を絶賛募集しています。 拠点が拡大していくこのワクワクするフェーズで、あなたの力を発揮してみませんか? 「ちょっと面白そうかも」「まずはオフィスの雰囲気を知りたい」という方は、ぜひ一度ざっくばらんにお話ししましょう! ご応募お待ちしております! ![](/assets/blog/authors/higashiji/20260501/image9_all.jpg =600x) 👇 詳細はこちらをチェック! https://www.kinto-technologies.com/recruit/#job-list https://hrmos.co/pages/kinto-technologies/jobs/1859151978603163665
はじめに こんにちは、2026年1月入社のI.Kobayashiです! 本記事では、2026年1月入社のみなさまに入社直後の感想をお伺いし、まとめてみました。 KINTOテクノロジーズ(以下、KTC)に興味のある方、そして、今回参加下さったメンバーへの振り返りとして有益なコンテンツになればいいなと思います! YY ![YYさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/yy.webp =300x) 自己紹介 デジタル戦略部 データグロースグループでプロデューサーをしています。 各サービスの成長に向けて、データドリブンな意思決定を支援する施策を企画・推進しています。 また、社内ツールのプロダクトマネージャー(PdM)も兼任しており、社内業務効率化のためのツール開発や改善にも取り組んでいます。 所属チームの体制は? ビジネス支援を行うチームに所属しており、デジタルマーケティングに強みを持つプロデューサー、定性調査に強みを持つプロデューサー、データサイエンスに強みを持つエンジニア達などに囲まれて仕事をしています。 それぞれの専門性を活かしながら、チーム一丸となってサービスの成長を支えています。 KTCへ入社したときの第一印象?ギャップはあった? 制作・開発に比重の強い会社だという印象を持っていましたが、実際にはビジネス側との距離も近く、連携が密である点にギャップを感じました。 現場の雰囲気はどんな感じ? 私が所属するデータ活用チームは、複数のサービスチームと横断的に関わるため、日常的にコミュニケーションが活発です。データで支援する立場から、サービスの理想の姿やデータから見える実像について、普段の会話の中で自然に議論が交わされています。 オフィスで気に入っているところ スカイツリーを眺めながらランチできる休憩スペースがお気に入りです。 眺望の良さはもちろん、リフレッシュしやすい雰囲気があり、午後の仕事への切り替えにも役立っています。 天野さん ⇒ 吉川さんへの質問 普段の業務でAIってどうやって使われていますか? データ分析をAIに任せて、プロジェクトの進む方向性や現在地を一緒に考えることを行っています。 壁打ち相手としても、分析担当としても利用しており、自分の役割を忘れてしまいそうになるぐらいに多用しています。 会議に向けたアジェンダ作成、それに伴うデータ分析、示唆出しまで、一言のプロンプトで完了してしまうのは革命的だと感じています。 Mizoguchi Hiroki ![Mizoguchi_Hirokiさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/mizoguchi.webp =300x) 自己紹介 KINTOを開発するグループで新車サブスクのWebフロントエンド開発チームに所属しています フロントエンドチームにいますがバックエンドもインフラも全般好きです 自転車に乗って走り回るのが趣味です!走り回りすぎて最近骨折しましたが、治ったら懲りずに走り回ろうと思っています 所属チームの体制は? 東京6名・大阪3名のフロントエンドエンジニア9名で構成されています バックエンド・フロントエンド・PdM・QAなど職種によってチームが分かれていて、開発する機能ごとに各チームから数名集って開発を進めるような体制になっています KTCへ入社したときの第一印象?ギャップはあった? 行動力がある大人が集まっているという印象でした。経験値からくる冷静さと、周りを巻き込んでやりたいこと・やるべきことを進めるアクティブさを持った人が多い印象を受けています 現場の雰囲気はどんな感じ? チームメンバーそれぞれで異なったタスクを進めることが多いので、主にモクモクと作業しています。協力が必要なことや相談したいことをslackや対面で声を掛けると皆さん積極的に会話に参加してくれるのでコミュニケーションは円滑です オフィスで気に入っているところ とにかく開放感があって、外の景色を見渡せるところが気に入っています オフィス全体が車をテーマにデザインされていて、遊び心があるところが気に入っています。イベントも頻繁に開催しているので、ぜひ覗きにきてください 吉川さん ⇒ 溝口さんへの質問 フロントエンド開発において、AIと人とどのように作業を分担されていますか? 私は大枠の設計は完全に人間、業務ロジック設計やコードのレイヤー分割などはAIの提案をもとに対話して決定、具体的な実装は殆どAIに任せるなど具象度に応じてAIへの依存が高まっていくような分担になっています。 地味にUIの見た目チェックや操作時の挙動確認は具象な作業なものの、人間がポチポチ画面操作して担当しています。(なんとかAIを使って自動化できないか模索中) Kosuke Kihara ![Kosuke_Kiharaさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/Kosuke.webp =300x) 自己紹介 新サービス開発部 FACTORY EC開発G所属です。 趣味は自作キーボード・ヴィオラ・園芸、ヴィオラは市民オーケストラで演奏していました。 所属チームの体制は? 新サービス開発部 FACTORY EC開発Gで、TOYOTA/LEXUS UPGRADE FACTORYのEC基盤を開発・運用しています。 フロントエンド、バックエンド、PdM、SRE、QA、ディレクター、マネージャーなど合わせて15名ほどの体制です。 KTCへ入社したときの第一印象?ギャップはあった? 入社前に受けていた説明と大きく印象が異なることもなく、戸惑うことはなかったです。 あえて言えば、自分が勤務しているOsaka Tech Labでは特に遊び心を大事にしているところが良い意味でギャップに感じました。 現場の雰囲気はどんな感じ? チームではバーチャルオフィスのGatherを利用しており、リモートでも気軽に相談できる空気感があります。 オフィスで気に入っているところ Osaka Tech Labのパークです。 ヨギボーを持っていってリラックスしながら仕事すると、頭が柔らかくなっていろんな発想ができる(気がする)。 溝口さん ⇒ 木原さんへの質問 最近AIを使ってうまくいった仕事や作業あれば教えてください! JiraチケットやPRのURLから紐づくConfluence・Jira・Slack・コードを自動で追わせてまとめるスキルを作成しました。 案件の周辺コンテキストの理解にかかる時間を大幅に削減できています。 やまそと ![やまそとさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/yamasoto.webp =300x) 自己紹介 プラットフォーム開発部Cloud Infrastructure Gのやまそとです。 トヨタグループへのクラウド領域の技術支援を担当しています。 前職まではSES/SIerでバックエンドの開発エンジニアとして働いていましたが、気づいたらインフラエンジニアになっていました。 プライベートではビールとバイクにハマってます! 所属チームの体制は? 大阪4名東京2名の体制です KTCへ入社したときの第一印象?ギャップはあった? コミュニケーションが活発でアクティブな人が多いなーという印象でした トップダウンではなくフラットに意見を言えますし、自律的に行動する人が多いのは良いギャップでした 現場の雰囲気はどんな感じ? 普段はみんなそれぞれの案件に携わっていますが、社内のチームミーティングはワイワイやってます オフィスで気に入っているところ OsakaTechLab勤務ですが、全体的にキレイでテンションが上がります 駅と繋がっていて雨に濡れずに済むので助かります 木原さん ⇒ 山外さんへの質問 バイクが趣味とお聞きしましたが、最近バイクで行ったおすすめの場所などあれば教えてください! 去年の秋頃に兵庫の須磨に行きました!海沿いを走るのは気持ちよかったです。 あったかくなってきたので淡路島か琵琶湖にいきたいですね。 やまと ![やまとさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/yamato.jpeg =300x) 自己紹介 my route開発部でAWSインフラアーキテクトとして働いています。 国内旅行が趣味で、アーケードゲームの全国行脚機能で43都道府県、スターバックスのアプリでは14都県巡っています。(2026年4月現在) インドア趣味のほうでは某オンラインゲームの/playtimeが執筆時点で10,047時間でした。 所属チームの体制は? 所属しているバックエンド開発チームでインフラを主に担当するのは私一人で、サーバサイドアプリケーションを開発する他のメンバーと密にコミュニケーションを取って仕事を進めています。 KTCへ入社したときの第一印象?ギャップはあった? 入社前の想像よりも、チームメンバーの一人ひとりが開発しているアプリケーションのことをもっとこうしたい!と考えていると感じました。 現場の雰囲気はどんな感じ? メンバーが2人以上出社すれば一緒にランチに行って雑談をしているので、仕事の依頼や質問もしやすく過ごしやすい雰囲気だと感じています。 オフィスで気に入っているところ 神保町オフィスは集中しやすくもあり孤独を感じるほど少なくもない、ほど良い出社率です。レストエリアがお洒落でアップルティーを取りに行くのがリフレッシュになります。 山外さん ⇒ 大和さんへの質問 おすすめの旅行先を教えてください! 美味しい酒・魚を求めるなら四国地方 or 日本海側、綺麗な景色を求めるなら海沿い、が良かったです! その土地の名産であれば、味はもとよりお値段も都市圏より安くてたくさん食べられます。 ただし、食を堪能する旅には登山やハイキングも取り入れた方が、よいです(戒め)。 I.Kobayashi ![I.Kobayashiさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/Kobayashi.webp =300x) 自己紹介 クラウドセキュリティG所属のI.Kobayashiです。 KTCで利用しているクラウドのセキュリティ改善や改善活動の効率よくするためのツール開発などを行っています。 所属チームの体制は? クラウドセキュリティGは現在、大阪2名、東京3名が在籍しています。 KTCへ入社したときの第一印象?ギャップはあった? 入社前にチーム状況・求められていることなど共有いただいていたのでギャップ全然ありませんでした。 現場の雰囲気はどんな感じ? 皆さん優しいので仕事がしやすいです。 利用したことないサービスや技術であっても一緒に調査してくれます! オフィスで気に入っているところ 1階コンビニ、2階レストランがあるので雨で外出たくない時によく利用しています! 大和さん ⇒ 小林さんへの質問 ご趣味は!(アウトドアでもインドアでも構いませんので!) 音楽・ポッドキャスト聴きながら目的もなく歩くのが好きです! HOKAMA ![HOKAMAさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/HOKAMA.webp =300x) 自己紹介 開発支援部企画管理Gの外間です。 主に会社の予算管理や業務フローの調整などを担っている部署となります。 休みの日は小学3年生の息子の町クラブ(サッカー)でコーチをやっています。 趣味はフットサルとゴルフで夏になると日焼け止めを塗っても真っ黒になります。 所属チームの体制は? 企画管理Gは室町2名、大阪1名、名古屋1名です。 KTCへ入社したときの第一印象?ギャップはあった? ある程度のミッション内容を入社前に伺っていたので、あまりギャップは感じませんでした。 現場の雰囲気はどんな感じ? 全員中途採用なので落ち着いた雰囲気です。 オフィスで気に入っているところ 室町7階の休憩室が気に入っています。マッサージ機もあるので体を労わりながら仕事が出来るので! 小林さん ⇒ 外間さんへの質問 室町周辺でおすすめのお店教えてください!(行ってみたいお店でも大丈夫です!) 室町オフィスから少しあるきますが、「新日本橋中華 龍龍龍龍 TETSU」の炒飯が美味しいです。 週一回は通ってます。 きーた ![きーたさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/kiita.webp =300x) 自己紹介 2026年1月入社のきーたです。 セキュリティ・プライバシー部に所属し、福岡オフィス(Fukuoka Tech Lab)で勤務しています。 アビスパ福岡が好きな方、お待ちしてます! 所属チームの体制は? 所属チームは3名体制です。 TFSグループが定める基準をベースとしたセキュリティのアセスメントを主に担当しています。 少人数なのでコミュニケーションも取りやすく、日々連携しながら進めています。 KTCへ入社したときの第一印象?ギャップはあった? 会社のカルチャーや雰囲気など、良い意味で入社前に抱いた印象とのギャップはありませんでした。 入社後のフォロー面談でも「ギャップはありましたか?」と聞かれますが、いつも「何もないですね」と答えていますw 現場の雰囲気はどんな感じ? 「個々がプロフェッショナルでありつつ、しっかりチームで連携して動ける」といった印象です。 困ったときはすぐに相談に乗ってもらえるので助かっています。 オフィスで気に入っているところ 立地がいいところ。あとは地下街と繋がっていたら最高でした。 外間さん ⇒ 紀伊さんへの質問 これまで仕事で一番やらかしたことはどんなことですか?(言える範囲でお願いします) 言えることだと…、某大手メーカーさんの重要拠点のインフラを数時間止めてしまったこと、でしょうか。 あの経験があったおかげて、作業は人一倍慎重になりました!! sasanoshouta ![sasanoshoutaさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/sasanoshouta.webp =300x) 自己紹介 AIファーストグループでAIエンジニアをしています、sasanoshoutaです。 社内外に対して生成AI活用の推進の為に折衝からPoC、実装までを幅広く行う業務に取り組んでいます。 所属チームの体制は? AIファーストグループは東京9名、名古屋3名、大阪1名の体制を敷いています。 KTCへ入社したときの第一印象?ギャップはあった? 入社間もなく、チーム内にはいい意味で上下の関係がなく、相互に取り組んでいることについて共有しながら技術的な共有や議論について交わすことができる印象を持ちました。 また、事前に自分への期待値や会社・チームの状況を聞いた上で役割を想像しながら入社しているので、ギャップはありませんでした。 現場の雰囲気はどんな感じ? チーム全員が同じ取り組みをしている訳ではないですが、共通言語として「誰の為のものか」を全員が常に意識しながら目の前の事に集中して取り組んでいる雰囲気が常にあります。 オフィスで気に入っているところ 日本橋の室町という歴史あるエリアにあるオフィスで、オフィスの内装もモダンで働きやすいですが、周辺のロケーションも気に入っています。 紀伊さん ⇒ 笹野さんへの質問 今年のサッカーW杯で日本以外に注目している国はありますか? たくさんあります。 優勝候補スペイン・フランスや、逸材を輩出し続けているアフリカ勢の国々、初参加国の中でもノルウェー・ウズベキスタン・エジプトがどこまでいくのか、数大会振り出場のチェコあたりに注目したいと思ってます! satoshi ![satoshiさんのプロフィール画像](/assets/blog/authors/i.kobayashi/2026-04-30-newcomer-202601/satoshi.webp =300x) 自己紹介 AIファーストグループの天野です!生成AIの活用推進を社内外に向けて活動しています。 非ソフトウェアエンジニアリング領域を中心に活動しています。 動画生成や記事執筆、顧客理解に対してのAI活用検証を行なっています。 所属チームの体制は? AIファーストグループは東京9名、名古屋3名、大阪1名の体制を敷いています。 KTCへ入社したときの第一印象?ギャップはあった? 皆さん主体的に新しい事に取り組む方が多いなと感じました! 私もアイデアを出して試してみるのが好きなので、カルチャーに馴染みやすかったです。 現場の雰囲気はどんな感じ? 皆さん和気あいあいとした感じがありながらも、しっかりと目的感を持っている印象でした。 オフィスで気に入っているところ オフィス周辺が綺麗なので帰宅時に優雅な感じに帰れる所です! 笹野さん ⇒ 天野さんへの質問 入社の決め手を教えてください! AIの非ソフトウェア領域での活用や推進ができるポジションがあり、自分のやりたい事と重なった為です。 元々はソフトウェア領域でAIを活用していましたが、開発経験が浅く方向転換をしたかったので、私と同じような考えの方がいればAIファーストGオススメです! さいごに みなさま、入社後の感想を教えてくださり、ありがとうございました! KINTOテクノロジーズでは日々、新たなメンバーが増えています! 今後もいろんな部署のいろんな方々の入社エントリが増えていきますので、楽しみにしていただけましたら幸いです。 そして、KINTOテクノロジーズでは、まだまださまざまな部署・職種で一緒に働ける仲間を募集しています! 詳しくは こちら からご確認ください!
こんにちは。KINTOテクノロジーズ(KTC)でKINTOの中古車ECサイトのディレクターをしている かーびー です。KINTO Technologiesでは 「ユーザーファースト」 を会社の重点方針のひとつに掲げ、全社でさまざまな取り組みが進んでいます。私も自分のチームで、ユーザーインタビューの録画をみんなで見る 「ユーザーインタビューわいわい会」 を試すなど、お客様の一次情報に触れる場づくりに取り組んできました。 こうした取り組みをきっかけに、現在はユーザーファーストを社内に広めるための活動にも運営メンバーのひとりとして関わっています。そのひとつが、今回ご紹介する社内勉強会「ユーザーに寄りそわNight! Vol.02」です。 自分たちのサービスを、ユーザーが使っているところを見たことはありますか? 勉強会の中で参加者にこの質問をしたところ、約7割が「ない」と回答しました。 関心がないのではなく、日常の開発フローの中にその機会がない。要件をヒアリングして、仕様に落とし込んで、品質の高いものを作って届ける。エンドユーザーがどんなふうにサービスを使っているかに触れる機会は、意外と少ないのが現実です。 しかもKINTOテクノロジーズの場合、関わるサービスはトヨタ自動車、株式会社KINTO、開発を担う私たちなど、複数の組織で成り立っています。本来なら1社の中で完結する「作って、使ってもらって、フィードバックをもとに改良する」という流れを、組織をまたいで回していく。ここが私たちの組織ならではの難しさだなと感じています。 関わる人が増えるほど、それぞれの立場や見えている景色は違ってきます。だからこそ、作っている一人ひとりがユーザーの姿を知っていることが大事になる。「あのお客様、こう言っていたよね」という共通の記憶がチームにあると、議論もかみ合いやすくなります。 言われたものを作るだけじゃなく、自分たちから価値を届けていく。「ユーザーに寄りそわNight!」は、ユーザーを知るために踏み出した社内チームの取り組みを紹介する勉強会です。 方法論の講義ではなく、隣のチームの体験を共有する場 この勉強会で大事にしているのは、 「私にもできそう!」 と思えることです。 ユーザーリサーチの手法を網羅的に学ぶ場ではなく、他のチームの取り組みを聞いて「これなら自分のチームでもできそう」と感じてもらう。そんな場でありたいと考えています。 toCでもtoBでも、自分たちの仕事の先には必ず使う人がいます。その誰かに寄りそっていくことが、ユーザーファーストの根っこにある考え方だと捉えています。 こうした考えから、勉強会では実際にユーザーと向き合う取り組みをしたチームに登壇してもらい、何をやって、何に気づいたかを共有してもらう形式にしています。専門的な方法論の紹介ではなく、隣のチームの体験を聞くこと。そこから自分のチームでも試してみたいと思える、小さなきっかけが生まれる場になればと思っています。 ユーザーに寄りそわNight! Vol.02:ユーザーと同じ環境で、プロダクトを使ってみる 2026年3月に開催された第2回の勉強会では、実際にユーザーが使っているのと同じような環境で、自分たちもプロダクトをテストしてみるーーそんな取り組みをしているチームに登壇してもらいました。ユーザーファーストの取り組みとして、社内の各所で生まれている実践をキャッチして勉強会に繋げていく中で、この取り組みのことを知り、声をかけたのが始まりでした。 トヨタグループには「現地現物」——実際の現場に足を運び、自分の目で見て判断する——という考え方があります。登壇してくれたチームはこの考え方をユーザー理解にも活かしたいと、開発メンバー自身がユーザーと同じ状況に身を置いてプロダクトを使ってみる、という取り組みに挑戦していました。 机の前の3秒、現場の3秒 登壇でとくに印象に残ったのは、開発環境ではわからなかったことが、ユーザーと同じ状況で使ってみると次々に見えてきたという話でした。 たとえばアプリの表示にかかる時間。開発環境で3秒かかっても「ちょっと遅いな」と感じる程度だけれど、ユーザーが実際に使う状況で体験する3秒はまるで別物。急いでいるとき、周りに人がいるとき、落ち着いて待てないとき。クーラーの効いたオフィスで感じる3秒と、現場で感じる3秒は、同じ時間とは思えないくらい違って感じられた、と。 「仕様通りに動く」はずのものが、ユーザーと同じ状況に置かれるとまったく違う顔を見せる。データでは見えない課題が、身体で感じられる瞬間でした。 「忖度を捨てる」という第一歩 では、現場で気づいたことをどう日常の開発に持ち帰っていくか。パネルディスカッションで印象に残ったのは、「忖度を捨てる」という言葉でした。 「アプリを使っていて『ここ遅いな』と思っても、『APIをたくさん呼んでるからしょうがないか』と開発者としての忖度をしてしまう。その忖度をあえて捨てて、純粋にユーザーとしてアプリを使ってみることが、まずできる第一歩」 開発者として「これはしょうがないか」と自分で飲み込んでしまう場面は、きっと多くの人に心当たりがあると思います。その忖度を一度横に置いて、純粋にユーザーとしてアプリを触ってみる。大がかりな準備をしなくても、今日から始められる小さな一歩として、とても印象に残った言葉でした。 これからも、小さな一歩を重ねていく Vol.02の懇親会では、「うちのチームでもこういうことをやってみたい、でもどう始めればいいんだろう?」という声や、登壇者を囲んで「どうやって社内を巻き込んでいったんですか?」と具体的な進め方を聞く姿が、あちこちで見られました。 アンケートのフリーコメント欄には、約半数の方が「これから自分のチームでやってみたいこと」を書き込んでくれました。印象的だったのは、toCのサービスを作っているチームだけでなく、業務システムやプラットフォームを担当する方々からも、具体的な一歩の言葉が並んだことです。 「業務システムなのでユーザーがKINTO社員であり距離が近い。実際に業務をやらせてもらったり、フィードバックを貯める場を作ったりして、ユーザーファーストを実践する場を作りたい」 「忖度せずに改善アイデアを出し、検討する。アイデアを歓迎する空気を作っていきたい」 自分たちの仕事の先にいる「使う人」は、toCのお客様だけではありません。社内の誰か、パートナー企業の誰か、ときには自分自身かもしれない。それぞれの現場で、それぞれの「寄り添い方」がある。そのことを、登壇してくれたチームの話と、参加者の声から改めて感じた回でした。 Vol.01の開催から半年、社内Slackチャンネルのメンバーは60人から99人に増え、「うちでもこういうことやってるよ!」と声をかけてくれる人も出てきています。これまで各チームの中に閉じていた取り組みが、少しずつ表に出てくるようになりました。 「ユーザーファースト」は2025年の注力テーマとして始まりましたが、ユーザーのことを考えるのはプロダクト開発の基礎の基礎。一年限りのテーマで終わらせず、Vol.03に向けた準備も進行中です。 大がかりな取り組みでなくても、まずは自分のプロダクトをユーザーとして使ってみることから。気づいたことを隣の人に話してみることから。一つひとつのチームで生まれる小さな一歩を、勉強会という場で共有し、また次の一歩へつなげていく。この取り組みの火を絶やさないよう、これからも続けていきます。
こんにちは、サイバーセキュリティと生成AI活用推進を担当しているたなちゅーです。この記事では、2026年2月に活動を開始したAI-Native Devプロジェクトについて紹介します。 活動の背景 2025年までの取り組み KTCでは2024年の 生成AI活用プロジェクト を皮切りに、2025年は「AIファースト」「リリースファースト」を掲げ、AI活用は着実に進みました。 AIファースト :すべてのプロダクトへのAI統合、AIプロダクトの開発推進、グループ内でのAI活用ドライバー リリースファースト :「いかに速く届けるか」を文化として組織に定着させる 「AIを使う」から「AIネイティブな開発・業務プロセス」へ こうした取り組みを経て、昨年末に副社長の景山がテックブログ「 2025年の振り返りと2026年の展望:Agenticな未来へ 」で、2026年のキーワードとして「Agentファースト」と「AIエンジニアリングファースト(AI-Native Dev)」を掲げました。 Agentファースト :「対話するAI」から「行動するAI」へ。AIが自律的にタスクを遂行する世界を全社で実現する AIエンジニアリングファースト(AI-Native Dev) :AIネイティブな視点で開発・業務プロセスを再構築し、職種の壁を超える 目指すのは、 一人ひとりがAIネイティブな視点で開発や業務のプロセスを変えていくこと 。その推進役として、2026年2月にAI-Native Devプロジェクトが発足。プロダクト開発からクラウドインフラ、コーポレート部門まで、10名超が合流した横断チームで活動を開始しています。 活動の2つの柱 個人の知見を組織全体で活かす仕組みと、それを支える開発環境の整備。この2つが揃って初めて組織として加速できると考え、活動を 文化醸成 と 開発環境整備 の2本立てで構成しています。 文化醸成 :ナレッジの体系化・共有、AIツール利用状況の可視化、社内事例の発信など 開発環境整備 :AI時代のコードレビュー最適化、AI Agent / MCP基盤の整備、エンバイロメント(環境)エンジニアリングなど Phase 1:まず土台をつくる Phase 1として取り組んだのは、活動の土台となる2つの基盤です。 AI Native Hub 1つ目は、社内Wiki上に開設した生成AI活用の社内ポータル「AI Native Hub」です。 職種別のAIツール活用ガイド、MCP・Skillsの使い方、社内事例などの情報を集約しています。また、コンテンツの運用にはGitHubを採用しています。Markdownで記述し、PRでレビューを回し、mainブランチにマージされると社内Wikiへ自動同期される仕組みです。運営チームだけが管理するのではなく誰でもナレッジを共有できる、社内全体で育てていくナレッジ集約場所を目指しています。 Claude Code Dashboard 2つ目は、Claude Codeの利用状況を可視化するダッシュボードです。Claude CodeのOpenTelemetryを活用しています。 ダッシュボードでは、MCPやSkillsの使用回数、利用者のトークン使用量、トークン使用量上位者のトレンドが見えます。自分の活用状況の振り返りやトークン使用量上位者との交流など、AIツール利用促進のきっかけになればと考えています。 Phase 2:実践と拡張 Phase 1は立ち上げと基盤整備。4月からのPhase 2は、その基盤の上で実践を加速するフェーズです。 文化醸成 文化醸成が目指すのは、AIネイティブな開発・業務のスタイルが組織に根づくことです。 もくもく会・ハンズオン会 :気軽に情報交換できるオンラインの場を定期開催し、実践知を共有する AIネイティブな個人・部署へのインタビューとナレッジの横展開 :先行事例を掘り起こし、他チームへ広げる AIネイティブな活動の可視化 :AIネイティブ度合いを可視化し、活動の推進に活かす まず動き出したのが「もくもく会」です。週2回オンラインで開催して、ちょっとした困りごとやTipsなどを話しています。また、テーマを決めたハンズオン会も実施しており、初回の「Claude Codeを使い倒す設定を一緒にしよう会」には合計80名以上が参加しました。学びは集約して、後から参照できる形にしています。 開発環境整備 開発環境整備が目指すのは、AIエージェントを前提とした開発基盤を整えることです。 AI Agent / MCP基盤の整備 :AI AgentやMCPの共有基盤の整備を進め、誰でも見つけて使える状態を目指します。 AI時代に合わせたコードレビューの最適化 :AIが生成したコードに対するレビュー観点や静的解析との連携など、AI前提のレビューフローを検討しています。 エンバイロメント(環境)エンジニアリング :AIエージェントが安全に活動できる範囲の境界線設計やガードレールなどの整備に取り組んでいきます。 既に社内ではエージェント開発・共有基盤「KTC Agent Store」を運用しており、現在は実行基盤をBedrock AgentCoreへの移行を進めています。AIエージェントとしてはAIインタビューという深堀りインタビューエージェントなどの開発が進行中です。 ここまでの活動で感じたこと 一番の発見は、AIネイティブな働き方に既に踏み出しているメンバーの多さです。初回ハンズオン会には80名以上が参加し、チャットではおすすめ設定や活用Tipsが飛び交いました。この熱量をつなげれば、もっと大きな力になる。その点と点をつなぐことがAI-Native Devの役割だと改めて感じています。 また、AIネイティブな開発・業務スタイルが根づけば、日々の業務から生まれた余力が新たな価値創出へ向かう流れをつくれるはずです。「攻めのAI活用」と「守りの安全基盤」の両面をつなぎながら、その流れを組織全体で加速させていきます。 おわりに AI-Native Devは始まったばかりです。土台を作るフェーズから、土台の上で走るフェーズへ。活動の進捗やナレッジは引き続きテックブログで発信していきます。 最後まで読んでいただきありがとうございました!
はじめに KINTOテクノロジーズの大沼です。 モビリティサービス「my route」アプリの開発に従事しています。 本記事では、AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細とハマった点・注意点をまとめました。 こちら大杉さんの記事 では、Tink を使用したケースとパフォーマンス検証を紹介しているのでぜひご一読ください。 💬 実装の前にディスカッション 🔍 本当に暗号化が必要なのか DroidKaigi 2025のyanzamさんのお話 でも触れられてましたが、Keystore がクラッシュするのは黙認しつつ最低限の機会頻度に抑えたいので、既存で暗号化しているデータが本当に暗号化する必要があるのかの議論をチームメンバーと交わしました。 案の定、不要に暗号化しているものもあり、議論することで最適なものに絞ることができました。 🏗️ アーキテクチャ セキュリティに関するリファクタリングのコードレビューは、心理負荷が高いと考えています。 私のチームはこういう時、大枠の実装方針を事前に共有し合うことで、コードレビュー時の認識違いや負担が減らせます。 今回、データの暗号化とインターフェースを以下のようなスライドで共有し、大きな齟齬なくレビューを進めることができました。 🛠️ 実装の流れ ここからは、実際の実装手順を以下の流れで解説します。 依存関係の追加 — DataStoreライブラリの導入 Keystoreを使った暗号化キーの生成 — AES/GCMの鍵をAndroid Keystoreで安全に管理 Cipherを使った暗号化・復号化 — 初期化ベクトル(IV)の扱いを含む暗号処理の実装 DataStoreへの保存 — 暗号化したデータをPreferences DataStoreで永続化し、Flowで読み出す 📚 依存関係の追加 ライブラリにDataStoreを追加します。 dependencies { // DataStore implementation("androidx.datastore:datastore-preferences:1.1.7") } 🔑 Keystoreを使った暗号化キーの生成 import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.security.KeyStore import javax.crypto.KeyGenerator ... fun getOrCreateSecretKey(): SecretKey? { try { // KeyStoreのインスタンス生成 val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE_PROVIDER).apply { load(null) // KeyStoreを初期化するための必須の呼び出し } // KeyStoreにプロダクトの鍵が存在するか確認し、あれば取得し返す if (keyStore.containsAlias(PROJECT_KEY_STORE_ALIAS)) { val entry = keyStore.getEntry(PROJECT_KEY_STORE_ALIAS, null) if (entry is KeyStore.SecretKeyEntry) { return entry.secretKey } } // KeyStoreにプロダクトの鍵が存在しなければ生成して保存し返す val params = KeyGenParameterSpec.Builder( PROJECT_KEY_STORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .build() val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE_PROVIDER, ) keyGenerator.init(params) return keyGenerator.generateKey() } catch (e: Exception) { Firebase.crashlytics.recordException(e) return null } } 🔐 Cipherを使った暗号化・復号化 import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import java.util.Base64 import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec interface CryptographyManager { fun encrypt(plaintext: String): String fun decrypt(encryptedString: String): String } private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val IV_SIZE_BYTES = 12 private const val TAG_SIZE_BITS = 128 class CryptographyManagerImpl : CryptographyManager { override fun encrypt(plaintext: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) val ivAndCiphertext = cipher.iv + ciphertext // IVと暗号文をバイト配列として結合 Base64.getEncoder().encodeToString(ivAndCiphertext) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } override fun decrypt(encryptedString: String): String { return try { val cipher = Cipher.getInstance(TRANSFORMATION) val ivAndCiphertext = Base64.getDecoder().decode(encryptedString) // 復号化時に保存したIVを使う val spec = GCMParameterSpec(TAG_SIZE_BITS, ivAndCiphertext, 0, IV_SIZE_BYTES) cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec) val plaintext = cipher.doFinal( ivAndCiphertext, IV_SIZE_BYTES, ivAndCiphertext.size - IV_SIZE_BYTES, ) String(plaintext, Charsets.UTF_8) } catch (e: Exception) { try { CryptographyException.parse(e) } catch (cryptoException: CryptographyException) { Firebase.crashlytics.recordException(cryptoException) "" } } } } 💾 DataStoreへの保存 import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map data class SecureDataPreferences( val textData: String, ) object PreferencesKeys { private val TEXT_KEY = stringPreferencesKey("encrypted_text") } private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "encrypted_prefs") class SecureDataRepository( private val cryptographyManager: CryptographyManager ) { suspend fun saveTextData(data: String) { val encryptedData = cryptographyManager.encrypt(data) dataStore.edit { preferences -> preferences[TEXT_KEY] = encryptedData } } private val secureDataFlow: Flow<SecureDataPreferences> = secureDataStore.data .catch { exception -> if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } } .map { it.mapSecureDataPreferences() } private fun Preferences.mapSecureDataPreferences(): SecureDataPreferences { return SecureDataPreferences( textData = this[PreferencesKeys.TEXT_KEY]?.let { cryptographyManager.decrypt(it) } ?: "", // ... Other data ) } suspend fun getTextData(): String { return try { withTimeout(3000L) { secureDataFlow.map { it.textData }.first { it.isNotBlank() } } } catch (_: TimeoutCancellationException) { "" } catch (_: NoSuchElementException) { "" } } } ⚠️ ハマった点・注意点 1. 初期化ベクトル(IV)の保存 暗号化時に生成されるIV(Initialization Vector)は、復号化時に必須です。 IVは暗号文と一緒に保存する必要があります。IVは秘密情報ではないため、平文で保存しても問題ありません。 ハマったポイント: 最初の実装でIVを保存し忘れ、復号化時に javax.crypto.AEADBadTagException が発生しました。 2. KeyStoreのキーのライフサイクル Android Keystoreに保存されたキーは、アプリをアンインストールすると削除されます。 また、デバイスのロック画面が解除されるまでキーにアクセスできない設定も可能です( setUserAuthenticationRequired(true) )。 注意点: keyが存在しない場合の処理を適切に実装する必要があります。 3. GCMモードのタグ長 GCM(Galois/Counter Mode)を使用する場合、タグ長を正しく設定する必要があります。 一般的には128ビット(16バイト)が使用されます。 4. エラーハンドリング 復号化時にはさまざまなエラーが発生する可能性があります: KeyPermanentlyInvalidatedException : キーが無効化された AEADBadTagException : 暗号文が改ざんされた、またはIVが間違っている InvalidKeyException : キーが無効 これらのエラーをハンドリングし、必要に応じてデータをクリアし再生成するなどの対応が必要です。 5. DataStoreの非同期処理 DataStoreはすべての操作が非同期で行われます。 CoroutineまたはFlowを使用して適切に処理する必要があります。 DataStoreのソースコード内で、最新の値を1つだけ取得できる data.first() を使用することを推奨しています。 // ViewModelでの使用例 viewModelScope.launch { repository.saveTextData(sensitiveData) } // Flowの監視 repository.secureDataFlow.map { it.textData }.first { it.isNotBlank() } 6. 無限待機の防止 DataStoreはディスクI/Oを伴う非同期処理です。first { it.isNotBlank() } は条件に一致する値が来るまで無限に待機しますが、 もしディスク読み込みが遅延したり、トークンが空のままだと、アプリがフリーズする可能性があり、タイムアウトを追加しました。 7. ProGuard/R8の設定 DataStore 1.1.5以降では、ProGuardルールがライブラリに内包されています。 巷の記事で ProGuardルール の記載が必要なことを目の当たりにしましたが、ルール記載なくリリースビルドしたところクラッシュせず、なぜ? となっていたところ、リリースノート確認し気づきました。 今回、1.1.7 を使用しているため、DataStore専用のProGuardルールを追加する必要はありません。 https://developer.android.com/jetpack/androidx/releases/datastore バージョン1.2.0-beta01で修正された問題として記載: "Fix java.lang.UnsatisfiedLinkError when using DataStore in an app which is optimized with R8" バージョン1.1.5で修正: "missing Proguard rules issue in the Android artifact of datastore-preferences-core" 8. 標準のSharedPreferencesMigrationが使えない EncryptedSharedPreferencesは特殊な暗号化を使用していて、 標準のマイグレーションでは暗号化されたままのデータが転送される また、EncryptedSharedPreferencesは 読み取り時に自動復号化されるのに対し、 今回は CryptographyManagerによる手動暗号化が必要です。 この部分を認知することができず、標準の標準のSharedPreferencesMigrationで実装し、テストしたところ復号できず判明しました。 マイグレーション時に適切な暗号化変換を実装しました。 まとめ 本記事では、Android Keystore、Cipher、DataStoreを組み合わせた秘匿情報の暗号化・永続化の実装について紹介しました。 実装前のディスカッションが重要 : そもそも暗号化が必要なデータかをチームで議論することで、Keystoreへのアクセス頻度を最小限に抑えられた Keystoreの鍵管理 : AES/GCMモードでの鍵生成とIV(初期化ベクトル)の保存を適切に行う必要がある DataStoreとの組み合わせ : Flowベースの非同期読み出しに対応するため、タイムアウトや無限待機の防止策が必要 EncryptedSharedPreferencesからの移行 : 標準のSharedPreferencesMigrationでは暗号化方式の違いにより復号できないため、手動でのマイグレーション実装が必要 Keystoreのクラッシュは完全には避けられませんが、暗号化対象を最適化し、エラーハンドリングを適切に実装することで、安定したセキュアなデータ管理を実現できました。 📣 追記: DataStore 1.3.0-alpha07 で暗号化サポートが追加 本記事の執筆後、 DataStore 1.3.0-alpha07 (2026年3月11日リリース)で、 Tinkライブラリを使用した暗号化サポート が新たに追加されました。 新しい androidx.datastore:datastore-tink アーティファクトにより、 AeadSerializer を使って既存のシリアライザをラップするだけで暗号化が実現できます。 val aeadSerializer = AeadSerializer( aead = keysetHandle.getPrimitive( RegistryConfiguration.get(), Aead::class.java, ), wrappedSerializer = ExistingSerializer, associatedData = "settings.json".encodeToByteArray(), ) 本記事で紹介したKeystore + Cipher による手動実装と比較すると、Tink統合によりボイラープレートが大幅に削減されます。ただし、現時点ではalpha版であるため、プロダクション導入の際はAPIの安定性を考慮する必要があります。今後のstable版リリースに注目です。 参考資料 Android Keystore System Jetpack DataStore 暗号化されたファイルの使用 Android セキュリティのベスト プラクティス DataStore 1.3.0-alpha07 リリースノート
はじめに KINTOテクノロジーズでインフラエンジニアをしているyassanです。 先日、GitHub Actionsのワークフローを意図せず大量に起動してしまい、 社内のCI/CDパイプラインを約1時間にわたって止めてしまう という事故を起こしました。 この記事では、小さなミスがどう連鎖して大きな障害になったのか、そしてそこから何を学んだのかをお話しします。 前提:コメント駆動のCI/CDパイプライン 私たちのチームでは、Terraformのインフラコードを管理するリポジトリでGitHub Actionsを活用しています。 仕組みはシンプルで、PRにコメントを投稿すると、そのPRで変更されたディレクトリを検出して自動的に terraform plan を実行してくれるというものです。 ワークフローの概要を簡略化すると、以下のようなイメージです。 name: Terraform Plan on: issue_comment: types: [created, edited] # コメントの新規作成・編集時に発火 jobs: plan: # PRへのコメントで、本文にコマンド文字列を含む場合に実行 if: | github.event.issue.pull_request && contains(github.event.comment.body, '/command') runs-on: ubuntu-latest steps: - name: PRの変更ディレクトリを検出 # ... - name: 対象ディレクトリごとに terraform plan を実行 # ... - name: 結果をPRにコメント # ... 通常であれば、PRの変更範囲は1〜2ディレクトリ程度。数分で完了する軽い処理です。 やらかしの連鎖 火種:いつもの感覚でリベースしたら、対象が35ヶ所に膨れ上がった 普段のPRは main ブランチに向けて作成しています。しかしこの日に限って、別の作業ブランチをベースにしたPRを作っていました。 ここで、いつもの癖で何も考えずにリベースを実行。すると、そのブランチにあった 他のメンバーのコミット が差分に混入してしまいました。 本来1ディレクトリだったplanの対象が、一気に 35ディレクトリ に膨れ上がりました。 延焼:消火しようとしたらガソリンだった 35ディレクトリ分のplanが走ってしまったことに気づき、「余計な結果コメントを非表示にして整理しよう」と考えました。 そこでGitHub APIを使って、不要な34件のコメントのうち20件を非表示(minimize)にしていきました。 その操作がワークフローのトリガーになるとも知らずに、非表示にするだけだと軽い気持ちで実施しました。 結果として、思いがけず20件 × 35ディレクトリ = 約700回のワークフロー が一斉に走り出しました。 種明かし:大量のトリガー GitHub APIの minimizeComment でコメントを非表示にすると、GitHub上では 「コメントの編集」イベント として扱われます。ちなみに、Web UIから手動でhideした場合はこのイベントは発生しません。 そして、非表示にしたコメントの本文には、ワークフローのトリガーとなるコマンド文字列が含まれていました。 つまり、 1件非表示にするたびに、35ディレクトリ分のplanが再び起動 してしまう状況だったのです。 graph TD A[結果コメントを非表示にする] -->|editイベント発火| B[ワークフローがコメント本文を読む] B -->|トリガー文字列を検出| C[35ディレクトリ分のplanが起動] C --> D[結果コメントが投稿される] D -->|さらに非表示にすると...| A style A fill:#ff6b6b,color:#fff style C fill:#ff6b6b,color:#fff 誤判断:PRを閉じれば止まると思った 約10分後、大量のワークフローが走っていることに気づきました。パニックになった私は「PRを閉じれば止まるはず」と考え、すぐにPRをクローズしました。 「これで大丈夫」と安心して、別の作業に戻りました。 発覚:社内から悲鳴が上がる さらに約10分後。社内のチャットに「GitHub Actionsが動かない」「CIがずっとキュー待ちになっている」という報告が上がり始めました。 慌ててGitHubを確認すると、クローズしたはずのPRに まだ結果コメントが投稿され続けていました 。 実は、PRをクローズしても 実行中のワークフローはキャンセルされません 。 それどころか、クローズされたPRに対してもコメントイベントは発火するため、PRクローズ自体にワークフローを止める効果はないのです。 これにより、共有ランナーの枠を食いつぶしてしまい、他チームのCIが動かなかったわけです。 私はすぐにGitHub Actionsの画面から、実行中のワークフローを手動で片っ端からキャンセル。ようやくキュー溜まりが解消し、社内のCI/CDが正常に戻りました。 あとから確認したところ、恐ろしいことに 約3,000分(50時間相当)のActions実行時間を、わずか1時間の間に消費していた ことがわかりました。 何が起きていたのか 今回の事故は、4つのミスが連鎖して起きました。 # やったこと 何が起きたか 1 別ブランチベースのPRでリベース 他人のコミット混入で対象35ディレクトリに膨張 2 結果コメントを非表示にして整理 非表示=編集イベント → ワークフロー再起動 × 20回 3 PRをクローズして安心 起動済みワークフローは止まらない 4 20分間気づかず放置 社内CI/CDが1時間停止 一つ一つは「ちょっとした判断ミス」や「仕様を知らなかった」程度のことですが、それが連鎖することで大きな障害になりました。 ワークフロー変更による再発防止 1. トリガー条件の見直し ワークフローのトリガーから edited (編集)イベントを削除し、 created (新規作成)のみに限定しました。これにより、コメントの編集や非表示でワークフローが起動することはなくなりました。 on: issue_comment: - types: [created, edited] + types: [created] 2. コマンド判定ロジックの厳格化 コメント本文にコマンド文字列が「含まれているか」ではなく、「先頭から始まっているか」で判定するように変更しました。さらに、イベント種別の二重チェックも追加しています。 jobs: run_plan: if: | github.event.issue.pull_request + && github.event.action == 'created' - && contains(github.event.comment.body, '/command') + && startsWith(github.event.comment.body, '/command') 3. 同時実行の制御 concurrency グループを設定し、同一PRでのワークフローの並列実行を防止しました。後から起動したワークフローが、先行するものをキャンセルして最新のplanだけが実行されるようになっています。 concurrency: group: plan-${{ github.event.issue.number }} cancel-in-progress: true 組織としての課題 今回の事故で、ワークフロー単体の修正だけでは防ぎきれない課題も見えてきました。 共有ランナーの同時実行数が急増しても気づく仕組みがない ワークフローのトリガー設計に関する共通のガイドラインがない 暴走に気づいたとき、誰がどう止めるかの手順が整備されていない これを踏まえてコーポレートITグループと連携して以下による改善を進めていきたいと考えています。 ランナー使用状況の監視強化(同時実行数がしきい値を超えた際の Slack アラート) ARMランナーやハイスペックランナーへの切り替えによる処理効率の改善 ワークフロートリガー設定のベストプラクティス策定・既存ワークフローの一括監査 この経験から学んだこと 「止めたつもり」が一番怖い。 PRを閉じればワークフローも止まると思い込んでいましたが、実際にはそうではありませんでした。慌てているときほど、思い込みで行動してしまいがちです。 ワークフローのトリガー条件は、「最悪のケース」で考える。 GitHub APIを使ったコメントの非表示は編集イベントとして扱われること、結果コメントの本文にトリガー文字列が含まれること。どちらも普段は問題にならない仕様ですが、組み合わさったときに暴走を引き起こしました。 小さなミスは連鎖する。 リベースのミス、コメント整理の操作、PRクローズへの過信、確認不足。どれか一つでも正しく対処できていれば、ここまでの事故にはなりませんでした。失敗が起きたとき、焦らずに「今何が動いているのか」を確認することが大事だと痛感しました。 おわりに 今回の事故は、自分の操作で社内の開発フローを止めてしまうという、なかなかにつらい経験でした。 ただ、この失敗をきっかけにワークフローのトリガー設計を見直し、同様の暴走が起きない仕組みに改善できました。外注開発なら責任問題になりかねない失敗も、内製開発なら改善のきっかけにできる。それがこの経験で得た一番の実感です。 この記事が、同じようなCI/CDの落とし穴を避けるための参考になれば幸いです。
はじめに こんにちは、KINTOテクノロジーズのFACTORY EC開発グループでバックエンドエンジニアをやっている、うえはら( @penpen_77777 )です。 今回はWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル「ISUCON」で得た知識を活用して、FACTORYでマスタデータ反映に1時間30分かかっていた処理をたった5分で終わらせるようにした方法についてご紹介します。 「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。 ISUCON is a trademark or registered trademark of LY Corporation. https://isucon.net 今回の課題 FACTORYでは商品や車種などのマスタデータをExcelファイルに取りまとめ、 そのExcelファイルをもとに本番環境のDBにデータを反映しています(=マスタ反映)。 このマスタ反映に90分かかっており、マスタ運用作業のボトルネックになっていました。 例えば本番環境への反映の前に検証環境でマスタデータに問題ないかを確認しているのですが、 データの誤りに気づいて修正してもマスタ反映に90分かかるため、データが正しく直せたかどうかすぐに確認できない状況でした。 そこで、マスタ反映を高速化することで運用作業の効率化を図ることにしました。 マスタデータ反映 マスタ反映は、Excelで管理されているマスタデータを元に、最終的にマスタ反映コンテナがDBに書き込むという流れになっています。 上記の流れを図に示します。 図中では以下のような流れでマスタ反映が進みます。 マスタ運営担当者が、原本となるExcelファイルに車種や商品情報を入力する 出来上がったExcelファイルをマスタ管理ツールにアップロードする マスタ管理ツールがバリデーションをかけ、問題があれば担当者に通知する Excelがアップロードされると裏でLambda関数が実行され、ExcelファイルからCSVファイルに変換される DBに反映したい段階で、マスタデータをFACTORY本体に連携するため、CSVをレプリケーションバケットに保存する レプリケーションバケットにファイルが保存されるとFACTORY本体でステートマシンが起動し、マスタ反映コンテナを起動する マスタ反映コンテナがCSVを読み取ってSQLを組み立て、DBの各テーブルにレコードを読み書きする 今回高速化の対象としたのは、7のマスタ反映コンテナの処理です。 パフォーマンスチューニングをどのように進めたか追体験する 今回のマスタ反映に関するパフォーマンス問題についてどのように解決したかサンプルコードで見ていきましょう。 実際のマスタ反映処理はKotlinで記述されていますが、サンプルコードの方では筆者が慣れているGoを使います。 また、使用するマスタデータはFACTORYの実際に使われているデータではありません。 ですが、似た構造のマスタデータを使うので、実際に筆者が行ったパフォーマンスチューニングと同じ方法で高速化できます。 もしよろしければ皆さんも手を動かしながら試してみてください。 入力 ECサイトで管理している商品データを反映したいと考えてみましょう。 表では省略していますが、全部で50万件程度のデータとなります product_code 商品を一意に識別するコード product_name 商品の表示名 category_code 商品が属するカテゴリのコード supplier_code 仕入先コード status_code 商品の販売状態 unit_price 単価(円) P1001 ボールペン 黒 CAT01 SUP01 active 150 P1002 ボールペン 赤 CAT01 SUP01 active 150 P1003 シャープペンシル CAT01 SUP02 discontinued 300 P2001 A4コピー用紙 500枚 CAT02 SUP03 active 450 P2002 A3コピー用紙 500枚 CAT02 SUP03 active 780 人間にとって分かりやすいように表で示しましたが、システムにはcsvの形で入力されます。 product_code,product_name,category_code,supplier_code,status_code,unit_price P1001,ボールペン 黒,CAT01,SUP01,active,150 P1002,ボールペン 赤,CAT01,SUP01,active,150 P1003,シャープペンシル,CAT01,SUP02,discontinued,300 P2001,A4コピー用紙 500枚,CAT02,SUP03,active,450 P2002,A3コピー用紙 500枚,CAT02,SUP03,active,780 出力 入力されたデータを以下のように product テーブルに入れることにします。 category_codeやsupplier_codeやstatus_codeは外部テーブルで保持される値となるため、idに変換した上で保存されます。 外部テーブルにはすでにレコードが反映されているとします。 product_id product_code product_name category_id supplier_id status_id unit_price 1 P1001 ボールペン 黒 1 1 1 150 2 P1002 ボールペン 赤 1 1 1 150 3 P1003 シャープペンシル 1 2 2 300 4 P2001 A4コピー用紙 500枚 2 3 1 450 5 P2002 A3コピー用紙 500枚 2 3 1 780 erDiagram Product { string product_id PK "商品ID" string product_code UK "商品コード" string product_name "商品名" string category_id FK "カテゴリID" string supplier_id FK "仕入先ID" string status_id FK "ステータスID" int unit_price "単価(円)" } Category { string category_id PK "カテゴリID" string category_code UK "カテゴリコード" string category_name "カテゴリ名" } Supplier { string supplier_id PK "仕入先ID" string supplier_code UK "仕入先コード" string supplier_name "仕入先名" } Status { string status_id PK "ステータスID" string status_code UK "ステータスコード" string status_name "ステータス名" } Category ||--o{ Product : "has" Supplier ||--o{ Product : "supplies" Status ||--o{ Product : "applies" 改善前のコード サンプルコードの全体構成を以下の図に示します。 ハンズオンをサクッとできるようにテストデータの準備等の必要な作業を行ったのち、本題のマスタ反映が実行されるようになっています。testcontainersでMySQLコンテナを起動しテスト用のCSVを生成した後、main.goがそのCSVを読み取ってDBにマスタ反映を行います。 今回使用するサンプルコードを以下に示します。以下の4つのコードを同じディレクトリに配置してください。 :::details main.go (改善対象のコード) package main import ( "context" "fmt" "log" "os" "time" _ "github.com/go-sql-driver/mysql" "github.com/gocarina/gocsv" "github.com/jmoiron/sqlx" ) func main() { ctx := context.Background() // MySQLコンテナを起動 connStr, cleanup, err := startMySQLContainer(ctx) if err != nil { log.Fatal(err) } defer cleanup() db, err := sqlx.Open("mysql", connStr) if err != nil { log.Fatal(err) } defer db.Close() // テーブル・マスターデータを作成 if err := setupTables(db); err != nil { log.Fatal(err) } // サンプルCSVを生成(50万行) csvFilename := "data.csv" if err := generateSampleCSV(csvFilename, 500000); err != nil { log.Fatal(err) } // 1. CSVを読み取る file, err := os.Open(csvFilename) if err != nil { log.Fatal(err) } defer file.Close() var products []Product if err := gocsv.UnmarshalFile(file, &products); err != nil { log.Fatal(err) } fmt.Printf("CSV読み込み完了: %d 行\n", len(products)) importStart := time.Now() for i, product := range products { // 2. 読んでない行があれば1行読み取る、なければ終了 lineNum := i + 2 // 3. category_codeをcategory_idに変換 var category Category if err := db.Get( &category, `SELECT * FROM categories WHERE code = ?`, product.CategoryCode, ); err != nil { log.Fatalf("行 %d: category_code %q の検索に失敗: %v", lineNum, product.CategoryCode, err) } // 4. supplier_codeをsupplier_idに変換 var supplier Supplier if err := db.Get( &supplier, `SELECT * FROM suppliers WHERE code = ?`, product.SupplierCode, ); err != nil { log.Fatalf("行 %d: supplier_code %q の検索に失敗: %v", lineNum, product.SupplierCode, err) } // 5. status_codeをstatus_idに変換 var status Status if err := db.Get( &status, `SELECT * FROM statuses WHERE code = ?`, product.StatusCode, ); err != nil { log.Fatalf("行 %d: status_code %q の検索に失敗: %v", lineNum, product.StatusCode, err) } // 6. ProductRowに変換 row := ProductRow{ ProductCode: product.ProductCode, ProductName: product.ProductName, CategoryID: category.ID, SupplierID: supplier.ID, StatusID: status.ID, UnitPrice: product.UnitPrice, } // 7. UPDATE文を実行する result, err := db.NamedExec(` UPDATE products SET product_name = :product_name, category_id = :category_id, supplier_id = :supplier_id, status_id = :status_id, unit_price = :unit_price WHERE product_code = :product_code`, row, ) if err != nil { log.Fatalf("行 %d: productsの更新に失敗: %v", lineNum, err) } rowsAffected, err := result.RowsAffected() if err != nil { log.Fatalf("行 %d: 更新件数の取得に失敗: %v", lineNum, err) } // 8. UPDATE対象がなければINSERTする if rowsAffected == 0 { _, err = db.NamedExec(` INSERT INTO products (product_code, product_name, category_id, supplier_id, status_id, unit_price) VALUES (:product_code, :product_name, :category_id, :supplier_id, :status_id, :unit_price)`, row, ) if err != nil { log.Fatalf("行 %d: productsの登録に失敗: %v", lineNum, err) } } if (lineNum-1)%1000 == 0 { rate := float64(lineNum-1) / time.Since(importStart).Seconds() fmt.Printf("進捗: %d / %d 行 (%.0f 行/秒)\n", lineNum-1, len(products), rate) } // 9. 2に戻る } fmt.Printf("完了: %d 行 (所要時間: %v)\n", len(products), time.Since(importStart)) } ::: :::details models.go (csv, dbを操作するのに必要な構造体を定義) package main type Product struct { ProductCode string `csv:"product_code"` ProductName string `csv:"product_name"` CategoryCode string `csv:"category_code"` SupplierCode string `csv:"supplier_code"` StatusCode string `csv:"status_code"` UnitPrice int `csv:"unit_price"` } type Category struct { ID int `db:"id"` Code string `db:"code"` Name string `db:"name"` } type Supplier struct { ID int `db:"id"` Code string `db:"code"` Name string `db:"name"` } type Status struct { ID int `db:"id"` Code string `db:"code"` Name string `db:"name"` } type ProductRow struct { ProductCode string `db:"product_code"` ProductName string `db:"product_name"` CategoryID int `db:"category_id"` SupplierID int `db:"supplier_id"` StatusID int `db:"status_id"` UnitPrice int `db:"unit_price"` } ::: :::details setup.go(DB初期化・CSV生成) package main import ( "context" "encoding/csv" "fmt" "math/rand" "os" "strconv" "time" "github.com/jmoiron/sqlx" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/mysql" "github.com/testcontainers/testcontainers-go/wait" ) func startMySQLContainer(ctx context.Context) (connStr string, cleanup func(), err error) { mysqlContainer, err := mysql.Run(ctx, "mysql:8.0", mysql.WithDatabase("testdb"), mysql.WithUsername("user"), mysql.WithPassword("password"), testcontainers.WithWaitStrategyAndDeadline(3*time.Minute, wait.ForListeningPort("3306/tcp"). WithStartupTimeout(3*time.Minute), ), ) if err != nil { return "", nil, err } connStr, err = mysqlContainer.ConnectionString(ctx) if err != nil { _ = mysqlContainer.Terminate(ctx) return "", nil, err } cleanup = func() { _ = mysqlContainer.Terminate(ctx) } return connStr, cleanup, nil } func generateSampleCSV(filename string, rows int) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() if err := writer.Write([]string{"product_code", "product_name", "category_code", "supplier_code", "status_code", "unit_price"}); err != nil { return err } categoryCodes := []string{"CAT01", "CAT02", "CAT03"} supplierCodes := []string{"SUP01", "SUP02", "SUP03"} statusCodes := []string{"active", "discontinued", "pending"} for i := 0; i < rows; i++ { record := []string{ fmt.Sprintf("P%d", 1000+i+1), fmt.Sprintf("商品_%d", i+1), categoryCodes[rand.Intn(len(categoryCodes))], supplierCodes[rand.Intn(len(supplierCodes))], statusCodes[rand.Intn(len(statusCodes))], strconv.Itoa(rand.Intn(10000) + 100), } if err := writer.Write(record); err != nil { return err } } return nil } func setupTables(db *sqlx.DB) error { tables := []string{ `CREATE TABLE IF NOT EXISTS categories ( id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(10) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL )`, `CREATE TABLE IF NOT EXISTS suppliers ( id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(10) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL )`, `CREATE TABLE IF NOT EXISTS statuses ( id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL )`, `CREATE TABLE IF NOT EXISTS products ( id INT AUTO_INCREMENT PRIMARY KEY, product_code VARCHAR(50) UNIQUE NOT NULL, product_name VARCHAR(255) NOT NULL, category_id INT NOT NULL, supplier_id INT NOT NULL, status_id INT NOT NULL, unit_price INT NOT NULL, FOREIGN KEY (category_id) REFERENCES categories(id), FOREIGN KEY (supplier_id) REFERENCES suppliers(id), FOREIGN KEY (status_id) REFERENCES statuses(id) )`, } for _, table := range tables { if _, err := db.Exec(table); err != nil { return err } } masterData := []string{ `INSERT IGNORE INTO categories (code, name) VALUES ('CAT01', '文房具'), ('CAT02', '食品'), ('CAT03', '電化製品')`, `INSERT IGNORE INTO suppliers (code, name) VALUES ('SUP01', '株式会社A商事'), ('SUP02', '株式会社B産業'), ('SUP03', '株式会社C物産')`, `INSERT IGNORE INTO statuses (code, name) VALUES ('active', '販売中'), ('discontinued', '販売終了'), ('pending', '販売準備中')`, } for _, data := range masterData { if _, err := db.Exec(data); err != nil { return err } } return nil } ::: :::details go.mod module csv-import-example go 1.24.5 require ( github.com/go-sql-driver/mysql v1.9.3 github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 github.com/jmoiron/sqlx v1.4.0 github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 ) require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/sys v0.38.0 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ::: 高速化するためにmain.goを改善していきます。 main.goの処理の流れをまとめると以下の通りです。 csvを読み取る product_code,product_name,category_code,supplier_code,status_code,unit_price P1001,ボールペン 黒,CAT01,SUP01,active,150 P1002,ボールペン 赤,CAT01,SUP01,active,150 ... 読んでない行があれば1行読み取る、なければ終了 P1001,ボールペン 黒,CAT01,SUP01,active,150 category_codeをcategory_idに変換 SELECT * FROM categories WHERE code = 'CAT01' -- => id=1, code='CAT01', name='文房具' supplier_codeをsupplier_idに変換 SELECT * FROM suppliers WHERE code = 'SUP01' -- => id=1, code='SUP01', name='株式会社A商事' status_codeをstatus_idに変換 SELECT * FROM statuses WHERE code = 'active' -- => id=1, code='active', name='販売中' ProductRowに変換 UPDATE文を実行する UPDATE products SET product_name = 'ボールペン 黒', category_id = 1, supplier_id = 1, status_id = 1, unit_price = 150 WHERE product_code = 'P1001' UPDATE対象がなければINSERTする INSERT INTO products (product_code, product_name, category_id, supplier_id, status_id, unit_price) VALUES ('P1001', 'ボールペン 黒', 1, 1, 1, 150) 2に戻る 実行してみる まずは現状を把握するため反映にどれくらい時間がかかるかみてみましょう。 testcontainersでMySQLコンテナを起動するため、事前にDocker Desktopを起動しておいてください。 また、依存パッケージを取得するために go mod tidy を実行してから go run . を実行します。 go mod tidy go run . このコードを実行してみると以下のような実行結果が得られます。 なんとDBへの反映に47分かかってしまいました。 $ go run . CSV読み込み完了: 500000 行 進捗: 1000 / 500000 行 (338 行/秒) 進捗: 2000 / 500000 行 (329 行/秒) 進捗: 3000 / 500000 行 (320 行/秒) 進捗: 4000 / 500000 行 (326 行/秒) 進捗: 5000 / 500000 行 (328 行/秒) 進捗: 6000 / 500000 行 (328 行/秒) 進捗: 7000 / 500000 行 (329 行/秒) 進捗: 8000 / 500000 行 (328 行/秒) 進捗: 9000 / 500000 行 (319 行/秒) ... 進捗: 500000 / 500000 行 (176 行/秒) 完了: 成功 500000 行, エラー 0 行 (所要時間: 47m23.503716s) 実際のFACTORYのマスタ反映の負荷状況 実際のFACTORYでの本番環境への反映では90分もの時間がかかっていました。 FACTORY本番のRDSでの負荷を計測するため、以下にDatabase Insightsの結果を示します。 図ではクエリ別にAAS(平均アクティブセッション)が示され、AASが高い順に並んでいます。 AASが高いほどDBに負荷がかかっており、低いほどDBに負荷がかかっていないというように解釈すればokです。 赤枠がマスタ反映時に実行されているSQLになりますが、 特定のテーブルに対するSELECTの実行回数が多い(1秒あたりに200回程度実行されている) SELECTよりも負荷は小さいものの、UPDATEも同程度の頻度で実行されている このように計測の結果、マスタ反映時に叩かれるSQL、特にSELECTが原因だなというように見当をつけ、改善を進めていきました。 原因を探る これだけの時間がかかる原因を探ってみましょう。 ここではコード中で実行されるクエリに着目してみます。 実行されているクエリは以下の通りです。 # クエリ ループ中(回) 合計(回) 1 SELECT * FROM categories WHERE code = ? 1 × 50万ループ = 50万 50万 2 SELECT * FROM suppliers WHERE code = ? 1 × 50万ループ = 50万 50万 3 SELECT * FROM statuses WHERE code = ? 1 × 50万ループ = 50万 50万 4 UPDATE products SET ... WHERE product_code = ? 1 × 50万ループ = 50万 50万 5 INSERT INTO products (...) VALUES (...) 最大1 × 50万ループ = 最大50万 最大50万 合計 最大250万 最大250万 1ループあたりの実行回数は少ないですが、今回はCSVが50万行あることから50万ループ実行され、最大で合計250万クエリ実行されることになります。 実行されるクエリが多いと、インデックスを貼って単体のクエリが高速にしたとしても、ちりつもで遅くなってしまいます。 特にDBは別サーバに分離されることが多く、ネットワークの通信帯域の影響も受けてしまいます。 なので高速化の方針としては実行されるクエリをいかに削減するかということを考えれば良さそうです。 実行されるクエリを削減するためには? SELECT編 実行されるクエリを削減するにはいくつかの手段がありますが、まずはオンメモリキャッシュを取り上げてみたいと思います。 オンメモリキャッシュは、時間のかかる処理の実行結果をあらかじめメモリ上に乗っけてしまい、結果が欲しい時にはメモリ上のデータから引っ張り出すことで高速化する手法です。ISUCONでは常套手段といっても良いほど典型的なパターンです。 今回でいくと時間のかかる処理とはDBへの問い合わせにあたります。 オンメモリでキャッシュするには、キャッシュ対象のデータが、キャッシュ中に書き換えられないほうが実装しやすいです。 キャッシュ中に実データに書き込みがある場合、キャッシュを書き込みに追随させるためデータの更新が必要になります。排他制御を考慮する必要があり、実装が困難になります。 productsテーブルを更新する際にはcategories, suppliers, statusesテーブルはすでに更新が完了しており、書き込みはありません。なのでproductsテーブルを更新する前にキャッシュしておけば問題なさそうです。 ということで先ほどのコードにキャッシュ処理を加えます。 CSV読み取り直後にSELECTを行い全件をメモリ上に載せます。 code→IDへ高速にデータを引きたいので、スライスではなくここでは map[string]int に載せてあげます。map型はキーにひもづくデータの取得で$O(1)$の計算量で高速にデータを引くことができます。 fmt.Printf("CSV読み込み完了: %d 行\n", len(products)) + // マスターデータをmapに読み込み(code → id) + var categories []Category + if err := db.Select(&categories, "SELECT * FROM categories"); err != nil { + log.Fatal(err) + } + categoryMap := make(map[string]int, len(categories)) + for _, c := range categories { + categoryMap[c.Code] = c.ID + } code→IDが欲しいタイミングで、先ほど定義したmap型の変数を使うように書き換えます // 3. category_codeをcategory_idに変換 - var category Category - if err := db.Get(&category, "SELECT * FROM categories WHERE code = ?", product.CategoryCode); err != nil { - log.Printf("行 %d: category変換エラー: %v", i+2, err) + categoryID, ok := categoryMap[product.CategoryCode] + if !ok { + log.Printf("行 %d: category変換エラー: code %q が見つかりません", i+2, product.CategoryCode) errorCount++ continue } 他の修正も加えると以下のような差分になります。 :::details オンメモリキャッシュ化の全体差分 diff --git a/main.go b/main.go index c3705d8..c3c16cf 100644 --- a/main.go +++ b/main.go @@ -52,6 +52,34 @@ func main() { } fmt.Printf("CSV読み込み完了: %d 行\n", len(products)) + // マスターデータをmapに読み込み(code → id) + var categories []Category + if err := db.Select(&categories, "SELECT * FROM categories"); err != nil { + log.Fatal(err) + } + categoryMap := make(map[string]int, len(categories)) + for _, c := range categories { + categoryMap[c.Code] = c.ID + } + + var suppliers []Supplier + if err := db.Select(&suppliers, "SELECT * FROM suppliers"); err != nil { + log.Fatal(err) + } + supplierMap := make(map[string]int, len(suppliers)) + for _, s := range suppliers { + supplierMap[s.Code] = s.ID + } + + var statuses []Status + if err := db.Select(&statuses, "SELECT * FROM statuses"); err != nil { + log.Fatal(err) + } + statusMap := make(map[string]int, len(statuses)) + for _, s := range statuses { + statusMap[s.Code] = s.ID + } + importStart := time.Now() for i, product := range products { @@ -59,41 +87,29 @@ func main() { lineNum := i + 2 // 3. category_codeをcategory_idに変換 - var category Category - if err := db.Get( - &category, - `SELECT * FROM categories WHERE code = ?`, - product.CategoryCode, - ); err != nil { - log.Fatalf("行 %d: category_code %q の検索に失敗: %v", lineNum, product.CategoryCode, err) + categoryID, ok := categoryMap[product.CategoryCode] + if !ok { + log.Fatalf("行 %d: category_code %q の検索に失敗", lineNum, product.CategoryCode) } // 4. supplier_codeをsupplier_idに変換 - var supplier Supplier - if err := db.Get( - &supplier, - `SELECT * FROM suppliers WHERE code = ?`, - product.SupplierCode, - ); err != nil { - log.Fatalf("行 %d: supplier_code %q の検索に失敗: %v", lineNum, product.SupplierCode, err) + supplierID, ok := supplierMap[product.SupplierCode] + if !ok { + log.Fatalf("行 %d: supplier_code %q の検索に失敗", lineNum, product.SupplierCode) } // 5. status_codeをstatus_idに変換 - var status Status - if err := db.Get( - &status, - `SELECT * FROM statuses WHERE code = ?`, - product.StatusCode, - ); err != nil { - log.Fatalf("行 %d: status_code %q の検索に失敗: %v", lineNum, product.StatusCode, err) + statusID, ok := statusMap[product.StatusCode] + if !ok { + log.Fatalf("行 %d: status_code %q の検索に失敗", lineNum, product.StatusCode) } row := ProductRow{ ProductCode: product.ProductCode, ProductName: product.ProductName, - CategoryID: category.ID, - SupplierID: supplier.ID, - StatusID: status.ID, + CategoryID: categoryID, + SupplierID: supplierID, + StatusID: statusID, UnitPrice: product.UnitPrice, } ::: DBに問い合わせる代わりにメモリ上のキャッシュにデータを問い合わせるため、 SELECTの150万回分がなくなり、残りのUPDATE/INSERTの最大100万回にまで削減できました。 # クエリ ループ前(回) ループ中(回) 合計(回) 1 SELECT * FROM categories 1 0 1 2 SELECT * FROM suppliers 1 0 1 3 SELECT * FROM statuses 1 0 1 4 UPDATE products SET ... WHERE product_code = ? 0 1 × 50万ループ = 50万 50万 5 INSERT INTO products (...) VALUES (...) 0 最大1 × 50万ループ = 最大50万 最大50万 合計 3 最大100万 最大100万3 これでどれくらい高速化できたか見てみましょう。 CSV読み込み完了: 500000 行 進捗: 1000 / 500000 行 (282 行/秒) 進捗: 2000 / 500000 行 (302 行/秒) 進捗: 3000 / 500000 行 (330 行/秒) 進捗: 4000 / 500000 行 (360 行/秒) 進捗: 5000 / 500000 行 (378 行/秒) (略) 進捗: 496000 / 500000 行 (409 行/秒) 進捗: 497000 / 500000 行 (409 行/秒) 進捗: 498000 / 500000 行 (409 行/秒) 進捗: 499000 / 500000 行 (407 行/秒) 進捗: 500000 / 500000 行 (405 行/秒) 完了: 成功 500000 行, エラー 0 行 (所要時間: 20m35.34731075s) 以上のように時間を半減させることができました。 INSERT/UPDATE編 SELECTの実行回数は削減できましたが、まだ100万回ものSQLが実行されています。 残りのINSERT/UPDATEの高速化にチャレンジしてみます。 INSERT/UPDATEの実行回数を削減する手段としてはupsertに変更することが挙げられます。 UPSERTとは UPSERTとはINSERTとUPDATEを組み合わせた単語で、INSERT時に対象レコードが存在しない場合はINSERTと、すでに存在する場合はUPDATEをかける処理です。 MySQLではINSERT ON DUPLICATE KEY UPDATEとREPLACE構文が使えますが、今回は前者の構文を使ってみます。 今回でいくと以下のUPDATE文を実行し、 UPDATE products SET product_name = ?, category_id = ?, supplier_id = ?, status_id = ?, unit_price = ? WHERE product_code = ? UPDATE対象が存在しなければINSERTを行っています。 INSERT INTO products ( product_code, product_name, category_id, supplier_id, status_id, unit_price ) VALUES (?, ?, ?, ?, ?, ?) INSERT ON DUPLICATE KEY UPDATEを使用すると2つのクエリを1つにまとめることができます。 INSERT INTO products ( product_code, product_name, category_id, supplier_id, status_id, unit_price ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE product_name = VALUES(product_name), category_id = VALUES(category_id), supplier_id = VALUES(supplier_id), status_id = VALUES(status_id), unit_price = VALUES(unit_price) これだけで100万回→50万回までクエリの実行回数を削減できます。 # クエリ ループ前(回) ループ中(回) 合計(回) 1 SELECT * FROM categories 1 0 1 2 SELECT * FROM suppliers 1 0 1 3 SELECT * FROM statuses 1 0 1 4 INSERT INTO products (...) ON DUPLICATE KEY UPDATE ... 0 1 × 50万ループ = 50万 50万 合計 3 50万 50万3 コードでは以下のように修正しています :::details UPSERT化の差分 diff --git a/main.go b/main.go index c3c16cf..0da4db0 100644 --- a/main.go +++ b/main.go @@ -113,36 +113,23 @@ func main() { UnitPrice: product.UnitPrice, } - // 7. UPDATE文を実行する - result, err := db.NamedExec(` - UPDATE products - SET product_name = :product_name, - category_id = :category_id, - supplier_id = :supplier_id, - status_id = :status_id, - unit_price = :unit_price - WHERE product_code = :product_code`, + // 7. UPSERT(INSERT or UPDATE)を実行する + _, err := db.NamedExec(` + INSERT INTO products ( + product_code, product_name, category_id, supplier_id, status_id, unit_price + ) VALUES ( + :product_code, :product_name, :category_id, :supplier_id, :status_id, :unit_price + ) + ON DUPLICATE KEY UPDATE + product_name = VALUES(product_name), + category_id = VALUES(category_id), + supplier_id = VALUES(supplier_id), + status_id = VALUES(status_id), + unit_price = VALUES(unit_price)`, row, ) if err != nil { - log.Fatalf("行 %d: productsの更新に失敗: %v", lineNum, err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - log.Fatalf("行 %d: 更新件数の取得に失敗: %v", lineNum, err) - } - - // 8. UPDATE対象がなければINSERTする - if rowsAffected == 0 { - _, err = db.NamedExec(` - INSERT INTO products (product_code, product_name, category_id, supplier_id, status_id, unit_price) - VALUES (:product_code, :product_name, :category_id, :supplier_id, :status_id, :unit_price)`, - row, - ) - if err != nil { - log.Fatalf("行 %d: productsの登録に失敗: %v", lineNum, err) - } + log.Fatalf("行 %d: productsのUPSERTに失敗: %v", lineNum, err) } if (lineNum-1)%1000 == 0 { ::: 実行してみましょう。 CSV読み込み完了: 500000 行 進捗: 1000 / 500000 行 (636 行/秒) 進捗: 2000 / 500000 行 (642 行/秒) 進捗: 3000 / 500000 行 (658 行/秒) 進捗: 4000 / 500000 行 (661 行/秒) 進捗: 5000 / 500000 行 (652 行/秒) (略) 進捗: 497000 / 500000 行 (650 行/秒) 進捗: 498000 / 500000 行 (650 行/秒) 進捗: 499000 / 500000 行 (650 行/秒) 進捗: 500000 / 500000 行 (650 行/秒) 完了: 成功 500000 行, エラー 0 行 (所要時間: 12m48.924974166s) この修正だけで10分程度まで早くすることができました。 bulk化する upsertに変更して50万回までSQLの実行回数を削減できました。 さらにSQLの実行回数を削減するためにSQLをbulk化してみます。 bulk化とはDBに対して複数のレコードに対する操作を1つのSQLにまとめて実行することを言います。 以下のUPSERT化したSQLはいまだ50万回叩かれています。 INSERT INTO products ( product_code, product_name, category_id, supplier_id, status_id, unit_price ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE product_name = VALUES(product_name), category_id = VALUES(category_id), supplier_id = VALUES(supplier_id), status_id = VALUES(status_id), unit_price = VALUES(unit_price) このSQLを1行ずつ入れていくのではなく、ある程度のレコード数で固めてから送ることで SQLの実行回数を減らせるわけです。 今回は1000レコード分ずつSQLをまとめて送ることにしてみましょう。 すると500000/1000=500回までSQLの実行回数を削減できます。 # クエリ ループ前(回) ループ中(回) 合計(回) 1 SELECT * FROM categories 1 0 1 2 SELECT * FROM suppliers 1 0 1 3 SELECT * FROM statuses 1 0 1 4 INSERT INTO products (...) VALUES (...), (...), ... ON DUPLICATE KEY UPDATE ... 0 50万ループ / 1000 = 500 500 合計 3 500 503 どれくらい固めるかを表す数値をバッチサイズと呼びますが、この場合バッチサイズは1000となります。 :::details バルクUPSERT化の差分 diff --git a/main.go b/main.go index 0da4db0..daf2689 100644 --- a/main.go +++ b/main.go @@ -80,8 +80,8 @@ func main() { statusMap[s.Code] = s.ID } - importStart := time.Now() - + // code → id 変換してProductRowスライスを構築 + var rows []ProductRow for i, product := range products { // 2. 読んでない行があれば1行読み取る、なければ終了 lineNum := i + 2 @@ -104,16 +104,29 @@ func main() { log.Fatalf("行 %d: status_code %q の検索に失敗", lineNum, product.StatusCode) } - row := ProductRow{ + // 6. ProductRowに変換 + rows = append(rows, ProductRow{ ProductCode: product.ProductCode, ProductName: product.ProductName, CategoryID: categoryID, SupplierID: supplierID, StatusID: statusID, UnitPrice: product.UnitPrice, + }) + } + fmt.Printf("変換完了: %d 行\n", len(rows)) + + // バルクUPSERT(1000行ずつ) + const batchSize = 1000 + importStart := time.Now() + + for i := 0; i < len(rows); i += batchSize { + end := i + batchSize + if end > len(rows) { + end = len(rows) } + batch := rows[i:end] - // 6. UPSERT(INSERT or UPDATE)を実行する _, err := db.NamedExec(` INSERT INTO products ( product_code, product_name, category_id, supplier_id, status_id, unit_price @@ -126,17 +139,16 @@ func main() { supplier_id = VALUES(supplier_id), status_id = VALUES(status_id), unit_price = VALUES(unit_price)`, - row, + batch, ) if err != nil { - log.Fatalf("行 %d: productsのUPSERTに失敗: %v", lineNum, err) + log.Fatalf("バッチ %d-%d: UPSERTに失敗: %v", i+1, end, err) } - if (lineNum-1)%1000 == 0 { - rate := float64(lineNum-1) / time.Since(importStart).Seconds() - fmt.Printf("進捗: %d / %d 行 (%.0f 行/秒)\n", lineNum-1, len(products), rate) + if end%10000 == 0 || end == len(rows) { + rate := float64(end) / time.Since(importStart).Seconds() + fmt.Printf("進捗: %d / %d 行 (%.0f 行/秒)\n", end, len(rows), rate) } - // 8. 2に戻る } fmt.Printf("完了: %d 行 (所要時間: %v)\n", len(products), time.Since(importStart)) ::: では実行してみましょう。 CSV読み込み完了: 500000 行 変換完了: 500000 行 (エラー 0 行) 進捗: 10000 / 500000 行 (56843 行/秒) 進捗: 20000 / 500000 行 (72234 行/秒) 進捗: 30000 / 500000 行 (78721 行/秒) 進捗: 40000 / 500000 行 (73047 行/秒) 進捗: 50000 / 500000 行 (76230 行/秒) 進捗: 60000 / 500000 行 (78932 行/秒) 進捗: 70000 / 500000 行 (81193 行/秒) (略) 進捗: 460000 / 500000 行 (83997 行/秒) 進捗: 470000 / 500000 行 (83998 行/秒) 進捗: 480000 / 500000 行 (84197 行/秒) 進捗: 490000 / 500000 行 (83433 行/秒) 進捗: 500000 / 500000 行 (83642 行/秒) 完了: 成功 500000 行, エラー 0 行 (所要時間: 5.977838667s) わずか6秒程度で完了するようになりました! 元々50分かかっていた処理だと考えると、かなり高速化されたのではないかと思います。 改善後の実際のFACTORYでのDBの負荷状況 改善の結果を先述のDatabase InsightsのAASで確認してみましょう。 赤枠がマスタ反映時に実行されているSQLになりますが、 改善前に負荷がかかっているSQLとして挙げられていたSELECTがなくなって、ボトルネックを解消した INSERTはまだいるが実行回数が減り、AASも減った このように実際のFACTORYのDBの計測からも負荷が減ったことがわかります。 この改善の結果、5分程度で反映が終わるようになりました! 改善前は90分かかっていたと考えるとめちゃくちゃ高速化できました! まとめ 今回の改善の変遷をまとめると以下の通りです。 ステップ 施策 所要時間 SQL実行回数(最大) 改善前 - 47分 250万回 1. オンメモリキャッシュ SELECTをメモリ参照に置換 20分 100万回 2. UPSERT化 UPDATE+INSERTを1クエリに統合 13分 50万回 3. バルクUPSERT化 1000行ずつまとめて実行 6秒 500回 パフォーマンスチューニングでとった方法はどれもISUCONではよく出てくる典型的な対応策です。 まさかISUCONで培った知識を使って業務でこれほどまでの結果を出せるとは思いもしませんでした。 ISUCONは業務でも役に立ちます。 これからもISUCONで腕を磨きつつ、業務でのボトルネックを改善していきたいと考えています。
はじめに はじめまして。 KINTO テクノロジーズで KINTO Unlimited Android アプリを開発している JR.Liang です。 本記事では、KINTO Unlimited アプリにて提供する「これなにガイド」スキャン機能の AR エフェクトについて、Android における技術的な検証を紹介します。 特に MediaPipe のソリューションを用いて幅広い Android デバイスで AR エフェクトを実現した実装にフォーカスします。 これなにガイドとは 「これなにガイド」は AR(拡張現実)を活用して、車内スイッチの用途や使い方をテキストと動画で案内する機能です。紹介動画をご覧ください。 https://youtube.com/watch?v=E8zfNzuHr7g&embeds_referring_euri=https%3A%2F%2Fcorp.kinto-jp.com%2F&source_ve_path=MjM4NTE 上記の紹介動画は iOS アプリでの動作を示しています。スイッチ上に表示された黄色の丸 🟡 が、AR 技術で実現した仮想コンテンツです。 機能全体の仕組みは以下の流れです。本記事では 3 番目(描画)に関する内容を扱います。 1. アプリのカメラを起動、カメラ画像を取得 2. 機械学習における物体認識を用いて、車内のスイッチを検出 3. 検出した座標を元に、ボタンとテキストをフレーム上に描画 4. ボタンをタップして、当該スイッチのテキストと動画を表示 Android AR 技術検証の経緯 当初の Android 版「これなにガイド」のスキャン機能では、Canvas を利用して毎フレーム検出される座標に描画する実装でした。そのため検出の時間差により、スマホ(カメラ)を動かすと描画のズレが生じていました。 2D Canvas 幸い、MediaPipe のソリューションである Instant Motion Tracking モジュールで 素早くかつ安定した AR エフェクトを実現できることがわかり、Android への導入を検証しました。 3D OpenGL MediaPipe Instant Motion Tracking MediaPipe は Google が開発したオープンソースの ML フレームワークで、顔検出・手のトラッキング・姿勢推定などリアルタイム映像処理のソリューションを提供します。 その中の Instant Motion Tracking は、現実世界のシーン上に 3D 仮想コンテンツをリアルタイムで正確に配置できる AR トラッキング機能です。初期化や厳密なキャリブレーションが不要で、静止面や動いている面の上にコンテンツを置くことが可能です。 @ card Android + MediaPipe AR アーキテクチャ graph TB A(Android CameraX) --> |Camera Frame| B(Instant Motion Tracking) B --> |Camera Image| C(TensorFlow Object Detection) C --> |Detections Information| B(Instant Motion Tracking) B --> |Output Stream| D(Android Surface Rendering) CameraX で取得したフレームを Instant Motion Tracking に渡し、TensorFlow Lite で物体検出した情報を元に AR コンテンツを描画・追従させるパイプラインです。 MediaPipe ライブラリの作成 MediaPipe では Bazel を使用してパッケージをビルドします。Android に適合する AAR として書き出してアプリに組み込みます。 https://chuoling.github.io/mediapipe/getting_started/android_archive_library.html AAR をビルドする BUILD ファイルを作成し、 instant_motion_tracking を基盤とした定義を記述します。 load("//mediapipe/java/com/google/mediapipe:mediapipe_aar.bzl", "mediapipe_aar") mediapipe_aar( name = "mediapipe_ar", calculators = ["//mediapipe/graphs/instant_motion_tracking:instant_motion_tracking_deps"] ) MediaPipe は C++ が中核のため、C++ ランタイムである libc++_shared.so を AAR に同梱する必要があります。 https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/third_party/BUILD#L399-L403 また Instant Motion Tracking では画像処理ライブラリ OpenCV を利用し、AR トラッキングを行います。 https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/WORKSPACE#L649-L655 上記サードパーティのライブラリを含めて、以下のコマンドで AAR をビルドします。 bazel build -c opt --strip=ALWAYS \ --host_crosstool_top=@bazel_tools//tools/cpp:toolchain \ --fat_apk_cpu=arm64-v8a \ --linkopt=-Wl,-z,max-page-size=16384 \ //path/to/the/aar/build/mediapipe_ar:mediapipe_ar.aar 市場に流通している Android デバイスは主に arm64-v8a アーキテクチャのため、AAR のサイズを抑える目的で fat_apk_cpu=arm64-v8a にします。 C++ ライブラリの 16KB page-size に対応するため、 max-page-size=16384 を追加します。 また AAR を利用するにはグラフ構造を定義するファイル( binarypb )が必要です。 bazel build -c opt mediapipe/graphs/instant_motion_tracking:instant_motion_tracking.binarypb Instant Motion Tracking の導入 AAR をアプリに組み込んで、Android 側の実装を解説していきます。 下記は AAR に組み込んだ instant_motion_tracking の全体構造です。 instant_motion_tracking.pbtxt の構成 グラフ定義ファイル instant_motion_tracking.pbtxt は、Calculator(処理ノード)・入出力ストリーム・サイドパケットの 3 要素で構成されます。 Calculator 各 Calculator がパイプライン上でどの処理を担うかを示します。 Calculator 役割 ImageTransformationCalculator カメラフレームを 320×320(FIT)にリサイズ。物体検出モデルの入力サイズに合わせる GpuBufferToImageFrameCalculator GPU テクスチャを CPU の ImageFrame に変換。TensorFlow Lite 推論に使用 StickerManagerCalculator Sticker Proto をパースし、初期アンカーの座標・回転・スケール・レンダリング種別に分解 RegionTrackingSubgraph ボックストラッキングでアンカー位置を追従。内部に TrackedAnchorManagerCalculator (アンカー管理)と BoxTrackingSubgraphGpu (GPU トラッキング)を持つ MatricesManagerCalculator トラッキング結果・回転・スケール・FOV・アスペクト比から OpenGL 用 4×4 モデル行列を生成 GlAnimationOverlayCalculator モデル行列とテクスチャを用いて、元のカメラフレーム上に AR コンテンツを OpenGL で描画し output_video として出力 input_stream / output_stream input_stream はフレームごとに Android 側から送信するデータ、 output_stream はグラフの処理結果です。 ストリーム名 C++ 型 方向 用途 input_video GpuBuffer Input カメラフレーム sticker_proto_string String(Serialized Proto) Input ステッカーの座標・スケール等(Sticker Proto) sticker_sentinels vector Input 座標をリセットするステッカー ID の配列 gif_textures vector Input AR コンテンツの Bitmap テクスチャ配列 gif_aspect_ratios vector Input 各テクスチャのアスペクト比 output_video GpuBuffer Output AR 描画済みフレーム input_side_packet input_side_packet は初期化時に一度だけ渡す定数で、グラフ実行中は変化しません。 パケット名 用途 vertical_fov_radians カメラの垂直 FOV(ラジアン) aspect_ratio カメラのアスペクト比 width / height カメラ解像度 gif_texture デフォルトテクスチャ(1x1 プレースホルダ) gif_asset_name AR テクスチャ描画用のポリゴンメッシュ( .obj )ファイル名 Android への導入に当たって、公式サンプルのコードを参考にします。 https://github.com/google-ai-edge/mediapipe/tree/master/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking 1. 初期化 MediaPipe を使用する前に、ネイティブライブラリの読み込みとアセットマネージャーの初期化が必要です。 companion object { init { System.loadLibrary("mediapipe_jni") System.loadLibrary("opencv_java4") } } // onCreate 相当の処理 AndroidAssetUtil.initializeNativeAssetManager(context) mediapipe_jni : MediaPipe のコア処理を行う JNI ライブラリ opencv_java4 : AR トラッキングに使用する OpenCV ライブラリ initializeNativeAssetManager : ネイティブコードからアセット(binarypb 等)にアクセスするために必要 2. カメラを起動する 公式サンプルを参考に、以下の順序でパイプラインを構築します。 データフロー: CameraX → ExternalTextureConverter → FrameProcessor → SurfaceView 2.1 EGL 環境と FrameProcessor の初期化 val eglManager = EglManager(null) val frameProcessor = FrameProcessor( context, eglManager.nativeContext, "instant_motion_tracking.binarypb", "input_video", "output_video" ).apply { videoSurfaceOutput.setFlipY(true) setInputSidePackets( mapOf( "gif_asset_name" to packetCreator.createString("gif.obj.uuu"), "vertical_fov_radians" to packetCreator.createFloat32(fovRadians), "aspect_ratio" to packetCreator.createFloat32(resolution.width.toFloat() / resolution.height.toFloat()), "width" to packetCreator.createInt32(resolution.width), "height" to packetCreator.createInt32(resolution.height), "gif_texture" to packetCreator.createRgbaImageFrame(createBitmap(1, 1)) ) ) } EglManager : OpenGL ES の EGL コンテキストを作成・管理。MediaPipe のグラフ内 GPU Calculator( GlAnimationOverlayCalculator 等)が OpenGL で描画するために必要 FrameProcessor : EGL コンテキストを受け取り、グラフの読み込み・入出力ストリームの管理・フレームごとのグラフ実行を行う instant_motion_tracking.binarypb : .pbtxt を Bazel でコンパイルしたグラフ定義バイナリ input_video : MediaPipe グラフへカメラフレームを入力 output_video : グラフで処理(AR 描画など)された映像を出力 videoSurfaceOutput.setFlipY(true) : OpenGL とカメラの Y 軸方向が逆のため、出力映像を上下反転して正しい向きにする setInputSidePackets : グラフの input_side_packet に対応する定数をまとめて設定。カメラの FOV・アスペクト比・解像度など、グラフ実行中に変化しない値を初期化時に一度だけ渡す gif_asset_name は AR テクスチャを描画するための ポリゴンメッシュ(頂点データ) 、ここでは公式サンプルの gif.obj.uuu を利用 2.2 カメラ映像の変換パイプライン構築 val externalTextureConverter = ExternalTextureConverter(eglManager.context, 2).apply { setFlipY(true) setConsumer(frameProcessor) setDestinationSize(resolution.width, resolution.height) } val cameraHelper = object : CameraXPreviewHelper() { override fun getCameraCharacteristics(context: Context?, lensFacing: Int?) = cameraCharacteristics }.apply { setOnCameraStartedListener(onCameraStartedListener) startCamera( context, lifecycleOwner, CameraHelper.CameraFacing.BACK, externalTextureConverter.surfaceTexture, Size(resolution.height, resolution.width) ) } ExternalTextureConverter : カメラの GL_EXTERNAL_OES テクスチャを MediaPipe が処理できる標準テクスチャに変換 setFlipY(true) : カメラ映像の上下反転を補正 setDestinationSize(resolution.width, resolution.height) : パイプラインの処理サイズはポートレート座標(例: 960×1280 )で指定 CameraXPreviewHelper : CameraX でバックカメラを起動し、Converter の SurfaceTexture に出力 startCamera(targetSize = Size(resolution.height, resolution.width)) : CameraX はセンサー座標(ランドスケープ)を期待するため、width と height を入れ替えて渡す 公式サンプルでは CameraXPreviewHelper をそのまま使用し、内部で CameraManager からカメラ特性を取得します。 https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/mediapipe/java/com/google/mediapipe/components/CameraXPreviewHelper.java#L558-L560 本実装では getCameraCharacteristics をオーバーライドし、事前に取得済みの CameraCharacteristics を直接渡します。これにより FOV やアスペクト比の算出に使うカメラ情報を、アプリ側で一元管理できます。 2.3 出力先SurfaceViewの設定 SurfaceView(context).apply { holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { frameProcessor.videoSurfaceOutput.setSurface(holder.surface) } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { val displaySize = cameraHelper.computeDisplaySizeFromViewSize(Size(width, height)) val (displayWidth, displayHeight) = if (cameraHelper.isCameraRotated) { displaySize.height to displaySize.width } else { displaySize.width to displaySize.height } externalTextureConverter.setDestinationSize(displayWidth, displayHeight) } override fun surfaceDestroyed(holder: SurfaceHolder) { frameProcessor.videoSurfaceOutput.setSurface(null) } }) } SurfaceHolder.Callback : SurfaceView のライフサイクルに応じて FrameProcessor の出力先を管理 surfaceCreated : FrameProcessor の出力先として Surface を設定 surfaceChanged : 画面回転・サイズ変更時に出力解像度を調整 surfaceDestroyed : リソース解放 3. 検出座標をグラフに渡す 物体検出(TensorFlow Lite 等)で得られた座標を MediaPipe グラフに渡し、AR コンテンツを配置します。 3.1 グラフから変換済み画像を取得 MediaPipe グラフ内で ImageTransformationCalculator と GpuBufferToImageFrameCalculator によって変換された画像を addPacketCallback で受け取り、物体検出に使用します。 frameProcessor.addPacketCallback("transformed_input_video_cpu") { packet -> packet ?: return@addPacketCallback // 変換済み画像を物体検出(TensorFlow Lite)に渡す val bitmap = PacketGetter.getBitmapFromRgba(packet) objectDetector.detect(bitmap) { detections -> // 検出結果を処理 } } transformed_input_video_cpu : 変換後の画像を出力するストリーム名 3.2 座標の正規化 物体検出結果のピクセル座標を、MediaPipe が期待する正規化座標に変換します。 // ピクセル座標 → 正規化座標 (0.0〜1.0) val normalizedX = pixelX / imageWidth.toFloat() val normalizedY = pixelY / imageHeight.toFloat() 3.3 Sticker Proto の構造 Instant Motion Tracking では、AR オブジェクトの位置情報を Protocol Buffers 形式で定義します。 message Sticker { int32 id = 1; // ユニークID float x = 2; // 正規化X座標 (0.0〜1.0) float y = 3; // 正規化Y座標 (0.0〜1.0) float rotation = 4; // 回転角度 float scale = 5; // スケール int32 render_id = 6; // レンダリングID } message StickerRoll { repeated Sticker sticker = 1; } 3.4 フレームごとにパケットを送信 setOnWillAddFrameListener を使用して、各フレーム処理前に検出座標をグラフへ送信します。 frameProcessor.setOnWillAddFrameListener { timestamp -> with(frameProcessor.graph) { // 検出された物体の座標情報をパケットとして送信 val stickerRoll = StickerRoll.newBuilder() .addAllSticker(detectedObjects.map { detection -> Sticker.newBuilder() .setId(detection.id) .setX(detection.normalizedX) // 0.0〜1.0 .setY(detection.normalizedY) // 0.0〜1.0 .setScale(detection.scale) .build() }) .build() val stickersPacket = packetCreator.createSerializedProto(stickerRoll) addPacketToInputStream("sticker_proto_string", stickersPacket, timestamp) } } FrameProcessor.setOnWillAddFrameListener : 各フレームがグラフに送られる直前に呼ばれるコールバック FrameProcessor.graph.addPacketToInputStream : 入力ストリームにパケットを追加 sticker_proto_string : グラフ定義で指定された入力ストリーム名 4. テクスチャ(Bitmap)の描画と送信 位置情報と同時に、AR コンテンツとして描画する Bitmap テクスチャもグラフに渡します。 4.1 Bitmap テクスチャの生成 検出された各スイッチに対して、丸アイコンとラベルテキストを含む Bitmap を生成します。 val bitmap = createBitmap(width.toInt(), height.toInt()).apply { with(Canvas(this)) { concat(Matrix().apply { preScale(-1.0f, 1.0f, width / 2f, height / 2f) // X軸を反転して描画 }) drawCircle(circleX, circleY, CIRCLE_RADIUS, circlePaint) drawRect(rectLeft, rectTop, rectRight, rectBottom, backgroundPaint) } } Matrix().preScale(-1.0f, 1.0f) で Bitmap を左右反転しています。以下の IMU 行列に合わせるためです。 float imu_matrix[9] = { -1.0f, 0.0f, 0.0f, // X軸 → 反転(-X) 0.0f, 0.0f, 1.0f, // Y軸 → Z軸へ 0.0f, 1.0f, 0.0f // Z軸 → Y軸へ }; この行列は OpenGL モデル行列(4x4)の回転成分として使われ、Y/Z 軸の入れ替えと X 軸反転でテクスチャをカメラ平面に平行に固定します。 本来はデバイスの IMU センサーから回転行列を受け取り、端末の傾きに追従させます。 https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L218-L220 本実装では固定値にすることで 常にカメラ正面を向く (ビルボード効果)ようにし、 (0,0) の -1.0 による X 軸反転を Bitmap 側の preScale(-1.0f, 1.0f) で打ち消します。 4.2 テクスチャの送信 // テクスチャ画像(Bitmap配列) val texturesPacket = packetCreator.createRgbaImageFrameVector( renderStickers.map { it.bitmap }.toTypedArray() ) addPacketToInputStream("gif_textures", texturesPacket, timestamp) // アスペクト比(テクスチャの縦横比) val aspectRatiosPacket = packetCreator.createFloat32Vector( renderStickers.map { it.aspectRatio }.toFloatArray() ) addPacketToInputStream("gif_aspect_ratios", aspectRatiosPacket, timestamp) PacketCreator.createRgbaImageFrameVector : 複数の Bitmap を RGBA 形式のパケットに変換 gif_textures : テクスチャ画像の入力ストリーム gif_aspect_ratios : 各テクスチャのアスペクト比(正しいスケーリングに必要) 公式サンプルでは createRgbaImageFrame を使用して 単一のテクスチャ をグラフに渡します。 https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L608-L610 本実装では、複数の検出オブジェクトに対応するため createRgbaImageFrameVector で 複数テクスチャを同時に送信 し、 gif_aspect_ratios も createFloat32Vector で 各テクスチャに対応するアスペクト比の配列 を渡すよう拡張します。これにより、検出された各スイッチに異なるラベル(テキスト付きBitmap)を正しい縦横比で表示できます。 ここまでで AR コンテンツをカメラ上に表示できました。 5. 座標の更新 トラッキング中のステッカー座標を更新するには、新しい座標を持つ sticker_proto_string と、リセット対象の ID を含む sticker_sentinels を同一 timestamp で送信します。 TrackedAnchorManagerCalculator が該当 ID のトラッキングボックスを破棄し、新しい座標でトラッキングを再開します。 // 更新した座標で Sticker Proto を再構築 val stickersPacket = packetCreator.createSerializedProto(stickerRoll) addPacketToInputStream("sticker_proto_string", stickersPacket, timestamp) // リセット対象のステッカー ID を送信 val stickerSentinels = packetCreator.createInt32Vector(updateIds) addPacketToInputStream("sticker_sentinels", stickerSentinels, timestamp) 公式サンプルでは sticker_sentinel で 単一のステッカー ID を送信します。 https://github.com/google-ai-edge/mediapipe/blob/0.10.32/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking/MainActivity.java#L342-L344 本実装では sticker_sentinels として createInt32Vector で 複数のステッカー ID を配列 で渡すよう拡張し、物体検出で座標が更新された複数のステッカーを同時にリセットできるようにします。 最後に 以上が MediaPipe Instant Motion Tracking を用いた技術的な実装解説でした。決して容易に導入できる手法ではありませんが、本機能の要件に対して Android に最も適した解決策だと考えています。 以前に ARCore の検証も行いましたが、ARCore は SLAM 技術による事前の 3D マッピングに時間を要し、 素早くかつ安定した AR エフェクトの実現には適さなかったため、検証を断念しました。 両フレームワークの違いを以下にまとめます。AR 技術の検討で参考になれば幸いです。 項目 Instant Motion Tracking ARCore 仕組み 2D ボックストラッキング + OpenGL 描画 環境マッピング + 平面検出(SLAM) デバイス要件 OpenGL ES 対応であれば動作 ARCore 対応デバイスのみ(Google 認定必須) 安定性 検出座標に依存するため補正が必要 空間認識が高精度で安定 導入コスト Bazel ビルド・C++ Calculator のカスタマイズが必要 SDK 導入のみで比較的容易 オープンソース あり(Apache 2.0) なし(プロプライエタリ) カスタマイズ性 Calculator の追加・変更で柔軟に拡張可能 SDK の API 範囲内に限定 パフォーマンス 軽量(2D トラッキングベースのため CPU/GPU 負荷が低い) 高負荷(環境の 3D 空間マッピングを常時実行) 学習コスト 高い(Bazel・C++・OpenGL・Protocol Buffers の知識が必要) 低い(Android SDK の知見で導入可能)
こんにちは!KINTOテクノロジーズ(以下、KTC)のAIファーストグループで、生成AIの社内活用推進を担当している和田です。普段は生成AIを使った業務価値の創出から、社内の教育研修、技術の手の内化まで、「AIを現場に届ける」仕事をしています。 今回お話ししたいのは、 AI Agent(AIエージェント) というトレンドです。KTCのようなテックカンパニーの内側で何が起きているのか。そして、ITやAIの知識を持つ我々と、業務の知識を持つ方々(それは時によってメーカーの設計技術者さんだったり、販売店の営業さんだったりします)との「協業の形」がどう変わろうとしているのか。「ニンベンのついた自働化」というキーワードを軸に、お伝えしていきます。 1. はじめに ― なぜ今「エージェント」なのか 生成AIの進化を振り返ると、大きく3つのフェーズがあったと考えています。 時期 フェーズ 特徴 2022〜2023年 チャットAI 1問1答。「質問すれば答えてくれる」体験が広がる 2024年 RAG全盛期 RAG(Retrieval-Augmented Generation:社内データ等を検索しながら回答を生成する手法)で「自社の情報を知っているAI」が登場 2025年〜 AI Agent AIが自ら考え、ツールを使い、複数ステップの仕事をこなす Agentを実現するOSSの老舗であるLangChainをはじめ、エージェントという概念自体は2023年頃にはすでに存在していました。しかし当時は、LLMそのものの"地頭"がまだ追いついていませんでした。指示を正しく理解できない、途中で迷子になる、ツールの使い方を間違える ― そんな状態を覚えている方もいると思います。 ここ1〜2年でLLM(Large Language Model:大規模言語モデル)の精度が飛躍的に向上したことで、ようやくエージェントが「実用に耐える」レベルになってきました。これは毎日エージェントを使い、自身の業務を常に効率化し続けてきた私の実感です。 2026年の今、多くの企業がエージェント技術を「PoCから社会実装へ」と動き始めています。試すフェーズは終わり、業務に組み込むフェーズに入りつつある。だからこそ、「どう組み込むか」の設計思想が問われています。 2. 目指す姿 ― 「ニンベンのついた自働化」とはどんな状態か KTCが所属するトヨタグループでは昔から「自働化」という概念が大切にされています。「動」ではなく「働」。機械が異常を検知したら自ら止まり、不良を後工程に流さない。問題を顕在化させ、人が原因を究明し対処できる状態をつくる。人を機械の番人にせず、本来人間にしかできない判断や改善に集中させる。自動化の中に「人の知恵」を埋め込む思想です。 ・・・とはいうものの、AIエージェントの時代における「ニンベンのついた自働化」とは、一体どんな状態でしょうか? 私はこう定義しています。 人間の役割が明確になっている エージェントが作業している間、人はより創造的・判断的な仕事に集中できている。たとえば、エージェントがログ分析をしている間に、人間は対応方針の意思決定に集中する、といった状態です。 エージェントの「持ち物」が事前に整っている 必要な権限、参照すべきデータ、判断基準 ― これらを人間が先回りして渡している。エージェントに手待ちをさせない環境設計です。 「やってはいけないこと」の境界線が設計されている 例えば「データの参照はOK、削除はNG」「提案はするが、最終承認は必ず人間」といったガードレールが明確に引かれている。 業務を知る人がフロー全体をデザインしている 技術者だけでは、業務の「行間」は読めません。何年・何十年と積み上げてきたドメイン知識を持つ人が、AIとの協業設計に参加している状態です。 この4つが揃ったとき、AIは「勝手に動く怖いもの」ではなく、「信頼して任せられるチームメイト」になる。それが「ニンベンのついた自働化」の姿だと考えています。 3. 進め方の指針 ― PoCを現場に届けるための3ステップ 「エージェント、作ってみたけど現場に浸透しない」 これは本当によく起きる現象です。理由はシンプルで、 技術的に動くものを作ることと、それが業務に根付くことは、まったく別の話 だからです。 私がエージェント開発の中で踏む3つのステップを紹介します。 ステップ1:課題を「正しく」見つける ここでの「正しく」とは、AIで解くべき課題かどうかを見極めるという意味です。 何年もかけて磨き上げられてきた課題解決の型は、道具が変わっても色褪せません。トヨタグループが大切にする問題解決のアプローチ ― 「現状把握」「真因追求」は、AI活用の文脈でもそのまま有効です。 ただし、一つ重要な判断軸が加わります。 「全てをAIでやろうとしない」 ということ。 たとえば、月に数回しか発生しない作業を自動化しても、構築・運用コストに見合わないことがあります。逆に、毎日30分かかる定型作業は、多少精度が荒くてもエージェント化する価値がある。費用対効果とスケール感を冷静に見極めることが、このステップの肝です。 ステップ2:試す・作り込む AIエージェントの構造は、実はシンプルです。大きく2つの要素で成り立っています。 プロンプト :エージェントへの「指示書」。あなたの役割はこれで、こういう手順で仕事をしてください、という設計図です。非エンジニアの方は「新人に渡す業務マニュアル」をイメージしていただくとわかりやすいかもしれません。 ツール :エージェントが使える「道具箱」。ウェブ検索、社内データの参照、計算、メール送信など、LLM単体では苦手なことを補う機能群です。 ・・・ただし、「シンプルな構造 = 簡単に完成する」ではありません。 プロンプトの書き方ひとつで、エージェントの振る舞いは劇的に変わります。ツールの選び方、渡すデータの粒度、エラー時のフォールバック設計。この作り込みの工程に、全体の工数の大半がかかると言っても過言ではありません。 ステップ3:業務フローに「組み込む」 ここが最も重要で、かつ最も見落とされやすいステップです。 完成したエージェントを業務フローのどこに置くか。誰が使うか。既存のツールとどう共存させるか。例外が起きたときに誰がフォローするか。 これらの問いに答えられるのは、 ドメインの知識を持つ人だけ です。 ここで言う「ドメイン知識」とは、特定の業務ノウハウだけを指しているわけではありません。業務フローを再設計するための価値判断基準、組織の意思決定経路や力学、そして現場の肌感覚 ― これらすべてを含む、長年の経験から培われた知の総体です。 たとえば自動車・モビリティの領域で考えると、その重要性がよくわかります。 現場の業務ノウハウ 整備士が持つ「この車種のこの年式は、ここが壊れやすい」という経験則。販売店の営業が持つ「この地域では◯月に需要が伸びる」という季節感覚。こうした知識は、個別業務に深く根ざしています。 価値判断と優先順位の基準 「納車までのリードタイムを短縮するよりも、お客様への中間報告の頻度を上げるほうが満足度に効く」「この検査工程は品質上絶対に省略できないが、書類作成の順序は変えられる」。業務フローを再設計するとき、何を守り何を変えてよいかを判断できるのは、その業務の「重み」を知っている人だけです。 組織の事情と意思決定の経路 「この変更はA部門だけでは通らない、B部門の部長の合意が要る」「この申請は制度上オンラインで完結するが、実質は事前の根回しが必要」。どんなに優れたエージェントを作っても、組織の中で動かせなければ意味がない。その道筋を知っているのも、ドメインの力です。 これらの知識は構造化されていません。業務マニュアルにも社内ドキュメントにも、ましてやLLMの学習データにも十分には載っていない。だからこそ、エージェントを開発する技術者だけでは業務フローの設計はできないし、業務を知る「人」が設計に参加する必要があるのです。 具体的な場面で言えば、「この申請は月末に集中するから、そのタイミングでエージェントが下書きを用意しておいてくれると助かる」「この承認フローは部長の口頭確認が実質必要だから、エージェントの自動承認は外したほうがいい」 ― こうした判断は、何年も現場で業務を回してきた人にしかできません。 だからこそ、ステップ3は技術者と業務担当者の「共同作業」になります。ここに「ニンベンのついた自働化」の真価があると考えています。 4. よくある落とし穴 ― 「動くけど根付かない」を避けるために セクション3で「 正しい進め方 」を紹介しましたが、現場では逆のパターン ― つまり、やってしまいがちな失敗 ― も数多く見てきました。エージェントが「技術的には動いているのに、業務に根付かない」とき、原因はたいてい次の3つのどれかに行き着きます。 落とし穴1:「全部AIで」と決めつけてしまう エージェントの可能性に惹かれるあまり、「AIに丸投げ」してしまうケースです。 一見すると大胆で魅力的に聞こえます。しかし、業務フローの中には「人の判断が入ることで価値が生まれている」工程が必ずあります。たとえば、クレーム対応における熟練オペレーターの声色の判断や、契約書レビューでのベテラン法務担当の「この条項は先方の意図と違う気がする」という直感。データ上は自動化できそうに見えても、その判断こそが顧客との信頼関係を支えている。こうした工程をAIに丸ごと置き換えると、効率は上がっても、守るべきものが静かに失われていきます。 ステップ1の「 AIで解くべき課題かどうかの見極め 」が甘いと、ここにはまります。 落とし穴2:ドメインエキスパート不在のまま業務フローを設計する エンジニアだけで「こう組み込めば効率的だろう」と業務フローを設計してしまうケース。技術的には合理的でも、現場の実態と噛み合わない、机上の空論で設計が進行してしまいます。 セクション3で挙げた「 組織の事情と意思決定の経路 」。これを知っているのは、何年もその業務を回してきた人だけです。エンジニアがどれほど優れていても、この層の知識は外から取得できません。 落とし穴3:「作って渡す」で終わりにしてしまう 「エージェント、完成しました。マニュアルも書きました。あとはよろしくお願いします」。 この引き渡し方は、ほぼ確実に定着しません。エージェントは従来のシステムとは違い、使い方や問いかけ方によって振る舞いが変わります。現場の人が「こう聞けばこう返る」という感覚を掴むまでには、作った人と一緒に使ってみる期間が要ります。 もうひとつ見落とされがちなのが、 UI/UXの設計 です。エージェントと聞くと、つい「チャットUI」を思い浮かべがちですが、チャットはあくまで暫定的なインターフェースにすぎません。現場の人が本当に求めているのは「チャットで何でも聞ける」体験ではなく、「いつもの業務の流れの中で、自然にAIの力が効いている」体験です。それはボタンひとつで起動するワークフローかもしれないし、既存ツールの中に溶け込んだ提案機能かもしれない。チャットUIで得たフィードバックを手がかりに、ユーザーが本当に求める体験を作り込んでいく ― この工程を「渡して終わり」にすると、永遠にチャットの域を出られません。 使っていく中で「ここはもう少しこうしてほしい」というフィードバックが生まれる。そのフィードバックをその場で反映できる ― この即応性が、エージェントが業務に馴染むかどうかの分岐点になります。 これらの落とし穴に共通するのは、 技術と業務の間に「翻訳者」がいない ということです。 エージェントにせよ何にせよ、 使ってもらってなんぼ です。どれだけ精緻に作り込んでも、現場で使われなければ価値はありません。そして「使われる」ためには、技術的な完成度よりも、業務への馴染み方のほうがはるかに重要です。エンジニアとドメインエキスパートが同じ机で一緒に考える体制さえあれば、これらの失敗の多くは防げます。 次のセクションでは、その「一緒に考える」を実現するための協業モデルについてお話しします。 5. 今後の展望 ― Forward Deployed Engineer(FDE)という協業の形 最後に、「ニンベンのついた自働化」を現場に届けるための、IT企業との新しい協業モデルについてお話しします。 Forward Deployed Engineer(FDE) とは、エンジニア自身が顧客の現場に入り込み、課題のヒアリングから実装・運用定着まで一気通貫で担う職種です。 起源は米国の Palantir Technologies が確立した FDSE(Forward Deployed Software Engineer) とされています。名前の由来は軍事用語の「Forward Deployed(前線展開)」で、「製品を納品するだけでは使われない、エンジニアが現場に入って初めて価値が生まれる」という哲学から生まれました。 従来のIT企業では、エンジニアは社内でシステムを開発し、営業・PM・カスタマーサクセスを介して顧客と接するのが一般的です。FDEはこの構造を変え、エンジニアが顧客と直接対話しながら、要件定義・実装・定着支援までをすべて担います。コンサルタントと異なるのは「自ら手を動かす」点です。 具体的には、 作れるエンジニア自身が、課題を持っている現場に直接入り込んで、一緒に考える 。プロトタイプを一緒に触りながら、同じ机で議論する。セクション4で挙げた 「作って渡す」で終わりにしてしまう という落とし穴の裏返しとも言えます。作って渡すのではなく、作りながら一緒に使う。その距離感が、エージェントの定着を左右します。 役割 担うこと FDE (IT側) 技術的な複雑さを引き受ける。AIの限界と可能性を正直に伝える。「これはできます、これは今は難しいです」を明確にする。 ドメインエキスパート (業務側) 業務の文脈を提供する。「このデータならここから取れる」「この件は誰に聞けばいい」「この申請は私が通します」という現場の力を発揮する。 この2つが掛け合わさったとき、初めて「ニンベンのついた自働化」が現場に根付く。私はそう信じています。 KTCは、この「FDEとドメインエキスパートの共創」を、自分たちの現場で実践し続けていきます。困りごとを見つけ、試し、形にして、届ける。そのサイクルの中で得た知見を、こうした場で発信していくことが、私にできる貢献のひとつだと考えています。 ここまで読んでいただき、ありがとうございました! 「AIエージェント」という言葉が少し身近になり、「うちの現場でも何かできそうだな」と感じていただけたなら、この記事を書いた甲斐があります。 ぜひ一緒に、「ニンベンのついた自働化」を実装していきましょう。
はじめに Webアプリケーションの回帰テストを自動化する際、適切なツールの選択は品質保証とチームの生産性に大きく影響します。 プロジェクト背景 KINTOテクノロジーズ(以下、KTC)では、これまでAutify NoCodeWebを活用して回帰テストの自動化を進め、品質保証体制を構築してきました。Autify NoCodeWebのノーコードプラットフォームは、QA専任メンバーが中心となってテスト自動化を迅速に導入する上で非常に有効であり、多くの成果を上げてきました。 しかし、プロジェクトの成長に伴い、新たな課題も見えてきました: より高速なテスト実行が求められるようになった CSVファイルの編集・アップロードなど、複雑なファイル操作を伴うテストシナリオの増加 データ駆動テストによる大量のテストパターンの実行ニーズ エンジニアチームの拡大により、コードベースのテスト資産の管理が可能になった このような背景から、現在のKTCの体制と要件に最適なツールを再検討する必要が生じました。本記事では、これまでお世話になってきたAutify NoCodeWebと、新たな選択肢としてのPlaywrightを、実際の回帰テストシナリオにおいて詳細に比較します。 どちらのツールも優れた特徴を持っており、組織の状況によって最適な選択は異なります。本記事が、皆様のツール選定の一助となれば幸いです。 ツール概要 Playwright 開発元: Microsoft タイプ: オープンソースのE2Eテストフレームワーク 対応言語: JavaScript/TypeScript、Python、.NET、Java 対応ブラウザ: PC:Chromium(Chrome、Edge)、Firefox、WebKit(Safari相当) モバイル:デバイスエミュレーション(Chromium、WebKit)  ※実機のモバイルブラウザ操作は非対応 特徴: コードベースで柔軟性が高く、高速な実行速度 Autify NoCodeWeb 開発元: オーティファイ株式会社(日本企業) タイプ: ノーコードAI搭載テスト自動化プラットフォーム 対応ブラウザ: PC:Chrome、Edge、Firefox、Safari(WebKit) モバイル:iOS、Android 特徴: 操作をレコーディングしてテストシナリオを作成、AI による要素認識と自動修復機能 ツール選択のためのデシジョンフローチャート 自社に最適なツールを選ぶ際の判断フローを視覚化しました。このフローチャートを参考に、組織の状況に応じた選択を行ってください。 graph TD Start[QAチームにプログラミング可能なエンジニアがいる] Start -->|No| AutifyNoCodeWeb1[Autify NoCodeWeb: ノーコードで容易、迅速な導入、AI自動修復] Start -->|Yes| Speed{実行速度を重視?} Speed -->|Yes| Playwright1[Playwright: 高速、柔軟、無料] Speed -->|No| Requirements{要件に応じて選択} Requirements -->|インフラ管理は避けたい| AutifyNoCodeWeb2[Autify NoCodeWeb] Requirements -->|メール連携や頻繁なUI変更がある| AutifyNoCodeWeb2 Requirements -->|コストを優先したい| Playwright2[Playwright] Requirements -->|データ駆動テストや複雑なファイル操作がある| Playwright2 フローチャートの使い方 このデシジョンフローは、以下の優先順位で判断することを推奨しています: チーム構成の確認: まず、開発チームにプログラミング可能なエンジニアがいるかを確認します。エンジニアリソースが限られている場合は、Autify NoCodeWebが最適な選択となります。 実行速度の重視度: エンジニアがいる場合、次に実行速度の重要性を評価します。CI/CDパイプラインでの高速フィードバックが重要な場合、Playwrightが適しています。 詳細要件の評価: 実行速度がそれほど重要でない場合は、具体的なテスト要件に基づいて判断します: graph LR C1[インフラ管理は避けたい] --> AutifyNoCodeWeb[Autify NoCodeWeb] C2[メール連携や頻繁なUI変更がある] --> AutifyNoCodeWeb[Autify NoCodeWeb] C3[コストを優先したい] --> Playwright C4[データ駆動テストや複雑なファイル操作がある] --> Playwright ハイブリッドアプローチの検討: 上記の要件が混在している場合、両ツールを併用するハイブリッドアプローチも有効な選択肢です。 機能別詳細比較 # 比較項目 Playwright Autify NoCodeWeb 1 CSVの編集とアップロード ✅ 可能 ⚠️ 制限あり 2 特定ファイルのダウンロード ✅ 可能 ⚠️ 検証に制限 3 特定ステップのスクリーンショット ✅ 柔軟なカスタマイ즈可能 ✅ 自動取得で便利 4 画面上の文字状態の判断 ✅ 詳細な検証可能 ✅ AI認識で安定 5 データ駆動テストの循環使用 ✅ 可能 ⚠️ 制限あり 6 異なる画面間の切り替え ✅ 完全対応 ✅ 対応 7 外部メール内容の確認 ✅ API連携で対応可能 ✅ 統合機能で便利 8 動的要素のロケート ✅ 高精度な制御 ✅ 高精度な制御 / JS指定 9 画面の比較(VRT) ✅ ピクセル単位の精密比較 ✅ AI支援で大規模変更に対応 10 スクリプトの実装難易度 ⚠️ プログラミングスキル必要 ✅ ノーコードで容易 11 スクリプトの修正難易度 ✅ テキスト編集で迅速 ⚠️ GUI操作が必要 12 スクリプトの実行速度 ✅ 基準速度 (高速) ⚠️ 比較的遅い傾向 1. CSVの編集とアップロード Playwrightの場合: input[type="file"] 要素に対して setInputFiles() メソッドを使用することで、CSVファイルのアップロードが柔軟に実装できます。また、ファイルの動的生成やデータ駆動テストとの組み合わせも可能です。コードベースの利点を活かし、複雑なファイル操作シナリオに対応できます。 Autify NoCodeWebの場合: 基本的なファイルアップロード機能は提供されていますが、複雑なCSV編集を伴うシナリオには制約があります。シンプルなファイルアップロードであれば、ノーコードで簡単に実装できる点は大きなメリットです 2. 特定ファイルのWebページからのダウンロード Playwrightの場合: page.waitForEvent('download') を使用してダウンロードイベントを捕捉し、ファイル名や内容の検証まで完全に制御できます。ダウンロードしたファイルの内容を自動的に検証するシナリオも実装可能です Autify NoCodeWebの場合: ダウンロード操作の記録と実行は可能です。基本的なダウンロード動作の確認には十分対応しており、ノーコードで実装できる利点があります。より詳細なファイル検証が必要な場合は、他の手段との組み合わせを検討する必要があります。 3. 特定ステップのスクリーンショット Playwrightの場合: page.screenshot() や locator.screenshot() を使用して、任意のタイミングで全画面または特定要素のスクリーンショットを取得できます。保存先やファイル名も自由に設定可能で、細かい制御が必要な場合に優れています Autify NoCodeWebの場合: 全てのテストステップで自動的にスクリーンショットが撮影されるため、設定の手間が不要です。テスト失敗時の原因調査が容易になり、特にテスト自動化に不慣れなメンバーでも、確実に証跡を残せる点が優れています。 4. 画面上の文字状態の判断 Playwrightの場合: expect(locator).toHaveText() 、 toContainText() 、 toBeVisible() など、豊富なアサーションメソッドで文字列の存在、内容、表示状態を詳細に検証できます。正規表現による柔軟なパターンマッチングも可能で、複雑な検証ロジックに対応できます。 Autify NoCodeWebの場合: テキストの存在確認や表示状態の検証が可能です。特にAIによる要素認識により、画面デザインが変更されても同じテキスト要素を識別できる点が優れています。HTMLの細かい変更に強く、メンテナンスコストを削減できます 5. データ駆動テストの循環使用 Playwrightの場合: テストデータを配列やCSVファイルから読み込み、 test.describe() やforループを使用して複数のデータセットで同じテストロジックを実行できます。テストの再利用性が非常に高く、大量のテストパターンを効率的に実行できます。 // CSVファイルからデータを読み込む testData = await readCSV('C:\\××××××××\\testData4.csv'); for (const data of testData) { const { password, surname, katakanaSurname, yearOfBirth, monthOfBirth, dayOfBirth, sex, postCode1, postCode2, cellphoneNumber1, cellphoneNumber2, cellphoneNumber3, typeOfHousing, yearsOfResidence, numberOfPeople1, numberOfPeople2, annualIncome, purposeOfUser, licenseNumber, route, fileName, profession, corporateName, positionOfCorporateName, nameOfCorporate, katakanaNameOfCorporate, department, postCodeOfCorporate1, postCodeOfCorporate2, cellphoneNumberOfCorporate1, cellphoneNumberOfCorporate2, cellphoneNumberOfCorporate3, lengthOfWork } = data; Autify NoCodeWebの場合: 個別のテストシナリオを作成することで、複数のパターンに対応できます。ノーコードで各シナリオを管理できるため、プログラミングの知識がなくても運用可能な点がメリットです。ただし、データ量が多い場合はシナリオ数が増加します。 6. 異なる画面間の切り替え Playwrightの場合: 複数タブ、複数ウィンドウ、iframe間の切り替えを完全にサポートしています。 page.context().pages() で全ページを取得したり、 page.waitForEvent('popup') で新しいページを待機することができます。複雑な画面遷移ロジックも実装可能です。 Autify NoCodeWebの場合: 画面遷移やタブ切り替えの操作を記録・実行できます。基本的な画面間の移動には十分対応しており、ノーコードで実装できる利点があります。 7. 外部メール内容の確認(URLのクリックなど) Playwrightの場合: メールテストAPIサービス(例:MailSlurp、Mailinator)と連携してメール内容を取得し、URLを抽出してナビゲーションすることが可能です。柔軟な連携が可能ですが、追加の実装とAPI費用が必要になる場合があります。 Autify NoCodeWebの場合: メール検証機能がプラットフォームに組み込まれており、追加の設定や実装なしでメール内のリンクをクリックしたり、内容を確認したりできます。この統合機能は大きな強みであり、特に非エンジニアのQAメンバーでも簡単に利用できる点が優れています。 8. XPathなどによる頻繁に変動する要素のロケート Playwrightの場合: CSS Selector、XPath、text、roleなど、多様なロケーター戦略をサポートしています。複数のロケーターを組み合わせたり、厳密な条件指定が可能で、動的要素に対しても高い精度で特定できます。 # ENT番号取得 xpath1 = '//*[@id="app"]/div/main/div/div[1]/div[1]/div[2]/div/div[3]/div[1]' display_text1 = (await page.locator(f'xpath={xpath1}').text_content() or '').strip() last1 = display_text1[-5:] shinsa_number = '97016QAP00' + last1 # メールアドレス取得 xpath2 = '//*[@id="app"]/div/main/div/div[1]/div[1]/div[2]/div/div[7]/div[2]' display_text2 = (await page.locator(f'xpath={xpath2}').text_content() or '').strip() # 名前取得 xpath0 = '//*[@id="app"]/div/main/div/div[1]/div[1]/div[2]/div/div[7]/div[1]/a' display_text0 = (await page.locator(f'xpath={xpath0}').text_content() or '').strip() lastname = display_text0[4:] Autify NoCodeWebの場合: AIによる要素認識を採用しており、HTMLが変更されても要素を識別しようとします。この機能は画面の小規模な変更に対して非常に強く、手動でのメンテナンスを大幅に削減できます。特にデザイン調整が頻繁に行われる開発フェーズでは、この自動修復機能が大きな価値を発揮します。複雑な動的要素については、認識精度を確認しながら運用することが推奨されます。 そして、Javascriptによって要素の指定も簡単にできます。 function getEmailInputValue() { var selector = "#__next > main > div > div > div.o-emailPasswordForm > div > div.m-inputField > div:nth-child(1) > div > div.m-inputField__container > div > div > input[type=email]"; var element = document.querySelector(selector); if (!element) { throw new Error("Error: cannot find the element with selector(" + selector + ")."); } return element.value; } // 実行例 console.log(getEmailInputValue()); 9. 画面の比較(ビジュアルリグレッションテスト) Playwrightの場合: toHaveScreenshot() メソッドでピクセルレベルの画面比較が可能です。差分の許容範囲を設定したり、特定領域をマスクしたりできます。細かい視覚的変更の検出に優れており、意図しないUI変更を確実に捉えます Autify NoCodeWebの場合: 画面全体の変更を検出し、AIが変更箇所を識別します。特に大規模なデザイン変更時には、変更点の確認とテストシナリオの更新が比較的容易です。AIによる変更の影響分析機能により、どのテストシナリオを更新すべきかの判断がしやすく、大規模リニューアル時のメンテナンス工数を削減できる点が優れています。 10. スクリプトの実装難易度 Playwrightの場合: JavaScript/TypeScriptなどのプログラミング言語とテストフレームワークの知識が必要です。習得には一定の時間がかかりますが、公式ドキュメントが充実しており、コミュニティも活発です。エンジニアチームが確立されている組織に適しています。 Autify NoCodeWebの場合: ブラウザ操作を記録するだけでテストシナリオが作成できるため、プログラミング経験がない非エンジニアでも容易に使用できます。この実装の容易さは、Autify NoCodeWebの最大の強みの一つです。QA専任メンバーが主体となってテスト自動化を推進できるため、エンジニアリソースが限られている組織や、迅速にテスト自動化を開始したい場合に特に有効です。 11. スクリプトの修正難易度 Playwrightの場合: テキストエディタでスクリプトを直接編集できるため、小規模な修正は数秒で完了します。バージョン管理システム(Git)との親和性も高く、差分確認やロールバックが容易です。複数人での並行開発やコードレビュー文化とも相性が良いです。 Autify NoCodeWebの場合: GUI上で操作を再記録するか、手動で修正する必要があります。ただし、AIによる自動修復機能により、画面の小規模な変更には自動的に対応されるため、実際の修正作業は最小限に抑えられます。この自動修復機能は、メンテナンスコストの削減に大きく貢献します。 12. スクリプトの実行速度 Playwrightの場合: ヘッドレスモードでの実行やネットワークリクエストの最適化により、非常に高速なテスト実行が可能です。並列実行にも標準対応しており、大規模なテストスイートでも短時間で完了します。 Autify NoCodeWebの場合: クラウドベースのプラットフォームであり、ネットワークレイテンシーや処理のオーバーヘッドにより、Playwrightと比較して実行速度が遅くなる傾向があります。ただし、実際の速度差はテストケースの複雑さ、ネットワーク環境、Autify NoCodeWebのサーバー負荷などの要因によって大きく変動する可能性があります。大規模なテストスイートでは実行時間が増加する可能性がありますが、並列実行機能を活用することで全体の実行時間を最適化できます。 :::message 実行速度は環境やテストケースによって大きく異なるため、具体的な数値比較は控えます。各ツールの特性を理解し、実際の使用環境でのパフォーマンスを評価することをお勧めします。 ::: 追加の比較ポイント 📊 要約比較表 項目 Playwright (エンジニア主導) Autify NoCodeWeb (QA・非エンジニア主導) コスト 完全無料 (OSS) サブスクリプション型 (有料) 導入障壁 プログラミングスキルが必要 低い (ノーコードで即時開始) CI/CD 柔軟かつ強力な統合 シンプルなAPI連携 メンテナンス コードベース・Git管理 AIによる自動修復 (Self-healing) 1. コストと導入障壁 Playwright: 完全無料のオープンソース 学習コストは必要だが、長期的なランニングコストはゼロ CI/CD環境への組み込みも容易 エンジニアチームの人件費は考慮が必要 Autify NoCodeWeb: サブスクリプション型の有料サービス 初期導入が簡単で、迅速にテスト自動化を開始できる テストシナリオ数や実行回数に応じた費用体系 インフラ管理コストが不要 エンジニアリソースが限られている場合、トータルコストで優位性がある場合も 2.チーム構成との適合性 Playwrightが適しているチーム: エンジニア主導のQA体制が整っている コードレビュー文化が定着している 複雑なテストロジックや高度なカスタマイズが必要 Git等のバージョン管理システムを活用している Autify NoCodeWebが適しているチーム: QA専任メンバーが中心(プログラミング経験が少ない) エンジニアリソースが限られている 迅速にテスト自動化を開始したい メンテナンスコストを抑えたい(AIによる自動修復活用) ノーコードでテスト資産を管理したい 3. CI/CD統合 Playwright: GitHub Actions、GitLab CI、Jenkins など主要CI/CDツールとの統合が容易 テスト結果のレポート生成、アーティファクト保存が柔軟 並列実行、シャーディングなど高度な実行戦略が可能 開発フローに深く統合できる Autify NoCodeWeb: APIを介したCI/CD統合が可能 独自のテスト実行環境を使用 クラウドベースのため、インフラ管理不要 CI/CD統合の設定がシンプル 4.メンテナンス性と長期運用 Playwright: スクリプトをバージョン管理できる リファクタリングやスクリプトの再利用が容易 コミュニティが活発で、最新のベストプラクティスにアクセスしやすい 長期的なスクリプト資産の管理に優れる Autify NoCodeWeb: AIによる要素の自動認識で、画面変更時のメンテナンス工数を削減 自動修復機能により、軽微な変更への対応が自動化される プラットフォーム上での一元管理が可能 ノーコードのため、担当者の変更による影響が少ない それぞれのツールが特に優れているシーン Playwrightが最適なケース 大量のデータパターンテスト: 同一ロジックで数百〜数千パターンのテストデータを処理する必要がある場合 高頻度の実行: CI/CDパイプラインで1日に何度もテストを実行し、迅速なフィードバックが必要な場合 複雑なファイル操作: CSV編集、複数ファイルの同時アップロード、ダウンロードファイルの内容検証など エンジニア主導のQA: 開発チームとQAチームが密接に連携し、テストスクリプトもコードレビューの対象とする場合 長期的な資産管理: テストスクリプトをソースコードと同様に管理し、継続的に改善していく場合 Autify NoCodeWebが最適なケース 迅速な導入: プログラミング経験のないQAメンバーが、短期間でテスト自動化を開始したい場合 メール連携テスト: 外部メールの検証を含むシナリオが多い場合 頻繁なUI変更: デザイン調整が頻繁に行われる環境で、AI自動修復機能を活用したい場合 インフラ管理の負担軽減: テスト実行環境の構築・管理リソースが限られている場合 ノーコード資産管理: テスト資産をコード化せず、ビジュアルに管理したい場合 大規模リニューアル: 画面全体の大幅な変更時に、AIによる影響分析と効率的な更新が必要な場合 KTCにおける選択理由 KTCでは、これまでAutify NoCodeWebによって品質保証の基盤を築いてきましたが、プロジェクトの成長と共にいろいろな課題が顕在化しました。(上記のプロジェクト背景で述べた課題) これら課題を解決する選択肢として、Playwrightを導入することにしました。ただし、これはAutify NoCodeWebを完全に置き換えるものではありません: Playwrightが担う領域: データ駆動テスト、高速実行が求められるCI/CD統合、複雑なファイル操作を伴うシナリオ Autify NoCodeWebが引き続き価値を発揮する領域: メール連携テスト、ノーコードで管理すべきシナリオ、QA専任メンバーが主導するテスト 両ツールの強みを活かしたハイブリッドアプローチにより、KTCの品質保証体制をさらに強化していく予定です。 まとめ:どちらを選ぶべきか Playwrightの主な強み: 高速な実行速度 柔軟なカスタマイズ性 精密な要素制御とデータ駆動テスト オープンソースでコストゼロ バージョン管理システムとの親和性 Autify NoCodeWebの主な強み: ノーコードで実装が容易 AIによる自動修復でメンテナンスコスト削減 統合されたメール検証機能 非エンジニアでも運用可能 インフラ管理不要 最適な選択は、組織の状況によって異なります: エンジニアリソースが限られ、迅速にテスト自動化を開始したい → Autify NoCodeWeb エンジニアチームが確立され、高度なカスタマイズと高速実行が必要 → Playwright 両方のメリットを活かしたい → ハイブリッドアプローチ どちらのツールも、現代のWebアプリケーション開発において品質を担保するための重要な選択肢です。本記事の比較内容を参考に、自社のチーム構成、スキルセット、プロジェクト要件、予算、長期的な運用計画などを総合的に考慮して、最適なツールを選定してください。 Autifyは世界中で支持されているノーコードテスト自動化プラットフォームであり、特にエンジニアリソースが限られている組織において、品質保証体制を迅速に構築できる優れたソリューションです。KTCも、Autifyのサービスを通じて多くの成果を上げてきました。 今後も、両ツールの進化に注目し、それぞれの強みを最大限に活用していくことが重要です。