<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>KINTO Tech Blog | キントテックブログ</title>
        <link>https://blog.kinto-technologies.com/</link>
        <description>年齢・性別・国籍問わず多様なメンバーが、トヨタグループのモビリティサービスの世界展開を実現する技術集団として様々な情報を発信します !</description>
        <lastBuildDate>Tue, 21 Apr 2026 03:16:37 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>KINTO Tech Blog | キントテックブログ</title>
            <url>https://blog.kinto-technologies.com/assets/common/thumbnail_default.png</url>
            <link>https://blog.kinto-technologies.com/</link>
        </image>
        <copyright>©KINTO Technologies Corporation. All rights reserved.</copyright>
        <item>
            <title><![CDATA[Android で秘匿情報を守る — Keystore, Cipher, DataStore による暗号化と永続化の実装例]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-04-17-keystore-cipher-datastore-encryption/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-04-17-keystore-cipher-datastore-encryption/</guid>
            <pubDate>Fri, 17 Apr 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細と注意点をまとめました]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2>
<p>KINTOテクノロジーズの大沼です。
モビリティサービス「my route」アプリの開発に従事しています。</p>
<p>本記事では、AndroidのKeystore、Cipher、DataStoreを使用して秘匿情報の暗号化と永続化を実装した際の実装詳細とハマった点・注意点をまとめました。</p>
<p><a href="https://blog.kinto-technologies.com/posts/2025-06-16-encrypted-shared-preferences-migration/">こちら大杉さんの記事</a> では、Tink を使用したケースとパフォーマンス検証を紹介しているのでぜひご一読ください。</p>
<h2>💬 実装の前にディスカッション</h2>
<h3>🔍 本当に暗号化が必要なのか</h3>
<p><a href="https://www.youtube.com/watch?v=6ISJv6G5dBg">DroidKaigi 2025のyanzamさんのお話</a> でも触れられてましたが、Keystore がクラッシュするのは黙認しつつ最低限の機会頻度に抑えたいので、既存で暗号化しているデータが本当に暗号化する必要があるのかの議論をチームメンバーと交わしました。
案の定、不要に暗号化しているものもあり、議論することで最適なものに絞ることができました。</p>
<h3>🏗️ アーキテクチャ</h3>
<p>セキュリティに関するリファクタリングのコードレビューは、心理負荷が高いと考えています。
私のチームはこういう時、大枠の実装方針を事前に共有し合うことで、コードレビュー時の認識違いや負担が減らせます。</p>
<p>今回、データの暗号化とインターフェースを以下のようなスライドで共有し、大きな齟齬なくレビューを進めることができました。</p>
<p><img src="/assets/blog/authors/numami/numa_memo%20-%20%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%81%E3%83%A3.jpg" alt="numa_memo - システムアーキテクチャ.jpg">
<img src="/assets/blog/authors/numami/numa_memo%20-%20%E3%82%A8%E3%83%A9%E3%83%BC%E3%83%8F%E3%83%B3%E3%83%89%E3%83%AA%E3%83%B3%E3%82%B0.jpg" alt="numa_memo - エラーハンドリング.jpg"></p>
<h2>🛠️ 実装の流れ</h2>
<p>ここからは、実際の実装手順を以下の流れで解説します。</p>
<ol>
<li><strong>依存関係の追加</strong> — DataStoreライブラリの導入</li>
<li><strong>Keystoreを使った暗号化キーの生成</strong> — AES/GCMの鍵をAndroid Keystoreで安全に管理</li>
<li><strong>Cipherを使った暗号化・復号化</strong> — 初期化ベクトル(IV)の扱いを含む暗号処理の実装</li>
<li><strong>DataStoreへの保存</strong> — 暗号化したデータをPreferences DataStoreで永続化し、Flowで読み出す</li>
</ol>
<h3>📚 依存関係の追加</h3>
<p>ライブラリにDataStoreを追加します。</p>
<pre><code class="language-gradle:build.gradle.kts">dependencies {
    // DataStore
    implementation(&quot;androidx.datastore:datastore-preferences:1.1.7&quot;)
}
</code></pre>
<h3>🔑 Keystoreを使った暗号化キーの生成</h3>
<pre><code class="language-kotlin">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
        }
}
</code></pre>
<h3>🔐 Cipherを使った暗号化・復号化</h3>
<pre><code class="language-kotlin">
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 = &quot;AES/GCM/NoPadding&quot;
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)
                &quot;&quot;
            }
        }
    }

    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)
                &quot;&quot;
            }
        }
    }
}
</code></pre>
<h3>💾 DataStoreへの保存</h3>
<pre><code class="language-kotlin">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(&quot;encrypted_text&quot;)
}

private val Context.dataStore: DataStore&lt;Preferences&gt; by preferencesDataStore(name = &quot;encrypted_prefs&quot;)

class SecureDataRepository(
    private val cryptographyManager: CryptographyManager
) {

    suspend fun saveTextData(data: String) {
        val encryptedData = cryptographyManager.encrypt(data)
        dataStore.edit { preferences -&gt;
            preferences[TEXT_KEY] = encryptedData
        }
    }
    
    private val secureDataFlow: Flow&lt;SecureDataPreferences&gt; =
        secureDataStore.data
            .catch { exception -&gt;
                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) } ?: &quot;&quot;,
            // ... Other data
        )
    }

    suspend fun getTextData(): String {
        return try {
            withTimeout(3000L) {
                secureDataFlow.map { it.textData }.first { it.isNotBlank() }
            }
        } catch (_: TimeoutCancellationException) {
            &quot;&quot;
        } catch (_: NoSuchElementException) {
            &quot;&quot;
        }
    }
}
</code></pre>
<h2>⚠️ ハマった点・注意点</h2>
<h3>1. 初期化ベクトル(IV)の保存</h3>
<p>暗号化時に生成されるIV（Initialization Vector）は、復号化時に必須です。
IVは暗号文と一緒に保存する必要があります。IVは秘密情報ではないため、平文で保存しても問題ありません。</p>
<p><strong>ハマったポイント:</strong> 最初の実装でIVを保存し忘れ、復号化時に<code>javax.crypto.AEADBadTagException</code>が発生しました。</p>
<h3>2. KeyStoreのキーのライフサイクル</h3>
<p>Android Keystoreに保存されたキーは、アプリをアンインストールすると削除されます。
また、デバイスのロック画面が解除されるまでキーにアクセスできない設定も可能です（<code>setUserAuthenticationRequired(true)</code>）。</p>
<p><strong>注意点:</strong> keyが存在しない場合の処理を適切に実装する必要があります。</p>
<h3>3. GCMモードのタグ長</h3>
<p>GCM（Galois/Counter Mode）を使用する場合、タグ長を正しく設定する必要があります。
一般的には128ビット（16バイト）が使用されます。</p>
<h3>4. エラーハンドリング</h3>
<p>復号化時にはさまざまなエラーが発生する可能性があります:</p>
<ul>
<li><code>KeyPermanentlyInvalidatedException</code>: キーが無効化された</li>
<li><code>AEADBadTagException</code>: 暗号文が改ざんされた、またはIVが間違っている</li>
<li><code>InvalidKeyException</code>: キーが無効</li>
</ul>
<p>これらのエラーをハンドリングし、必要に応じてデータをクリアし再生成するなどの対応が必要です。</p>
<h3>5. DataStoreの非同期処理</h3>
<p>DataStoreはすべての操作が非同期で行われます。
CoroutineまたはFlowを使用して適切に処理する必要があります。</p>
<p>DataStoreのソースコード内で、最新の値を1つだけ取得できる data.first() を使用することを推奨しています。</p>
<pre><code class="language-kotlin">// ViewModelでの使用例
viewModelScope.launch {
    repository.saveTextData(sensitiveData)
}

// Flowの監視
repository.secureDataFlow.map { it.textData }.first { it.isNotBlank() }
</code></pre>
<h3>6. 無限待機の防止</h3>
<p>DataStoreはディスクI/Oを伴う非同期処理です。first { it.isNotBlank() } は条件に一致する値が来るまで無限に待機しますが、
もしディスク読み込みが遅延したり、トークンが空のままだと、アプリがフリーズする可能性があり、タイムアウトを追加しました。</p>
<h3>7. ProGuard/R8の設定</h3>
<p>DataStore 1.1.5以降では、ProGuardルールがライブラリに内包されています。
巷の記事で ProGuardルール の記載が必要なことを目の当たりにしましたが、ルール記載なくリリースビルドしたところクラッシュせず、なぜ?
となっていたところ、リリースノート確認し気づきました。
今回、1.1.7 を使用しているため、DataStore専用のProGuardルールを追加する必要はありません。</p>
<p><a href="https://developer.android.com/jetpack/androidx/releases/datastore">https://developer.android.com/jetpack/androidx/releases/datastore</a></p>
<ul>
<li>バージョン1.2.0-beta01で修正された問題として記載：
&quot;Fix java.lang.UnsatisfiedLinkError when using DataStore in an app which is optimized with
R8&quot;</li>
<li>バージョン1.1.5で修正：
&quot;missing Proguard rules issue in the Android artifact of datastore-preferences-core&quot;</li>
</ul>
<h3>8. 標準のSharedPreferencesMigrationが使えない</h3>
<p>EncryptedSharedPreferencesは特殊な暗号化を使用していて、 標準のマイグレーションでは暗号化されたままのデータが転送される</p>
<p>また、EncryptedSharedPreferencesは 読み取り時に自動復号化されるのに対し、 今回は CryptographyManagerによる手動暗号化が必要です。
この部分を認知することができず、標準の標準のSharedPreferencesMigrationで実装し、テストしたところ復号できず判明しました。
マイグレーション時に適切な暗号化変換を実装しました。</p>
<h2>まとめ</h2>
<p>本記事では、Android Keystore、Cipher、DataStoreを組み合わせた秘匿情報の暗号化・永続化の実装について紹介しました。</p>
<ul>
<li><strong>実装前のディスカッションが重要</strong>: そもそも暗号化が必要なデータかをチームで議論することで、Keystoreへのアクセス頻度を最小限に抑えられた</li>
<li><strong>Keystoreの鍵管理</strong>: AES/GCMモードでの鍵生成とIV(初期化ベクトル)の保存を適切に行う必要がある</li>
<li><strong>DataStoreとの組み合わせ</strong>: Flowベースの非同期読み出しに対応するため、タイムアウトや無限待機の防止策が必要</li>
<li><strong>EncryptedSharedPreferencesからの移行</strong>: 標準のSharedPreferencesMigrationでは暗号化方式の違いにより復号できないため、手動でのマイグレーション実装が必要</li>
</ul>
<p>Keystoreのクラッシュは完全には避けられませんが、暗号化対象を最適化し、エラーハンドリングを適切に実装することで、安定したセキュアなデータ管理を実現できました。</p>
<h2>📣 追記: DataStore 1.3.0-alpha07 で暗号化サポートが追加</h2>
<p>本記事の執筆後、<a href="https://developer.android.com/jetpack/androidx/releases/datastore?hl=ja#1.3.0-alpha07">DataStore 1.3.0-alpha07</a>（2026年3月11日リリース）で、<strong>Tinkライブラリを使用した暗号化サポート</strong>が新たに追加されました。</p>
<p>新しい <code>androidx.datastore:datastore-tink</code> アーティファクトにより、<code>AeadSerializer</code> を使って既存のシリアライザをラップするだけで暗号化が実現できます。</p>
<pre><code class="language-kotlin">val aeadSerializer = AeadSerializer(
    aead = keysetHandle.getPrimitive(
        RegistryConfiguration.get(),
        Aead::class.java,
    ),
    wrappedSerializer = ExistingSerializer,
    associatedData = &quot;settings.json&quot;.encodeToByteArray(),
)
</code></pre>
<p>本記事で紹介したKeystore + Cipher による手動実装と比較すると、Tink統合によりボイラープレートが大幅に削減されます。ただし、現時点ではalpha版であるため、プロダクション導入の際はAPIの安定性を考慮する必要があります。今後のstable版リリースに注目です。</p>
<h2>参考資料</h2>
<ul>
<li><a href="https://developer.android.com/training/articles/keystore">Android Keystore System</a></li>
<li><a href="https://developer.android.com/topic/libraries/architecture/datastore">Jetpack DataStore</a></li>
<li><a href="https://developer.android.com/topic/security/data">暗号化されたファイルの使用</a></li>
<li><a href="https://developer.android.com/topic/security/best-practices">Android セキュリティのベスト プラクティス</a></li>
<li><a href="https://developer.android.com/jetpack/androidx/releases/datastore?hl=ja#1.3.0-alpha07">DataStore 1.3.0-alpha07 リリースノート</a></li>
</ul>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/blog/authors/numami/keystore_cipher_datastore_cover.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[GitHub Actionsを意図せず大量実行させて社内CIを止めた話]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-23-github-actions-runaway/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-23-github-actions-runaway/</guid>
            <pubDate>Wed, 01 Apr 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[ちょっとした操作ミスが連鎖して、GitHub Actionsのワークフローを大量実行させてしまった失敗談]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2>
<p>KINTOテクノロジーズでインフラエンジニアをしているyassanです。</p>
<p>先日、GitHub Actionsのワークフローを意図せず大量に起動してしまい、<strong>社内のCI/CDパイプラインを約1時間にわたって止めてしまう</strong>という事故を起こしました。</p>
<p>この記事では、小さなミスがどう連鎖して大きな障害になったのか、そしてそこから何を学んだのかをお話しします。</p>
<h2>前提：コメント駆動のCI/CDパイプライン</h2>
<p>私たちのチームでは、Terraformのインフラコードを管理するリポジトリでGitHub Actionsを活用しています。</p>
<p>仕組みはシンプルで、PRにコメントを投稿すると、そのPRで変更されたディレクトリを検出して自動的に <code>terraform plan</code> を実行してくれるというものです。</p>
<p>ワークフローの概要を簡略化すると、以下のようなイメージです。</p>
<pre><code class="language-yaml">name: Terraform Plan

on:
  issue_comment:
    types: [created, edited]  # コメントの新規作成・編集時に発火

jobs:
  plan:
    # PRへのコメントで、本文にコマンド文字列を含む場合に実行
    if: |
      github.event.issue.pull_request
      &amp;&amp; contains(github.event.comment.body, &#39;/command&#39;)
    runs-on: ubuntu-latest
    steps:
      - name: PRの変更ディレクトリを検出
        # ...
      - name: 対象ディレクトリごとに terraform plan を実行
        # ...
      - name: 結果をPRにコメント
        # ...
</code></pre>
<p>通常であれば、PRの変更範囲は1〜2ディレクトリ程度。数分で完了する軽い処理です。</p>
<h2>やらかしの連鎖</h2>
<h3>火種：いつもの感覚でリベースしたら、対象が35ヶ所に膨れ上がった</h3>
<p>普段のPRは <code>main</code> ブランチに向けて作成しています。しかしこの日に限って、別の作業ブランチをベースにしたPRを作っていました。</p>
<p>ここで、いつもの癖で何も考えずにリベースを実行。すると、そのブランチにあった<strong>他のメンバーのコミット</strong>が差分に混入してしまいました。</p>
<p>本来1ディレクトリだったplanの対象が、一気に<strong>35ディレクトリ</strong>に膨れ上がりました。</p>
<h3>延焼：消火しようとしたらガソリンだった</h3>
<p>35ディレクトリ分のplanが走ってしまったことに気づき、「余計な結果コメントを非表示にして整理しよう」と考えました。</p>
<p>そこでGitHub APIを使って、不要な34件のコメントのうち20件を非表示（minimize）にしていきました。</p>
<p>その操作がワークフローのトリガーになるとも知らずに、非表示にするだけだと軽い気持ちで実施しました。</p>
<p>結果として、思いがけず20件 × 35ディレクトリ = <strong>約700回のワークフロー</strong>が一斉に走り出しました。</p>
<h3>種明かし：大量のトリガー</h3>
<p>GitHub APIの <code>minimizeComment</code> でコメントを非表示にすると、GitHub上では <strong>「コメントの編集」イベント</strong> として扱われます。ちなみに、Web UIから手動でhideした場合はこのイベントは発生しません。</p>
<p>そして、非表示にしたコメントの本文には、ワークフローのトリガーとなるコマンド文字列が含まれていました。</p>
<p>つまり、<strong>1件非表示にするたびに、35ディレクトリ分のplanが再び起動</strong>してしまう状況だったのです。</p>
<pre><code class="language-mermaid">graph TD
    A[結果コメントを非表示にする] --&gt;|editイベント発火| B[ワークフローがコメント本文を読む]
    B --&gt;|トリガー文字列を検出| C[35ディレクトリ分のplanが起動]
    C --&gt; D[結果コメントが投稿される]
    D --&gt;|さらに非表示にすると...| A
    style A fill:#ff6b6b,color:#fff
    style C fill:#ff6b6b,color:#fff
</code></pre>
<h3>誤判断：PRを閉じれば止まると思った</h3>
<p>約10分後、大量のワークフローが走っていることに気づきました。パニックになった私は「PRを閉じれば止まるはず」と考え、すぐにPRをクローズしました。</p>
<p>「これで大丈夫」と安心して、別の作業に戻りました。</p>
<h3>発覚：社内から悲鳴が上がる</h3>
<p>さらに約10分後。社内のチャットに「GitHub Actionsが動かない」「CIがずっとキュー待ちになっている」という報告が上がり始めました。</p>
<p>慌ててGitHubを確認すると、クローズしたはずのPRに<strong>まだ結果コメントが投稿され続けていました</strong>。</p>
<p>実は、PRをクローズしても <strong>実行中のワークフローはキャンセルされません</strong>。</p>
<p>それどころか、クローズされたPRに対してもコメントイベントは発火するため、PRクローズ自体にワークフローを止める効果はないのです。</p>
<p>これにより、共有ランナーの枠を食いつぶしてしまい、他チームのCIが動かなかったわけです。</p>
<p>私はすぐにGitHub Actionsの画面から、実行中のワークフローを手動で片っ端からキャンセル。ようやくキュー溜まりが解消し、社内のCI/CDが正常に戻りました。</p>
<p>あとから確認したところ、恐ろしいことに<strong>約3,000分（50時間相当）のActions実行時間を、わずか1時間の間に消費していた</strong>ことがわかりました。</p>
<h2>何が起きていたのか</h2>
<p>今回の事故は、4つのミスが連鎖して起きました。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>やったこと</th>
<th>何が起きたか</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>別ブランチベースのPRでリベース</td>
<td>他人のコミット混入で対象35ディレクトリに膨張</td>
</tr>
<tr>
<td>2</td>
<td>結果コメントを非表示にして整理</td>
<td>非表示=編集イベント → ワークフロー再起動 × 20回</td>
</tr>
<tr>
<td>3</td>
<td>PRをクローズして安心</td>
<td>起動済みワークフローは止まらない</td>
</tr>
<tr>
<td>4</td>
<td>20分間気づかず放置</td>
<td>社内CI/CDが1時間停止</td>
</tr>
</tbody></table>
<p>一つ一つは「ちょっとした判断ミス」や「仕様を知らなかった」程度のことですが、それが連鎖することで大きな障害になりました。</p>
<h2>ワークフロー変更による再発防止</h2>
<h3>1. トリガー条件の見直し</h3>
<p>ワークフローのトリガーから <code>edited</code>（編集）イベントを削除し、<code>created</code>（新規作成）のみに限定しました。これにより、コメントの編集や非表示でワークフローが起動することはなくなりました。</p>
<pre><code class="language-diff">on:
  issue_comment:
-    types: [created, edited]
+    types: [created]
</code></pre>
<h3>2. コマンド判定ロジックの厳格化</h3>
<p>コメント本文にコマンド文字列が「含まれているか」ではなく、「先頭から始まっているか」で判定するように変更しました。さらに、イベント種別の二重チェックも追加しています。</p>
<pre><code class="language-diff">jobs:
  run_plan:
    if: |
      github.event.issue.pull_request
+      &amp;&amp; github.event.action == &#39;created&#39;
-      &amp;&amp; contains(github.event.comment.body, &#39;/command&#39;)
+      &amp;&amp; startsWith(github.event.comment.body, &#39;/command&#39;)
</code></pre>
<h3>3. 同時実行の制御</h3>
<p><code>concurrency</code> グループを設定し、同一PRでのワークフローの並列実行を防止しました。後から起動したワークフローが、先行するものをキャンセルして最新のplanだけが実行されるようになっています。</p>
<pre><code class="language-yaml">concurrency:
  group: plan-${{ github.event.issue.number }}
  cancel-in-progress: true
</code></pre>
<h2>組織としての課題</h2>
<p>今回の事故で、ワークフロー単体の修正だけでは防ぎきれない課題も見えてきました。</p>
<ul>
<li>共有ランナーの同時実行数が急増しても気づく仕組みがない</li>
<li>ワークフローのトリガー設計に関する共通のガイドラインがない</li>
<li>暴走に気づいたとき、誰がどう止めるかの手順が整備されていない</li>
</ul>
<p>これを踏まえてコーポレートITグループと連携して以下による改善を進めていきたいと考えています。</p>
<ul>
<li>ランナー使用状況の監視強化（同時実行数がしきい値を超えた際の Slack アラート）</li>
<li>ARMランナーやハイスペックランナーへの切り替えによる処理効率の改善</li>
<li>ワークフロートリガー設定のベストプラクティス策定・既存ワークフローの一括監査</li>
</ul>
<h2>この経験から学んだこと</h2>
<p><strong>「止めたつもり」が一番怖い。</strong></p>
<p>PRを閉じればワークフローも止まると思い込んでいましたが、実際にはそうではありませんでした。慌てているときほど、思い込みで行動してしまいがちです。</p>
<p><strong>ワークフローのトリガー条件は、「最悪のケース」で考える。</strong></p>
<p>GitHub APIを使ったコメントの非表示は編集イベントとして扱われること、結果コメントの本文にトリガー文字列が含まれること。どちらも普段は問題にならない仕様ですが、組み合わさったときに暴走を引き起こしました。</p>
<p><strong>小さなミスは連鎖する。</strong></p>
<p>リベースのミス、コメント整理の操作、PRクローズへの過信、確認不足。どれか一つでも正しく対処できていれば、ここまでの事故にはなりませんでした。失敗が起きたとき、焦らずに「今何が動いているのか」を確認することが大事だと痛感しました。</p>
<h2>おわりに</h2>
<p>今回の事故は、自分の操作で社内の開発フローを止めてしまうという、なかなかにつらい経験でした。</p>
<p>ただ、この失敗をきっかけにワークフローのトリガー設計を見直し、同様の暴走が起きない仕組みに改善できました。外注開発なら責任問題になりかねない失敗も、内製開発なら改善のきっかけにできる。それがこの経験で得た一番の実感です。</p>
<p>この記事が、同じようなCI/CDの落とし穴を避けるための参考になれば幸いです。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/common/thumbnail_default_×2.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[1時間30分かかっていたデータ取り込み処理をたった5分で終わらせる技術〜ISUCONは役にたつ〜]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-31-improvement-importing-data-performance/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-31-improvement-importing-data-performance/</guid>
            <pubDate>Tue, 31 Mar 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[1時間30分かかっていたデータ取り込み処理をたった5分で終わらせるようにできました。ISUCONの知識は役に立ちます。]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2>
<p>こんにちは、KINTOテクノロジーズのFACTORY EC開発グループでバックエンドエンジニアをやっている、うえはら(<a href="https://x.com/penpen_77777">@penpen_77777</a>)です。
今回はWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトル「ISUCON」で得た知識を活用して、FACTORYでマスタデータ反映に1時間30分かかっていた処理をたった5分で終わらせるようにした方法についてご紹介します。</p>
<p>「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。
ISUCON is a trademark or registered trademark of LY Corporation.
<a href="https://isucon.net">https://isucon.net</a></p>
<h2>今回の課題</h2>
<p>FACTORYでは商品や車種などのマスタデータをExcelファイルに取りまとめ、
そのExcelファイルをもとに本番環境のDBにデータを反映しています(=マスタ反映)。</p>
<p>このマスタ反映に90分かかっており、マスタ運用作業のボトルネックになっていました。
例えば本番環境への反映の前に検証環境でマスタデータに問題ないかを確認しているのですが、
データの誤りに気づいて修正してもマスタ反映に90分かかるため、データが正しく直せたかどうかすぐに確認できない状況でした。</p>
<p>そこで、マスタ反映を高速化することで運用作業の効率化を図ることにしました。</p>
<h2>マスタデータ反映</h2>
<p>マスタ反映は、Excelで管理されているマスタデータを元に、最終的にマスタ反映コンテナがDBに書き込むという流れになっています。</p>
<p>上記の流れを図に示します。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/import-master-flow.svg" alt=""></p>
<p>図中では以下のような流れでマスタ反映が進みます。</p>
<ol>
<li>マスタ運営担当者が、原本となるExcelファイルに車種や商品情報を入力する</li>
<li>出来上がったExcelファイルをマスタ管理ツールにアップロードする</li>
<li>マスタ管理ツールがバリデーションをかけ、問題があれば担当者に通知する</li>
<li>Excelがアップロードされると裏でLambda関数が実行され、ExcelファイルからCSVファイルに変換される</li>
<li>DBに反映したい段階で、マスタデータをFACTORY本体に連携するため、CSVをレプリケーションバケットに保存する</li>
<li>レプリケーションバケットにファイルが保存されるとFACTORY本体でステートマシンが起動し、マスタ反映コンテナを起動する</li>
<li>マスタ反映コンテナがCSVを読み取ってSQLを組み立て、DBの各テーブルにレコードを読み書きする</li>
</ol>
<p>今回高速化の対象としたのは、7のマスタ反映コンテナの処理です。</p>
<h2>パフォーマンスチューニングをどのように進めたか追体験する</h2>
<p>今回のマスタ反映に関するパフォーマンス問題についてどのように解決したかサンプルコードで見ていきましょう。
実際のマスタ反映処理はKotlinで記述されていますが、サンプルコードの方では筆者が慣れているGoを使います。
また、使用するマスタデータはFACTORYの実際に使われているデータではありません。
ですが、似た構造のマスタデータを使うので、実際に筆者が行ったパフォーマンスチューニングと同じ方法で高速化できます。
もしよろしければ皆さんも手を動かしながら試してみてください。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/master-import-container.svg" alt=""></p>
<h3>入力</h3>
<p>ECサイトで管理している商品データを反映したいと考えてみましょう。
表では省略していますが、全部で50万件程度のデータとなります</p>
<table>
<thead>
<tr>
<th>product_code<br>商品を一意に識別するコード</th>
<th>product_name<br>商品の表示名</th>
<th>category_code<br>商品が属するカテゴリのコード</th>
<th>supplier_code<br>仕入先コード</th>
<th>status_code<br>商品の販売状態</th>
<th align="right">unit_price<br>単価（円）</th>
</tr>
</thead>
<tbody><tr>
<td>P1001</td>
<td>ボールペン 黒</td>
<td>CAT01</td>
<td>SUP01</td>
<td>active</td>
<td align="right">150</td>
</tr>
<tr>
<td>P1002</td>
<td>ボールペン 赤</td>
<td>CAT01</td>
<td>SUP01</td>
<td>active</td>
<td align="right">150</td>
</tr>
<tr>
<td>P1003</td>
<td>シャープペンシル</td>
<td>CAT01</td>
<td>SUP02</td>
<td>discontinued</td>
<td align="right">300</td>
</tr>
<tr>
<td>P2001</td>
<td>A4コピー用紙 500枚</td>
<td>CAT02</td>
<td>SUP03</td>
<td>active</td>
<td align="right">450</td>
</tr>
<tr>
<td>P2002</td>
<td>A3コピー用紙 500枚</td>
<td>CAT02</td>
<td>SUP03</td>
<td>active</td>
<td align="right">780</td>
</tr>
</tbody></table>
<p>人間にとって分かりやすいように表で示しましたが、システムにはcsvの形で入力されます。</p>
<pre><code class="language-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
</code></pre>
<h3>出力</h3>
<p>入力されたデータを以下のように<code>product</code>テーブルに入れることにします。
category_codeやsupplier_codeやstatus_codeは外部テーブルで保持される値となるため、idに変換した上で保存されます。
外部テーブルにはすでにレコードが反映されているとします。</p>
<table>
<thead>
<tr>
<th align="right">product_id</th>
<th>product_code</th>
<th>product_name</th>
<th align="right">category_id</th>
<th align="right">supplier_id</th>
<th align="right">status_id</th>
<th align="right">unit_price</th>
</tr>
</thead>
<tbody><tr>
<td align="right">1</td>
<td>P1001</td>
<td>ボールペン 黒</td>
<td align="right">1</td>
<td align="right">1</td>
<td align="right">1</td>
<td align="right">150</td>
</tr>
<tr>
<td align="right">2</td>
<td>P1002</td>
<td>ボールペン 赤</td>
<td align="right">1</td>
<td align="right">1</td>
<td align="right">1</td>
<td align="right">150</td>
</tr>
<tr>
<td align="right">3</td>
<td>P1003</td>
<td>シャープペンシル</td>
<td align="right">1</td>
<td align="right">2</td>
<td align="right">2</td>
<td align="right">300</td>
</tr>
<tr>
<td align="right">4</td>
<td>P2001</td>
<td>A4コピー用紙 500枚</td>
<td align="right">2</td>
<td align="right">3</td>
<td align="right">1</td>
<td align="right">450</td>
</tr>
<tr>
<td align="right">5</td>
<td>P2002</td>
<td>A3コピー用紙 500枚</td>
<td align="right">2</td>
<td align="right">3</td>
<td align="right">1</td>
<td align="right">780</td>
</tr>
</tbody></table>
<pre><code class="language-mermaid">erDiagram
    Product {
        string product_id PK &quot;商品ID&quot;
        string product_code UK &quot;商品コード&quot;
        string product_name &quot;商品名&quot;
        string category_id FK &quot;カテゴリID&quot;
        string supplier_id FK &quot;仕入先ID&quot;
        string status_id FK &quot;ステータスID&quot;
        int unit_price &quot;単価（円）&quot;
    }

    Category {
        string category_id PK &quot;カテゴリID&quot;
        string category_code UK &quot;カテゴリコード&quot;
        string category_name &quot;カテゴリ名&quot;
    }

    Supplier {
        string supplier_id PK &quot;仕入先ID&quot;
        string supplier_code UK &quot;仕入先コード&quot;
        string supplier_name &quot;仕入先名&quot;
    }

    Status {
        string status_id PK &quot;ステータスID&quot;
        string status_code UK &quot;ステータスコード&quot;
        string status_name &quot;ステータス名&quot;
    }

    Category ||--o{ Product : &quot;has&quot;
    Supplier ||--o{ Product : &quot;supplies&quot;
    Status ||--o{ Product : &quot;applies&quot;
</code></pre>
<h2>改善前のコード</h2>
<p>サンプルコードの全体構成を以下の図に示します。
ハンズオンをサクッとできるようにテストデータの準備等の必要な作業を行ったのち、本題のマスタ反映が実行されるようになっています。testcontainersでMySQLコンテナを起動しテスト用のCSVを生成した後、main.goがそのCSVを読み取ってDBにマスタ反映を行います。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/sample-code.svg" alt=""></p>
<p>今回使用するサンプルコードを以下に示します。以下の4つのコードを同じディレクトリに配置してください。</p>
<p>:::details main.go (改善対象のコード)</p>
<pre><code class="language-go">package main

import (
	&quot;context&quot;
	&quot;fmt&quot;
	&quot;log&quot;
	&quot;os&quot;
	&quot;time&quot;

	_ &quot;github.com/go-sql-driver/mysql&quot;
	&quot;github.com/gocarina/gocsv&quot;
	&quot;github.com/jmoiron/sqlx&quot;
)

func main() {
	ctx := context.Background()

	// MySQLコンテナを起動
	connStr, cleanup, err := startMySQLContainer(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer cleanup()

	db, err := sqlx.Open(&quot;mysql&quot;, connStr)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// テーブル・マスターデータを作成
	if err := setupTables(db); err != nil {
		log.Fatal(err)
	}

	// サンプルCSVを生成（50万行）
	csvFilename := &quot;data.csv&quot;
	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, &amp;products); err != nil {
		log.Fatal(err)
	}
	fmt.Printf(&quot;CSV読み込み完了: %d 行\n&quot;, 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(
			&amp;category,
			`SELECT * FROM categories WHERE code = ?`,
			product.CategoryCode,
		); err != nil {
			log.Fatalf(&quot;行 %d: category_code %q の検索に失敗: %v&quot;, lineNum, product.CategoryCode, err)
		}

		// 4. supplier_codeをsupplier_idに変換
		var supplier Supplier
		if err := db.Get(
			&amp;supplier,
			`SELECT * FROM suppliers WHERE code = ?`,
			product.SupplierCode,
		); err != nil {
			log.Fatalf(&quot;行 %d: supplier_code %q の検索に失敗: %v&quot;, lineNum, product.SupplierCode, err)
		}

		// 5. status_codeをstatus_idに変換
		var status Status
		if err := db.Get(
			&amp;status,
			`SELECT * FROM statuses WHERE code = ?`,
			product.StatusCode,
		); err != nil {
			log.Fatalf(&quot;行 %d: status_code %q の検索に失敗: %v&quot;, 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(&quot;行 %d: productsの更新に失敗: %v&quot;, lineNum, err)
		}

		rowsAffected, err := result.RowsAffected()
		if err != nil {
			log.Fatalf(&quot;行 %d: 更新件数の取得に失敗: %v&quot;, 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(&quot;行 %d: productsの登録に失敗: %v&quot;, lineNum, err)
			}
		}

		if (lineNum-1)%1000 == 0 {
			rate := float64(lineNum-1) / time.Since(importStart).Seconds()
			fmt.Printf(&quot;進捗: %d / %d 行 (%.0f 行/秒)\n&quot;, lineNum-1, len(products), rate)
		}
		// 9. 2に戻る
	}

	fmt.Printf(&quot;完了: %d 行 (所要時間: %v)\n&quot;, len(products), time.Since(importStart))
}
</code></pre>
<p>:::</p>
<p>:::details models.go (csv, dbを操作するのに必要な構造体を定義)</p>
<pre><code class="language-go">package main

type Product struct {
	ProductCode  string `csv:&quot;product_code&quot;`
	ProductName  string `csv:&quot;product_name&quot;`
	CategoryCode string `csv:&quot;category_code&quot;`
	SupplierCode string `csv:&quot;supplier_code&quot;`
	StatusCode   string `csv:&quot;status_code&quot;`
	UnitPrice    int    `csv:&quot;unit_price&quot;`
}

type Category struct {
	ID   int    `db:&quot;id&quot;`
	Code string `db:&quot;code&quot;`
	Name string `db:&quot;name&quot;`
}

type Supplier struct {
	ID   int    `db:&quot;id&quot;`
	Code string `db:&quot;code&quot;`
	Name string `db:&quot;name&quot;`
}

type Status struct {
	ID   int    `db:&quot;id&quot;`
	Code string `db:&quot;code&quot;`
	Name string `db:&quot;name&quot;`
}

type ProductRow struct {
	ProductCode string `db:&quot;product_code&quot;`
	ProductName string `db:&quot;product_name&quot;`
	CategoryID  int    `db:&quot;category_id&quot;`
	SupplierID  int    `db:&quot;supplier_id&quot;`
	StatusID    int    `db:&quot;status_id&quot;`
	UnitPrice   int    `db:&quot;unit_price&quot;`
}
</code></pre>
<p>:::</p>
<p>:::details setup.go（DB初期化・CSV生成）</p>
<pre><code class="language-go">package main

import (
	&quot;context&quot;
	&quot;encoding/csv&quot;
	&quot;fmt&quot;
	&quot;math/rand&quot;
	&quot;os&quot;
	&quot;strconv&quot;
	&quot;time&quot;

	&quot;github.com/jmoiron/sqlx&quot;
	&quot;github.com/testcontainers/testcontainers-go&quot;
	&quot;github.com/testcontainers/testcontainers-go/modules/mysql&quot;
	&quot;github.com/testcontainers/testcontainers-go/wait&quot;
)

func startMySQLContainer(ctx context.Context) (connStr string, cleanup func(), err error) {
	mysqlContainer, err := mysql.Run(ctx,
		&quot;mysql:8.0&quot;,
		mysql.WithDatabase(&quot;testdb&quot;),
		mysql.WithUsername(&quot;user&quot;),
		mysql.WithPassword(&quot;password&quot;),
		testcontainers.WithWaitStrategyAndDeadline(3*time.Minute,
			wait.ForListeningPort(&quot;3306/tcp&quot;).
				WithStartupTimeout(3*time.Minute),
		),
	)
	if err != nil {
		return &quot;&quot;, nil, err
	}

	connStr, err = mysqlContainer.ConnectionString(ctx)
	if err != nil {
		_ = mysqlContainer.Terminate(ctx)
		return &quot;&quot;, 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{&quot;product_code&quot;, &quot;product_name&quot;, &quot;category_code&quot;, &quot;supplier_code&quot;, &quot;status_code&quot;, &quot;unit_price&quot;}); err != nil {
		return err
	}

	categoryCodes := []string{&quot;CAT01&quot;, &quot;CAT02&quot;, &quot;CAT03&quot;}
	supplierCodes := []string{&quot;SUP01&quot;, &quot;SUP02&quot;, &quot;SUP03&quot;}
	statusCodes := []string{&quot;active&quot;, &quot;discontinued&quot;, &quot;pending&quot;}

	for i := 0; i &lt; rows; i++ {
		record := []string{
			fmt.Sprintf(&quot;P%d&quot;, 1000+i+1),
			fmt.Sprintf(&quot;商品_%d&quot;, 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
			(&#39;CAT01&#39;, &#39;文房具&#39;), (&#39;CAT02&#39;, &#39;食品&#39;), (&#39;CAT03&#39;, &#39;電化製品&#39;)`,
		`INSERT IGNORE INTO suppliers (code, name) VALUES
			(&#39;SUP01&#39;, &#39;株式会社A商事&#39;), (&#39;SUP02&#39;, &#39;株式会社B産業&#39;), (&#39;SUP03&#39;, &#39;株式会社C物産&#39;)`,
		`INSERT IGNORE INTO statuses (code, name) VALUES
			(&#39;active&#39;, &#39;販売中&#39;), (&#39;discontinued&#39;, &#39;販売終了&#39;), (&#39;pending&#39;, &#39;販売準備中&#39;)`,
	}

	for _, data := range masterData {
		if _, err := db.Exec(data); err != nil {
			return err
		}
	}

	return nil
}
</code></pre>
<p>:::</p>
<p>:::details go.mod</p>
<pre><code class="language-plain">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
)
</code></pre>
<p>:::</p>
<p>高速化するためにmain.goを改善していきます。
main.goの処理の流れをまとめると以下の通りです。</p>
<ol>
<li>csvを読み取る<pre><code class="language-csv">product_code,product_name,category_code,supplier_code,status_code,unit_price
P1001,ボールペン 黒,CAT01,SUP01,active,150
P1002,ボールペン 赤,CAT01,SUP01,active,150
...
</code></pre>
</li>
<li>読んでない行があれば1行読み取る、なければ終了<pre><code class="language-csv">P1001,ボールペン 黒,CAT01,SUP01,active,150
</code></pre>
</li>
<li>category_codeをcategory_idに変換<pre><code class="language-sql">SELECT * FROM categories WHERE code = &#39;CAT01&#39;
-- =&gt; id=1, code=&#39;CAT01&#39;, name=&#39;文房具&#39;
</code></pre>
</li>
<li>supplier_codeをsupplier_idに変換<pre><code class="language-sql">SELECT * FROM suppliers WHERE code = &#39;SUP01&#39;
-- =&gt; id=1, code=&#39;SUP01&#39;, name=&#39;株式会社A商事&#39;
</code></pre>
</li>
<li>status_codeをstatus_idに変換<pre><code class="language-sql">SELECT * FROM statuses WHERE code = &#39;active&#39;
-- =&gt; id=1, code=&#39;active&#39;, name=&#39;販売中&#39;
</code></pre>
</li>
<li>ProductRowに変換</li>
<li>UPDATE文を実行する<pre><code class="language-sql">UPDATE products SET product_name = &#39;ボールペン 黒&#39;, category_id = 1, supplier_id = 1, status_id = 1, unit_price = 150 WHERE product_code = &#39;P1001&#39;
</code></pre>
</li>
<li>UPDATE対象がなければINSERTする<pre><code class="language-sql">INSERT INTO products (product_code, product_name, category_id, supplier_id, status_id, unit_price) VALUES (&#39;P1001&#39;, &#39;ボールペン 黒&#39;, 1, 1, 1, 150)
</code></pre>
</li>
<li>2に戻る</li>
</ol>
<h3>実行してみる</h3>
<p>まずは現状を把握するため反映にどれくらい時間がかかるかみてみましょう。
testcontainersでMySQLコンテナを起動するため、事前にDocker Desktopを起動しておいてください。
また、依存パッケージを取得するために<code>go mod tidy</code>を実行してから<code>go run .</code>を実行します。</p>
<pre><code class="language-bash">go mod tidy
go run .
</code></pre>
<p>このコードを実行してみると以下のような実行結果が得られます。
なんとDBへの反映に47分かかってしまいました。</p>
<pre><code>$ 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)
</code></pre>
<h2>実際のFACTORYのマスタ反映の負荷状況</h2>
<p>実際のFACTORYでの本番環境への反映では90分もの時間がかかっていました。
FACTORY本番のRDSでの負荷を計測するため、以下にDatabase Insightsの結果を示します。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/before.png" alt=""></p>
<p>図ではクエリ別にAAS(平均アクティブセッション)が示され、AASが高い順に並んでいます。
AASが高いほどDBに負荷がかかっており、低いほどDBに負荷がかかっていないというように解釈すればokです。</p>
<p>赤枠がマスタ反映時に実行されているSQLになりますが、</p>
<ol>
<li>特定のテーブルに対するSELECTの実行回数が多い(1秒あたりに200回程度実行されている)</li>
<li>SELECTよりも負荷は小さいものの、UPDATEも同程度の頻度で実行されている</li>
</ol>
<p>このように計測の結果、マスタ反映時に叩かれるSQL、特にSELECTが原因だなというように見当をつけ、改善を進めていきました。</p>
<h2>原因を探る</h2>
<p>これだけの時間がかかる原因を探ってみましょう。
ここではコード中で実行されるクエリに着目してみます。
実行されているクエリは以下の通りです。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>クエリ</th>
<th align="right">ループ中(回)</th>
<th align="right">合計(回)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>SELECT * FROM categories WHERE code = ?</code></td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td>2</td>
<td><code>SELECT * FROM suppliers WHERE code = ?</code></td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td>3</td>
<td><code>SELECT * FROM statuses WHERE code = ?</code></td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td>4</td>
<td><code>UPDATE products SET ... WHERE product_code = ?</code></td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td>5</td>
<td><code>INSERT INTO products (...) VALUES (...)</code></td>
<td align="right">最大1 × 50万ループ = 最大50万</td>
<td align="right">最大50万</td>
</tr>
<tr>
<td></td>
<td>合計</td>
<td align="right">最大250万</td>
<td align="right">最大250万</td>
</tr>
</tbody></table>
<p>1ループあたりの実行回数は少ないですが、今回はCSVが50万行あることから50万ループ実行され、最大で合計250万クエリ実行されることになります。</p>
<p>実行されるクエリが多いと、インデックスを貼って単体のクエリが高速にしたとしても、ちりつもで遅くなってしまいます。
特にDBは別サーバに分離されることが多く、ネットワークの通信帯域の影響も受けてしまいます。</p>
<p>なので高速化の方針としては実行されるクエリをいかに削減するかということを考えれば良さそうです。</p>
<h2>実行されるクエリを削減するためには？</h2>
<h3>SELECT編</h3>
<p>実行されるクエリを削減するにはいくつかの手段がありますが、まずはオンメモリキャッシュを取り上げてみたいと思います。
オンメモリキャッシュは、時間のかかる処理の実行結果をあらかじめメモリ上に乗っけてしまい、結果が欲しい時にはメモリ上のデータから引っ張り出すことで高速化する手法です。ISUCONでは常套手段といっても良いほど典型的なパターンです。
今回でいくと時間のかかる処理とはDBへの問い合わせにあたります。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/cache-db.svg" alt=""></p>
<p>オンメモリでキャッシュするには、キャッシュ対象のデータが、キャッシュ中に書き換えられないほうが実装しやすいです。
キャッシュ中に実データに書き込みがある場合、キャッシュを書き込みに追随させるためデータの更新が必要になります。排他制御を考慮する必要があり、実装が困難になります。</p>
<p>productsテーブルを更新する際にはcategories, suppliers, statusesテーブルはすでに更新が完了しており、書き込みはありません。なのでproductsテーブルを更新する前にキャッシュしておけば問題なさそうです。</p>
<p>ということで先ほどのコードにキャッシュ処理を加えます。</p>
<p>CSV読み取り直後にSELECTを行い全件をメモリ上に載せます。
code→IDへ高速にデータを引きたいので、スライスではなくここでは<code>map[string]int</code>に載せてあげます。map型はキーにひもづくデータの取得で$O(1)$の計算量で高速にデータを引くことができます。</p>
<pre><code class="language-diff">        fmt.Printf(&quot;CSV読み込み完了: %d 行\n&quot;, len(products))

+       // マスターデータをmapに読み込み（code → id）
+       var categories []Category
+       if err := db.Select(&amp;categories, &quot;SELECT * FROM categories&quot;); err != nil {
+               log.Fatal(err)
+       }
+       categoryMap := make(map[string]int, len(categories))
+       for _, c := range categories {
+               categoryMap[c.Code] = c.ID
+       }
</code></pre>
<p>code→IDが欲しいタイミングで、先ほど定義したmap型の変数を使うように書き換えます</p>
<pre><code class="language-diff">                // 3. category_codeをcategory_idに変換
-               var category Category
-               if err := db.Get(&amp;category, &quot;SELECT * FROM categories WHERE code = ?&quot;, product.CategoryCode); err !=
nil {
-                       log.Printf(&quot;行 %d: category変換エラー: %v&quot;, i+2, err)
+               categoryID, ok := categoryMap[product.CategoryCode]
+               if !ok {
+                       log.Printf(&quot;行 %d: category変換エラー: code %q が見つかりません&quot;, i+2, product.CategoryCode)
                        errorCount++
                        continue
                }
</code></pre>
<p>他の修正も加えると以下のような差分になります。
:::details オンメモリキャッシュ化の全体差分</p>
<pre><code class="language-diff">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(&quot;CSV読み込み完了: %d 行\n&quot;, len(products))

+	// マスターデータをmapに読み込み（code → id）
+	var categories []Category
+	if err := db.Select(&amp;categories, &quot;SELECT * FROM categories&quot;); 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(&amp;suppliers, &quot;SELECT * FROM suppliers&quot;); 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(&amp;statuses, &quot;SELECT * FROM statuses&quot;); 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(
-			&amp;category,
-			`SELECT * FROM categories WHERE code = ?`,
-			product.CategoryCode,
-		); err != nil {
-			log.Fatalf(&quot;行 %d: category_code %q の検索に失敗: %v&quot;, lineNum, product.CategoryCode, err)
+		categoryID, ok := categoryMap[product.CategoryCode]
+		if !ok {
+			log.Fatalf(&quot;行 %d: category_code %q の検索に失敗&quot;, lineNum, product.CategoryCode)
 		}

 		// 4. supplier_codeをsupplier_idに変換
-		var supplier Supplier
-		if err := db.Get(
-			&amp;supplier,
-			`SELECT * FROM suppliers WHERE code = ?`,
-			product.SupplierCode,
-		); err != nil {
-			log.Fatalf(&quot;行 %d: supplier_code %q の検索に失敗: %v&quot;, lineNum, product.SupplierCode, err)
+		supplierID, ok := supplierMap[product.SupplierCode]
+		if !ok {
+			log.Fatalf(&quot;行 %d: supplier_code %q の検索に失敗&quot;, lineNum, product.SupplierCode)
 		}

 		// 5. status_codeをstatus_idに変換
-		var status Status
-		if err := db.Get(
-			&amp;status,
-			`SELECT * FROM statuses WHERE code = ?`,
-			product.StatusCode,
-		); err != nil {
-			log.Fatalf(&quot;行 %d: status_code %q の検索に失敗: %v&quot;, lineNum, product.StatusCode, err)
+		statusID, ok := statusMap[product.StatusCode]
+		if !ok {
+			log.Fatalf(&quot;行 %d: status_code %q の検索に失敗&quot;, 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,
 		}
</code></pre>
<p>:::</p>
<p>DBに問い合わせる代わりにメモリ上のキャッシュにデータを問い合わせるため、
SELECTの150万回分がなくなり、残りのUPDATE/INSERTの最大100万回にまで削減できました。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>クエリ</th>
<th align="right">ループ前(回)</th>
<th align="right">ループ中(回)</th>
<th align="right">合計(回)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>SELECT * FROM categories</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>2</td>
<td><code>SELECT * FROM suppliers</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>3</td>
<td><code>SELECT * FROM statuses</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>4</td>
<td><code>UPDATE products SET ... WHERE product_code = ?</code></td>
<td align="right">0</td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td>5</td>
<td><code>INSERT INTO products (...) VALUES (...)</code></td>
<td align="right">0</td>
<td align="right">最大1 × 50万ループ = 最大50万</td>
<td align="right">最大50万</td>
</tr>
<tr>
<td></td>
<td>合計</td>
<td align="right">3</td>
<td align="right">最大100万</td>
<td align="right">最大100万3</td>
</tr>
</tbody></table>
<p>これでどれくらい高速化できたか見てみましょう。</p>
<pre><code>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)
</code></pre>
<p>以上のように時間を半減させることができました。</p>
<h3>INSERT/UPDATE編</h3>
<p>SELECTの実行回数は削減できましたが、まだ100万回ものSQLが実行されています。
残りのINSERT/UPDATEの高速化にチャレンジしてみます。</p>
<p>INSERT/UPDATEの実行回数を削減する手段としてはupsertに変更することが挙げられます。</p>
<h3>UPSERTとは</h3>
<p>UPSERTとはINSERTとUPDATEを組み合わせた単語で、INSERT時に対象レコードが存在しない場合はINSERTと、すでに存在する場合はUPDATEをかける処理です。
MySQLではINSERT ON DUPLICATE KEY UPDATEとREPLACE構文が使えますが、今回は前者の構文を使ってみます。</p>
<p>今回でいくと以下のUPDATE文を実行し、</p>
<pre><code class="language-sql">UPDATE products
SET product_name = ?,
    category_id  = ?,
    supplier_id  = ?,
    status_id    = ?,
    unit_price   = ?
WHERE product_code = ?
</code></pre>
<p>UPDATE対象が存在しなければINSERTを行っています。</p>
<pre><code class="language-sql">INSERT INTO products (
  product_code,
  product_name,
  category_id,
  supplier_id,
  status_id,
  unit_price
)
VALUES (?, ?, ?, ?, ?, ?)
</code></pre>
<p>INSERT ON DUPLICATE KEY UPDATEを使用すると2つのクエリを1つにまとめることができます。</p>
<pre><code class="language-sql">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)
</code></pre>
<p>これだけで100万回→50万回までクエリの実行回数を削減できます。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>クエリ</th>
<th align="right">ループ前(回)</th>
<th align="right">ループ中(回)</th>
<th align="right">合計(回)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>SELECT * FROM categories</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>2</td>
<td><code>SELECT * FROM suppliers</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>3</td>
<td><code>SELECT * FROM statuses</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>4</td>
<td><code>INSERT INTO products (...) ON DUPLICATE KEY UPDATE ...</code></td>
<td align="right">0</td>
<td align="right">1 × 50万ループ = 50万</td>
<td align="right">50万</td>
</tr>
<tr>
<td></td>
<td>合計</td>
<td align="right">3</td>
<td align="right">50万</td>
<td align="right">50万3</td>
</tr>
</tbody></table>
<p>コードでは以下のように修正しています
:::details UPSERT化の差分</p>
<pre><code class="language-diff">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(&quot;行 %d: productsの更新に失敗: %v&quot;, lineNum, err)
-		}
-
-		rowsAffected, err := result.RowsAffected()
-		if err != nil {
-			log.Fatalf(&quot;行 %d: 更新件数の取得に失敗: %v&quot;, 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(&quot;行 %d: productsの登録に失敗: %v&quot;, lineNum, err)
-			}
+			log.Fatalf(&quot;行 %d: productsのUPSERTに失敗: %v&quot;, lineNum, err)
 		}

 		if (lineNum-1)%1000 == 0 {
</code></pre>
<p>:::
実行してみましょう。</p>
<pre><code>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)
</code></pre>
<p>この修正だけで10分程度まで早くすることができました。</p>
<h3>bulk化する</h3>
<p>upsertに変更して50万回までSQLの実行回数を削減できました。
さらにSQLの実行回数を削減するためにSQLをbulk化してみます。</p>
<p>bulk化とはDBに対して複数のレコードに対する操作を1つのSQLにまとめて実行することを言います。
以下のUPSERT化したSQLはいまだ50万回叩かれています。</p>
<pre><code class="language-sql">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)
</code></pre>
<p>このSQLを1行ずつ入れていくのではなく、ある程度のレコード数で固めてから送ることで
SQLの実行回数を減らせるわけです。
今回は1000レコード分ずつSQLをまとめて送ることにしてみましょう。
すると500000/1000=500回までSQLの実行回数を削減できます。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>クエリ</th>
<th align="right">ループ前(回)</th>
<th align="right">ループ中(回)</th>
<th align="right">合計(回)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>SELECT * FROM categories</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>2</td>
<td><code>SELECT * FROM suppliers</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>3</td>
<td><code>SELECT * FROM statuses</code></td>
<td align="right">1</td>
<td align="right">0</td>
<td align="right">1</td>
</tr>
<tr>
<td>4</td>
<td><code>INSERT INTO products (...) VALUES (...), (...), ... ON DUPLICATE KEY UPDATE ...</code></td>
<td align="right">0</td>
<td align="right">50万ループ / 1000 = 500</td>
<td align="right">500</td>
</tr>
<tr>
<td></td>
<td>合計</td>
<td align="right">3</td>
<td align="right">500</td>
<td align="right">503</td>
</tr>
</tbody></table>
<p>どれくらい固めるかを表す数値をバッチサイズと呼びますが、この場合バッチサイズは1000となります。</p>
<p>:::details バルクUPSERT化の差分</p>
<pre><code class="language-diff">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(&quot;行 %d: status_code %q の検索に失敗&quot;, 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(&quot;変換完了: %d 行\n&quot;, len(rows))
+
+	// バルクUPSERT（1000行ずつ）
+	const batchSize = 1000
+	importStart := time.Now()
+
+	for i := 0; i &lt; len(rows); i += batchSize {
+		end := i + batchSize
+		if end &gt; 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(&quot;行 %d: productsのUPSERTに失敗: %v&quot;, lineNum, err)
+			log.Fatalf(&quot;バッチ %d-%d: UPSERTに失敗: %v&quot;, i+1, end, err)
 		}

-		if (lineNum-1)%1000 == 0 {
-			rate := float64(lineNum-1) / time.Since(importStart).Seconds()
-			fmt.Printf(&quot;進捗: %d / %d 行 (%.0f 行/秒)\n&quot;, lineNum-1, len(products), rate)
+		if end%10000 == 0 || end == len(rows) {
+			rate := float64(end) / time.Since(importStart).Seconds()
+			fmt.Printf(&quot;進捗: %d / %d 行 (%.0f 行/秒)\n&quot;, end, len(rows), rate)
 		}
-		// 8. 2に戻る
 	}

 	fmt.Printf(&quot;完了: %d 行 (所要時間: %v)\n&quot;, len(products), time.Since(importStart))
</code></pre>
<p>:::</p>
<p>では実行してみましょう。</p>
<pre><code>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)
</code></pre>
<p>わずか6秒程度で完了するようになりました！
元々50分かかっていた処理だと考えると、かなり高速化されたのではないかと思います。</p>
<h2>改善後の実際のFACTORYでのDBの負荷状況</h2>
<p>改善の結果を先述のDatabase InsightsのAASで確認してみましょう。
<img src="/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/after.png" alt=""></p>
<p>赤枠がマスタ反映時に実行されているSQLになりますが、</p>
<ol>
<li>改善前に負荷がかかっているSQLとして挙げられていたSELECTがなくなって、ボトルネックを解消した</li>
<li>INSERTはまだいるが実行回数が減り、AASも減った</li>
</ol>
<p>このように実際のFACTORYのDBの計測からも負荷が減ったことがわかります。
この改善の結果、5分程度で反映が終わるようになりました！
改善前は90分かかっていたと考えるとめちゃくちゃ高速化できました！</p>
<h2>まとめ</h2>
<p>今回の改善の変遷をまとめると以下の通りです。</p>
<table>
<thead>
<tr>
<th>ステップ</th>
<th>施策</th>
<th align="right">所要時間</th>
<th align="right">SQL実行回数(最大)</th>
</tr>
</thead>
<tbody><tr>
<td>改善前</td>
<td>-</td>
<td align="right">47分</td>
<td align="right">250万回</td>
</tr>
<tr>
<td>1. オンメモリキャッシュ</td>
<td>SELECTをメモリ参照に置換</td>
<td align="right">20分</td>
<td align="right">100万回</td>
</tr>
<tr>
<td>2. UPSERT化</td>
<td>UPDATE+INSERTを1クエリに統合</td>
<td align="right">13分</td>
<td align="right">50万回</td>
</tr>
<tr>
<td>3. バルクUPSERT化</td>
<td>1000行ずつまとめて実行</td>
<td align="right">6秒</td>
<td align="right">500回</td>
</tr>
</tbody></table>
<p>パフォーマンスチューニングでとった方法はどれもISUCONではよく出てくる典型的な対応策です。
まさかISUCONで培った知識を使って業務でこれほどまでの結果を出せるとは思いもしませんでした。
ISUCONは業務でも役に立ちます。
これからもISUCONで腕を磨きつつ、業務でのボトルネックを改善していきたいと考えています。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/blog/authors/uehara/2026-03-31-improvement-importing-data-performance/thumb.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[MediaPipe Instant Motion Trackingを用いた、AndroidにおけるARエフェクトの実現]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-27-korenani-guide-mediapipe-ar/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-27-korenani-guide-mediapipe-ar/</guid>
            <pubDate>Fri, 27 Mar 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[MediaPipeを用いてARエフェクトをAndroidで実装する方法を技術的に解説]]></description>
            <content:encoded><![CDATA[<!-- ↓マークダウン チートシート↓ -->

<h1>はじめに</h1>
<p>はじめまして。
KINTO テクノロジーズで KINTO Unlimited Android アプリを開発している JR.Liang です。</p>
<p>本記事では、KINTO Unlimited アプリにて提供する「これなにガイド」スキャン機能の AR エフェクトについて、Android における技術的な検証を紹介します。
特に MediaPipe のソリューションを用いて幅広い Android デバイスで AR エフェクトを実現した実装にフォーカスします。</p>
<h2>これなにガイドとは</h2>
<p>「これなにガイド」は AR（拡張現実）を活用して、車内スイッチの用途や使い方をテキストと動画で案内する機能です。紹介動画をご覧ください。
<a href="https://youtube.com/watch?v=E8zfNzuHr7g&embeds_referring_euri=https%3A%2F%2Fcorp.kinto-jp.com%2F&source_ve_path=MjM4NTE">https://youtube.com/watch?v=E8zfNzuHr7g&amp;embeds_referring_euri=https%3A%2F%2Fcorp.kinto-jp.com%2F&amp;source_ve_path=MjM4NTE</a>
上記の紹介動画は iOS アプリでの動作を示しています。スイッチ上に表示された黄色の丸 🟡 が、AR 技術で実現した仮想コンテンツです。</p>
<p>機能全体の仕組みは以下の流れです。本記事では 3 番目（描画）に関する内容を扱います。</p>
<pre><code>1. アプリのカメラを起動、カメラ画像を取得
2. 機械学習における物体認識を用いて、車内のスイッチを検出
3. 検出した座標を元に、ボタンとテキストをフレーム上に描画
4. ボタンをタップして、当該スイッチのテキストと動画を表示
</code></pre>
<h2>Android AR 技術検証の経緯</h2>
<p>当初の Android 版「これなにガイド」のスキャン機能では、Canvas を利用して毎フレーム検出される座標に描画する実装でした。そのため検出の時間差により、スマホ（カメラ）を動かすと描画のズレが生じていました。
<img src="/assets/blog/authors/JR.Liang/v1.gif" alt="v1">
<em>2D Canvas</em>
幸い、MediaPipe のソリューションである Instant Motion Tracking モジュールで<strong>素早くかつ安定した</strong> AR エフェクトを実現できることがわかり、Android への導入を検証しました。
<img src="/assets/blog/authors/JR.Liang/v2.gif" alt="v2">
<em>3D OpenGL</em></p>
<h1>MediaPipe Instant Motion Tracking</h1>
<p>MediaPipe は Google が開発したオープンソースの ML フレームワークで、顔検出・手のトラッキング・姿勢推定などリアルタイム映像処理のソリューションを提供します。</p>
<p>その中の Instant Motion Tracking は、現実世界のシーン上に 3D 仮想コンテンツをリアルタイムで正確に配置できる AR トラッキング機能です。初期化や厳密なキャリブレーションが不要で、静止面や動いている面の上にコンテンツを置くことが可能です。
@<a href="https://github.com/google-ai-edge/mediapipe/blob/master/docs/solutions/instant_motion_tracking.md">card</a></p>
<h2>Android + MediaPipe AR アーキテクチャ</h2>
<pre><code class="language-mermaid">graph TB
    A(Android CameraX) --&gt; |Camera Frame| B(Instant Motion Tracking)
    B --&gt; |Camera Image| C(TensorFlow Object Detection)
    C --&gt; |Detections Information| B(Instant Motion Tracking)
    B --&gt; |Output Stream| D(Android Surface Rendering)
</code></pre>
<p>CameraX で取得したフレームを Instant Motion Tracking に渡し、TensorFlow Lite で物体検出した情報を元に AR コンテンツを描画・追従させるパイプラインです。</p>
<h2>MediaPipe ライブラリの作成</h2>
<p>MediaPipe では Bazel を使用してパッケージをビルドします。Android に適合する AAR として書き出してアプリに組み込みます。
<a href="https://chuoling.github.io/mediapipe/getting_started/android_archive_library.html">https://chuoling.github.io/mediapipe/getting_started/android_archive_library.html</a></p>
<p>AAR をビルドする <code>BUILD</code> ファイルを作成し、<code>instant_motion_tracking</code> を基盤とした定義を記述します。</p>
<pre><code>load(&quot;//mediapipe/java/com/google/mediapipe:mediapipe_aar.bzl&quot;, &quot;mediapipe_aar&quot;)

mediapipe_aar(
    name = &quot;mediapipe_ar&quot;,
    calculators = [&quot;//mediapipe/graphs/instant_motion_tracking:instant_motion_tracking_deps&quot;]
)
</code></pre>
<p>MediaPipe は C++ が中核のため、C++ ランタイムである libc++_shared.so を AAR に同梱する必要があります。
<a href="https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/third_party/BUILD#L399-L403">https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/third_party/BUILD#L399-L403</a></p>
<p>また Instant Motion Tracking では画像処理ライブラリ OpenCV を利用し、AR トラッキングを行います。
<a href="https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/WORKSPACE#L649-L655">https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/WORKSPACE#L649-L655</a></p>
<p>上記サードパーティのライブラリを含めて、以下のコマンドで AAR をビルドします。</p>
<pre><code>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
</code></pre>
<ul>
<li>市場に流通している Android デバイスは主に arm64-v8a アーキテクチャのため、AAR のサイズを抑える目的で <code>fat_apk_cpu=arm64-v8a</code> にします。</li>
<li>C++ ライブラリの 16KB page-size に対応するため、<code>max-page-size=16384</code> を追加します。</li>
</ul>
<p>また AAR を利用するにはグラフ構造を定義するファイル（<code>binarypb</code>）が必要です。</p>
<pre><code>bazel build -c opt mediapipe/graphs/instant_motion_tracking:instant_motion_tracking.binarypb
</code></pre>
<h1>Instant Motion Tracking の導入</h1>
<p>AAR をアプリに組み込んで、Android 側の実装を解説していきます。
下記は AAR に組み込んだ instant_motion_tracking の全体構造です。</p>
<p><img src="/assets/blog/authors/JR.Liang/graph.png" alt="Graph"></p>
<h2>instant_motion_tracking.pbtxt の構成</h2>
<p>グラフ定義ファイル <code>instant_motion_tracking.pbtxt</code> は、Calculator（処理ノード）・入出力ストリーム・サイドパケットの 3 要素で構成されます。</p>
<h3>Calculator</h3>
<p>各 Calculator がパイプライン上でどの処理を担うかを示します。</p>
<table>
<thead>
<tr>
<th>Calculator</th>
<th>役割</th>
</tr>
</thead>
<tbody><tr>
<td><strong>ImageTransformationCalculator</strong></td>
<td>カメラフレームを 320×320（FIT）にリサイズ。物体検出モデルの入力サイズに合わせる</td>
</tr>
<tr>
<td><strong>GpuBufferToImageFrameCalculator</strong></td>
<td>GPU テクスチャを CPU の <code>ImageFrame</code> に変換。TensorFlow Lite 推論に使用</td>
</tr>
<tr>
<td><strong>StickerManagerCalculator</strong></td>
<td>Sticker Proto をパースし、初期アンカーの座標・回転・スケール・レンダリング種別に分解</td>
</tr>
<tr>
<td><strong>RegionTrackingSubgraph</strong></td>
<td>ボックストラッキングでアンカー位置を追従。内部に <code>TrackedAnchorManagerCalculator</code>（アンカー管理）と <code>BoxTrackingSubgraphGpu</code>（GPU トラッキング）を持つ</td>
</tr>
<tr>
<td><strong>MatricesManagerCalculator</strong></td>
<td>トラッキング結果・回転・スケール・FOV・アスペクト比から OpenGL 用 4×4 モデル行列を生成</td>
</tr>
<tr>
<td><strong>GlAnimationOverlayCalculator</strong></td>
<td>モデル行列とテクスチャを用いて、元のカメラフレーム上に AR コンテンツを OpenGL で描画し <code>output_video</code> として出力</td>
</tr>
</tbody></table>
<h3>input_stream / output_stream</h3>
<p><code>input_stream</code> はフレームごとに Android 側から送信するデータ、<code>output_stream</code> はグラフの処理結果です。</p>
<table>
<thead>
<tr>
<th>ストリーム名</th>
<th>C++ 型</th>
<th>方向</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td><code>input_video</code></td>
<td>GpuBuffer</td>
<td>Input</td>
<td>カメラフレーム</td>
</tr>
<tr>
<td><code>sticker_proto_string</code></td>
<td>String(Serialized Proto)</td>
<td>Input</td>
<td>ステッカーの座標・スケール等（Sticker Proto）</td>
</tr>
<tr>
<td><code>sticker_sentinels</code></td>
<td>vector<int></td>
<td>Input</td>
<td>座標をリセットするステッカー ID の配列</td>
</tr>
<tr>
<td><code>gif_textures</code></td>
<td>vector<AssetTextureFormat></td>
<td>Input</td>
<td>AR コンテンツの Bitmap テクスチャ配列</td>
</tr>
<tr>
<td><code>gif_aspect_ratios</code></td>
<td>vector<float></td>
<td>Input</td>
<td>各テクスチャのアスペクト比</td>
</tr>
<tr>
<td><code>output_video</code></td>
<td>GpuBuffer</td>
<td>Output</td>
<td>AR 描画済みフレーム</td>
</tr>
</tbody></table>
<h3>input_side_packet</h3>
<p><code>input_side_packet</code> は初期化時に一度だけ渡す定数で、グラフ実行中は変化しません。</p>
<table>
<thead>
<tr>
<th>パケット名</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td><code>vertical_fov_radians</code></td>
<td>カメラの垂直 FOV（ラジアン）</td>
</tr>
<tr>
<td><code>aspect_ratio</code></td>
<td>カメラのアスペクト比</td>
</tr>
<tr>
<td><code>width</code> / <code>height</code></td>
<td>カメラ解像度</td>
</tr>
<tr>
<td><code>gif_texture</code></td>
<td>デフォルトテクスチャ（1x1 プレースホルダ）</td>
</tr>
<tr>
<td><code>gif_asset_name</code></td>
<td>AR テクスチャ描画用のポリゴンメッシュ（<code>.obj</code>）ファイル名</td>
</tr>
</tbody></table>
<p>Android への導入に当たって、公式サンプルのコードを参考にします。
<a href="https://github.com/google-ai-edge/mediapipe/tree/master/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking">https://github.com/google-ai-edge/mediapipe/tree/master/mediapipe/examples/android/src/java/com/google/mediapipe/apps/instantmotiontracking</a></p>
<h2>1. 初期化</h2>
<p>MediaPipe を使用する前に、ネイティブライブラリの読み込みとアセットマネージャーの初期化が必要です。</p>
<pre><code class="language-kotlin">companion object {
    init {
        System.loadLibrary(&quot;mediapipe_jni&quot;)
        System.loadLibrary(&quot;opencv_java4&quot;)
    }
}

// onCreate 相当の処理
AndroidAssetUtil.initializeNativeAssetManager(context)
</code></pre>
<ul>
<li><code>mediapipe_jni</code>: MediaPipe のコア処理を行う JNI ライブラリ</li>
<li><code>opencv_java4</code>: AR トラッキングに使用する OpenCV ライブラリ</li>
<li><code>initializeNativeAssetManager</code>: ネイティブコードからアセット（binarypb 等）にアクセスするために必要</li>
</ul>
<h2>2. カメラを起動する</h2>
<p>公式サンプルを参考に、以下の順序でパイプラインを構築します。
<strong>データフロー：</strong> CameraX → ExternalTextureConverter → FrameProcessor → SurfaceView</p>
<h3>2.1 EGL 環境と FrameProcessor の初期化</h3>
<pre><code class="language-kotlin">val eglManager = EglManager(null)
val frameProcessor = FrameProcessor(
    context,
    eglManager.nativeContext,
    &quot;instant_motion_tracking.binarypb&quot;,
    &quot;input_video&quot;,
    &quot;output_video&quot;
).apply {
    videoSurfaceOutput.setFlipY(true)
    setInputSidePackets(
        mapOf(
            &quot;gif_asset_name&quot; to packetCreator.createString(&quot;gif.obj.uuu&quot;),
            &quot;vertical_fov_radians&quot; to packetCreator.createFloat32(fovRadians),
            &quot;aspect_ratio&quot; to packetCreator.createFloat32(resolution.width.toFloat() / resolution.height.toFloat()),
            &quot;width&quot; to packetCreator.createInt32(resolution.width),
            &quot;height&quot; to packetCreator.createInt32(resolution.height),
            &quot;gif_texture&quot; to packetCreator.createRgbaImageFrame(createBitmap(1, 1))
        )
    )
}
</code></pre>
<ul>
<li><code>EglManager</code>: OpenGL ES の EGL コンテキストを作成・管理。MediaPipe のグラフ内 GPU Calculator（<code>GlAnimationOverlayCalculator</code> 等）が OpenGL で描画するために必要</li>
<li><code>FrameProcessor</code>: EGL コンテキストを受け取り、グラフの読み込み・入出力ストリームの管理・フレームごとのグラフ実行を行う<ul>
<li><code>instant_motion_tracking.binarypb</code>: <code>.pbtxt</code> を Bazel でコンパイルしたグラフ定義バイナリ</li>
<li><code>input_video</code>: MediaPipe グラフへカメラフレームを入力</li>
<li><code>output_video</code>: グラフで処理（AR 描画など）された映像を出力</li>
<li><code>videoSurfaceOutput.setFlipY(true)</code>: OpenGL とカメラの Y 軸方向が逆のため、出力映像を上下反転して正しい向きにする</li>
<li><code>setInputSidePackets</code>: グラフの <code>input_side_packet</code> に対応する定数をまとめて設定。カメラの FOV・アスペクト比・解像度など、グラフ実行中に変化しない値を初期化時に一度だけ渡す</li>
</ul>
</li>
<li><code>gif_asset_name</code> は AR テクスチャを描画するための<strong>ポリゴンメッシュ（頂点データ）</strong>、ここでは公式サンプルの<code>gif.obj.uuu</code>を利用</li>
</ul>
<h3>2.2 カメラ映像の変換パイプライン構築</h3>
<pre><code class="language-kotlin">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)
    )
}
</code></pre>
<ul>
<li><code>ExternalTextureConverter</code>: カメラの <code>GL_EXTERNAL_OES</code> テクスチャを MediaPipe が処理できる標準テクスチャに変換<ul>
<li><code>setFlipY(true)</code>: カメラ映像の上下反転を補正</li>
<li><code>setDestinationSize(resolution.width, resolution.height)</code>: パイプラインの処理サイズはポートレート座標（例: <code>960×1280</code>）で指定</li>
</ul>
</li>
<li><code>CameraXPreviewHelper</code>: CameraX でバックカメラを起動し、Converter の SurfaceTexture に出力<ul>
<li><code>startCamera(targetSize = Size(resolution.height, resolution.width))</code>: CameraX はセンサー座標（ランドスケープ）を期待するため、width と height を入れ替えて渡す</li>
</ul>
</li>
</ul>
<p>公式サンプルでは <code>CameraXPreviewHelper</code> をそのまま使用し、内部で <code>CameraManager</code> からカメラ特性を取得します。
<a href="https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/mediapipe/java/com/google/mediapipe/components/CameraXPreviewHelper.java#L558-L560">https://github.com/google-ai-edge/mediapipe/blob/v0.10.32/mediapipe/java/com/google/mediapipe/components/CameraXPreviewHelper.java#L558-L560</a>
本実装では <code>getCameraCharacteristics</code> をオーバーライドし、事前に取得済みの <code>CameraCharacteristics</code> を直接渡します。これにより FOV やアスペクト比の算出に使うカメラ情報を、アプリ側で一元管理できます。</p>
<h3>2.3 出力先SurfaceViewの設定</h3>
<pre><code class="language-kotlin">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)
        }
    })
}
</code></pre>
<ul>
<li><code>SurfaceHolder.Callback</code>: SurfaceView のライフサイクルに応じて FrameProcessor の出力先を管理<ul>
<li><code>surfaceCreated</code>: FrameProcessor の出力先として Surface を設定</li>
<li><code>surfaceChanged</code>: 画面回転・サイズ変更時に出力解像度を調整</li>
<li><code>surfaceDestroyed</code>: リソース解放</li>
</ul>
</li>
</ul>
<h2>3. 検出座標をグラフに渡す</h2>
<p>物体検出（TensorFlow Lite 等）で得られた座標を MediaPipe グラフに渡し、AR コンテンツを配置します。</p>
<h3>3.1 グラフから変換済み画像を取得</h3>
<p>MediaPipe グラフ内で <code>ImageTransformationCalculator</code> と <code>GpuBufferToImageFrameCalculator</code> によって変換された画像を <code>addPacketCallback</code> で受け取り、物体検出に使用します。</p>
<pre><code class="language-kotlin">frameProcessor.addPacketCallback(&quot;transformed_input_video_cpu&quot;) { packet -&gt;
    packet ?: return@addPacketCallback
    // 変換済み画像を物体検出（TensorFlow Lite）に渡す
    val bitmap = PacketGetter.getBitmapFromRgba(packet)
    objectDetector.detect(bitmap) { detections -&gt;
        // 検出結果を処理
    }
}
</code></pre>
<ul>
<li><code>transformed_input_video_cpu</code>: 変換後の画像を出力するストリーム名</li>
</ul>
<h3>3.2 座標の正規化</h3>
<p>物体検出結果のピクセル座標を、MediaPipe が期待する正規化座標に変換します。</p>
<pre><code class="language-kotlin">// ピクセル座標 → 正規化座標 (0.0〜1.0)
val normalizedX = pixelX / imageWidth.toFloat()
val normalizedY = pixelY / imageHeight.toFloat()
</code></pre>
<h3>3.3 Sticker Proto の構造</h3>
<p>Instant Motion Tracking では、AR オブジェクトの位置情報を Protocol Buffers 形式で定義します。</p>
<pre><code class="language-protobuf">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;
}
</code></pre>
<h3>3.4 フレームごとにパケットを送信</h3>
<p><code>setOnWillAddFrameListener</code> を使用して、各フレーム処理前に検出座標をグラフへ送信します。</p>
<pre><code class="language-kotlin">frameProcessor.setOnWillAddFrameListener { timestamp -&gt;
    with(frameProcessor.graph) {
        // 検出された物体の座標情報をパケットとして送信
        val stickerRoll = StickerRoll.newBuilder()
            .addAllSticker(detectedObjects.map { detection -&gt;
                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(&quot;sticker_proto_string&quot;, stickersPacket, timestamp)
    }
}
</code></pre>
<ul>
<li><code>FrameProcessor.setOnWillAddFrameListener</code>: 各フレームがグラフに送られる直前に呼ばれるコールバック</li>
<li><code>FrameProcessor.graph.addPacketToInputStream</code>: 入力ストリームにパケットを追加</li>
<li><code>sticker_proto_string</code>: グラフ定義で指定された入力ストリーム名</li>
</ul>
<h2>4. テクスチャ（Bitmap）の描画と送信</h2>
<p>位置情報と同時に、AR コンテンツとして描画する Bitmap テクスチャもグラフに渡します。</p>
<h3>4.1 Bitmap テクスチャの生成</h3>
<p>検出された各スイッチに対して、丸アイコンとラベルテキストを含む Bitmap を生成します。</p>
<pre><code class="language-kotlin">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)
    }
}
</code></pre>
<p><code>Matrix().preScale(-1.0f, 1.0f)</code> で Bitmap を左右反転しています。以下の IMU 行列に合わせるためです。</p>
<pre><code class="language-cpp">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軸へ
};
</code></pre>
<p>この行列は OpenGL モデル行列（4x4）の回転成分として使われ、Y/Z 軸の入れ替えと X 軸反転でテクスチャをカメラ平面に平行に固定します。</p>
<p>本来はデバイスの IMU センサーから回転行列を受け取り、端末の傾きに追従させます。
<a href="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">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</a>
本実装では固定値にすることで<strong>常にカメラ正面を向く</strong>（ビルボード効果）ようにし、<code>(0,0)</code> の <code>-1.0</code> による X 軸反転を Bitmap 側の <code>preScale(-1.0f, 1.0f)</code> で打ち消します。</p>
<h3>4.2 テクスチャの送信</h3>
<pre><code class="language-kotlin">// テクスチャ画像（Bitmap配列）
val texturesPacket = packetCreator.createRgbaImageFrameVector(
    renderStickers.map { it.bitmap }.toTypedArray()
)
addPacketToInputStream(&quot;gif_textures&quot;, texturesPacket, timestamp)
// アスペクト比（テクスチャの縦横比）
val aspectRatiosPacket = packetCreator.createFloat32Vector(
    renderStickers.map { it.aspectRatio }.toFloatArray()
)
addPacketToInputStream(&quot;gif_aspect_ratios&quot;, aspectRatiosPacket, timestamp)
</code></pre>
<ul>
<li><code>PacketCreator.createRgbaImageFrameVector</code>: 複数の Bitmap を RGBA 形式のパケットに変換</li>
<li><code>gif_textures</code>: テクスチャ画像の入力ストリーム</li>
<li><code>gif_aspect_ratios</code>: 各テクスチャのアスペクト比（正しいスケーリングに必要）</li>
</ul>
<p>公式サンプルでは <code>createRgbaImageFrame</code> を使用して<strong>単一のテクスチャ</strong>をグラフに渡します。
<a href="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">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</a>
本実装では、複数の検出オブジェクトに対応するため <code>createRgbaImageFrameVector</code> で<strong>複数テクスチャを同時に送信</strong>し、<code>gif_aspect_ratios</code> も <code>createFloat32Vector</code> で<strong>各テクスチャに対応するアスペクト比の配列</strong>を渡すよう拡張します。これにより、検出された各スイッチに異なるラベル（テキスト付きBitmap）を正しい縦横比で表示できます。</p>
<p>ここまでで AR コンテンツをカメラ上に表示できました。</p>
<h2>5. 座標の更新</h2>
<p>トラッキング中のステッカー座標を更新するには、新しい座標を持つ <code>sticker_proto_string</code> と、リセット対象の ID を含む <code>sticker_sentinels</code> を同一 timestamp で送信します。<code>TrackedAnchorManagerCalculator</code> が該当 ID のトラッキングボックスを破棄し、新しい座標でトラッキングを再開します。</p>
<pre><code class="language-kotlin">// 更新した座標で Sticker Proto を再構築
val stickersPacket = packetCreator.createSerializedProto(stickerRoll)
addPacketToInputStream(&quot;sticker_proto_string&quot;, stickersPacket, timestamp)

// リセット対象のステッカー ID を送信
val stickerSentinels = packetCreator.createInt32Vector(updateIds)
addPacketToInputStream(&quot;sticker_sentinels&quot;, stickerSentinels, timestamp)
</code></pre>
<p>公式サンプルでは <code>sticker_sentinel</code> で<strong>単一のステッカー ID</strong> を送信します。
<a href="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">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</a>
本実装では <code>sticker_sentinels</code> として <code>createInt32Vector</code> で<strong>複数のステッカー ID を配列</strong>で渡すよう拡張し、物体検出で座標が更新された複数のステッカーを同時にリセットできるようにします。</p>
<h1>最後に</h1>
<p>以上が MediaPipe Instant Motion Tracking を用いた技術的な実装解説でした。決して容易に導入できる手法ではありませんが、本機能の要件に対して Android に最も適した解決策だと考えています。
以前に ARCore の検証も行いましたが、ARCore は SLAM 技術による事前の 3D マッピングに時間を要し、<strong>素早くかつ安定した</strong> AR エフェクトの実現には適さなかったため、検証を断念しました。
両フレームワークの違いを以下にまとめます。AR 技術の検討で参考になれば幸いです。</p>
<table>
<thead>
<tr>
<th>項目</th>
<th>Instant Motion Tracking</th>
<th>ARCore</th>
</tr>
</thead>
<tbody><tr>
<td>仕組み</td>
<td>2D ボックストラッキング + OpenGL 描画</td>
<td>環境マッピング + 平面検出（SLAM）</td>
</tr>
<tr>
<td>デバイス要件</td>
<td>OpenGL ES 対応であれば動作</td>
<td>ARCore 対応デバイスのみ（Google 認定必須）</td>
</tr>
<tr>
<td>安定性</td>
<td>検出座標に依存するため補正が必要</td>
<td>空間認識が高精度で安定</td>
</tr>
<tr>
<td>導入コスト</td>
<td>Bazel ビルド・C++ Calculator のカスタマイズが必要</td>
<td>SDK 導入のみで比較的容易</td>
</tr>
<tr>
<td>オープンソース</td>
<td>あり（Apache 2.0）</td>
<td>なし（プロプライエタリ）</td>
</tr>
<tr>
<td>カスタマイズ性</td>
<td>Calculator の追加・変更で柔軟に拡張可能</td>
<td>SDK の API 範囲内に限定</td>
</tr>
<tr>
<td>パフォーマンス</td>
<td>軽量（2D トラッキングベースのため CPU/GPU 負荷が低い）</td>
<td>高負荷（環境の 3D 空間マッピングを常時実行）</td>
</tr>
<tr>
<td>学習コスト</td>
<td>高い（Bazel・C++・OpenGL・Protocol Buffers の知識が必要）</td>
<td>低い（Android SDK の知見で導入可能）</td>
</tr>
</tbody></table>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/blog/authors/JR.Liang/mediapipe.webp" length="0" type="image/webp"/>
        </item>
        <item>
            <title><![CDATA[FDEが届ける「ニンベンのついた自働化」 ― AI Agent時代の新しい協業の形]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-23-AIエージェントによる業務の自働化/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-23-AIエージェントによる業務の自働化/</guid>
            <pubDate>Mon, 23 Mar 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[AI Agent（AIエージェント）の時代における「ニンベンのついた自働化」をキーワードに、KTCの現場事例やForward Deployed Engineer（FDE）という協業モデルを通じて、業務の知識を持つ人とAIの共創の形を紹介します]]></description>
            <content:encoded><![CDATA[<p>こんにちは！KINTOテクノロジーズ（以下、KTC）のAIファーストグループで、生成AIの社内活用推進を担当している和田です。普段は生成AIを使った業務価値の創出から、社内の教育研修、技術の手の内化まで、「AIを現場に届ける」仕事をしています。</p>
<p>今回お話ししたいのは、<strong>AI Agent（AIエージェント）</strong> というトレンドです。KTCのようなテックカンパニーの内側で何が起きているのか。そして、ITやAIの知識を持つ我々と、業務の知識を持つ方々（それは時によってメーカーの設計技術者さんだったり、販売店の営業さんだったりします）との「協業の形」がどう変わろうとしているのか。「ニンベンのついた自働化」というキーワードを軸に、お伝えしていきます。</p>
<hr>
<h2>1. はじめに ― なぜ今「エージェント」なのか</h2>
<p>生成AIの進化を振り返ると、大きく3つのフェーズがあったと考えています。</p>
<table>
<thead>
<tr>
<th>時期</th>
<th>フェーズ</th>
<th>特徴</th>
</tr>
</thead>
<tbody><tr>
<td>2022〜2023年</td>
<td>チャットAI</td>
<td>1問1答。「質問すれば答えてくれる」体験が広がる</td>
</tr>
<tr>
<td>2024年</td>
<td>RAG全盛期</td>
<td>RAG（Retrieval-Augmented Generation：社内データ等を検索しながら回答を生成する手法）で「自社の情報を知っているAI」が登場</td>
</tr>
<tr>
<td>2025年〜</td>
<td>AI Agent</td>
<td>AIが自ら考え、ツールを使い、複数ステップの仕事をこなす</td>
</tr>
</tbody></table>
<p><img src="/assets/blog/authors/s.wada/20260323/%E3%82%B9%E3%83%A9%E3%82%A4%E3%83%897.webp" alt="生成AIは「チャット」から「エージェント」へ"></p>
<p>Agentを実現するOSSの老舗であるLangChainをはじめ、エージェントという概念自体は2023年頃にはすでに存在していました。しかし当時は、LLMそのものの&quot;地頭&quot;がまだ追いついていませんでした。指示を正しく理解できない、途中で迷子になる、ツールの使い方を間違える ― そんな状態を覚えている方もいると思います。</p>
<p>ここ1〜2年でLLM（Large Language Model：大規模言語モデル）の精度が飛躍的に向上したことで、ようやくエージェントが「実用に耐える」レベルになってきました。これは毎日エージェントを使い、自身の業務を常に効率化し続けてきた私の実感です。</p>
<p>2026年の今、多くの企業がエージェント技術を「PoCから社会実装へ」と動き始めています。試すフェーズは終わり、業務に組み込むフェーズに入りつつある。だからこそ、「どう組み込むか」の設計思想が問われています。</p>
<hr>
<h2>2. 目指す姿 ― 「ニンベンのついた自働化」とはどんな状態か</h2>
<p>KTCが所属するトヨタグループでは昔から「自働化」という概念が大切にされています。「動」ではなく「働」。機械が異常を検知したら自ら止まり、不良を後工程に流さない。問題を顕在化させ、人が原因を究明し対処できる状態をつくる。人を機械の番人にせず、本来人間にしかできない判断や改善に集中させる。自動化の中に「人の知恵」を埋め込む思想です。</p>
<p>・・・とはいうものの、AIエージェントの時代における「ニンベンのついた自働化」とは、一体どんな状態でしょうか？</p>
<p>私はこう定義しています。</p>
<h3><strong>人間の役割が明確になっている</strong></h3>
<p>エージェントが作業している間、人はより創造的・判断的な仕事に集中できている。たとえば、エージェントがログ分析をしている間に、人間は対応方針の意思決定に集中する、といった状態です。</p>
<h3><strong>エージェントの「持ち物」が事前に整っている</strong></h3>
<p>必要な権限、参照すべきデータ、判断基準 ― これらを人間が先回りして渡している。エージェントに手待ちをさせない環境設計です。</p>
<h3><strong>「やってはいけないこと」の境界線が設計されている</strong></h3>
<p>例えば「データの参照はOK、削除はNG」「提案はするが、最終承認は必ず人間」といったガードレールが明確に引かれている。</p>
<h3><strong>業務を知る人がフロー全体をデザインしている</strong></h3>
<p>技術者だけでは、業務の「行間」は読めません。何年・何十年と積み上げてきたドメイン知識を持つ人が、AIとの協業設計に参加している状態です。</p>
<p>この4つが揃ったとき、AIは「勝手に動く怖いもの」ではなく、「信頼して任せられるチームメイト」になる。それが「ニンベンのついた自働化」の姿だと考えています。</p>
<hr>
<h2>3. 進め方の指針 ― PoCを現場に届けるための3ステップ</h2>
<p>「エージェント、作ってみたけど現場に浸透しない」</p>
<p>これは本当によく起きる現象です。理由はシンプルで、<strong>技術的に動くものを作ることと、それが業務に根付くことは、まったく別の話</strong> だからです。</p>
<p>私がエージェント開発の中で踏む3つのステップを紹介します。</p>
<p><img src="/assets/blog/authors/s.wada/20260323/%E3%82%B9%E3%83%A9%E3%82%A4%E3%83%8919.webp" alt="生成AIを用いた業務改善のプロセス"></p>
<h3>ステップ1：課題を「正しく」見つける</h3>
<p>ここでの「正しく」とは、AIで解くべき課題かどうかを見極めるという意味です。</p>
<p>何年もかけて磨き上げられてきた課題解決の型は、道具が変わっても色褪せません。トヨタグループが大切にする問題解決のアプローチ ― 「現状把握」「真因追求」は、AI活用の文脈でもそのまま有効です。</p>
<p>ただし、一つ重要な判断軸が加わります。<strong>「全てをAIでやろうとしない」</strong> ということ。</p>
<p>たとえば、月に数回しか発生しない作業を自動化しても、構築・運用コストに見合わないことがあります。逆に、毎日30分かかる定型作業は、多少精度が荒くてもエージェント化する価値がある。費用対効果とスケール感を冷静に見極めることが、このステップの肝です。</p>
<h3>ステップ2：試す・作り込む</h3>
<p>AIエージェントの構造は、実はシンプルです。大きく2つの要素で成り立っています。</p>
<ul>
<li><strong>プロンプト</strong>：エージェントへの「指示書」。あなたの役割はこれで、こういう手順で仕事をしてください、という設計図です。非エンジニアの方は「新人に渡す業務マニュアル」をイメージしていただくとわかりやすいかもしれません。</li>
<li><strong>ツール</strong>：エージェントが使える「道具箱」。ウェブ検索、社内データの参照、計算、メール送信など、LLM単体では苦手なことを補う機能群です。</li>
</ul>
<p>・・・ただし、「シンプルな構造 = 簡単に完成する」ではありません。</p>
<p>プロンプトの書き方ひとつで、エージェントの振る舞いは劇的に変わります。ツールの選び方、渡すデータの粒度、エラー時のフォールバック設計。この作り込みの工程に、全体の工数の大半がかかると言っても過言ではありません。</p>
<h3>ステップ3：業務フローに「組み込む」</h3>
<p>ここが最も重要で、かつ最も見落とされやすいステップです。</p>
<p>完成したエージェントを業務フローのどこに置くか。誰が使うか。既存のツールとどう共存させるか。例外が起きたときに誰がフォローするか。</p>
<p>これらの問いに答えられるのは、<strong>ドメインの知識を持つ人だけ</strong> です。</p>
<p>ここで言う「ドメイン知識」とは、特定の業務ノウハウだけを指しているわけではありません。業務フローを再設計するための価値判断基準、組織の意思決定経路や力学、そして現場の肌感覚 ― これらすべてを含む、長年の経験から培われた知の総体です。</p>
<p>たとえば自動車・モビリティの領域で考えると、その重要性がよくわかります。</p>
<h4><strong>現場の業務ノウハウ</strong></h4>
<p>整備士が持つ「この車種のこの年式は、ここが壊れやすい」という経験則。販売店の営業が持つ「この地域では◯月に需要が伸びる」という季節感覚。こうした知識は、個別業務に深く根ざしています。</p>
<h4><strong>価値判断と優先順位の基準</strong></h4>
<p>「納車までのリードタイムを短縮するよりも、お客様への中間報告の頻度を上げるほうが満足度に効く」「この検査工程は品質上絶対に省略できないが、書類作成の順序は変えられる」。業務フローを再設計するとき、何を守り何を変えてよいかを判断できるのは、その業務の「重み」を知っている人だけです。</p>
<h4><strong>組織の事情と意思決定の経路</strong></h4>
<p>「この変更はA部門だけでは通らない、B部門の部長の合意が要る」「この申請は制度上オンラインで完結するが、実質は事前の根回しが必要」。どんなに優れたエージェントを作っても、組織の中で動かせなければ意味がない。その道筋を知っているのも、ドメインの力です。</p>
<hr>
<p>これらの知識は構造化されていません。業務マニュアルにも社内ドキュメントにも、ましてやLLMの学習データにも十分には載っていない。だからこそ、エージェントを開発する技術者だけでは業務フローの設計はできないし、業務を知る「人」が設計に参加する必要があるのです。</p>
<p>具体的な場面で言えば、「この申請は月末に集中するから、そのタイミングでエージェントが下書きを用意しておいてくれると助かる」「この承認フローは部長の口頭確認が実質必要だから、エージェントの自動承認は外したほうがいい」 ― こうした判断は、何年も現場で業務を回してきた人にしかできません。</p>
<p>だからこそ、ステップ3は技術者と業務担当者の「共同作業」になります。ここに「ニンベンのついた自働化」の真価があると考えています。</p>
<h2>4. よくある落とし穴 ― 「動くけど根付かない」を避けるために</h2>
<p>セクション3で「<a href="#3.-%E9%80%B2%E3%82%81%E6%96%B9%E3%81%AE%E6%8C%87%E9%87%9D-%E2%80%95-poc%E3%82%92%E7%8F%BE%E5%A0%B4%E3%81%AB%E5%B1%8A%E3%81%91%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE3%E3%82%B9%E3%83%86%E3%83%83%E3%83%97">正しい進め方</a>」を紹介しましたが、現場では逆のパターン ― つまり、やってしまいがちな失敗 ― も数多く見てきました。エージェントが「技術的には動いているのに、業務に根付かない」とき、原因はたいてい次の3つのどれかに行き着きます。</p>
<h3>落とし穴1：「全部AIで」と決めつけてしまう</h3>
<p>エージェントの可能性に惹かれるあまり、「AIに丸投げ」してしまうケースです。</p>
<p>一見すると大胆で魅力的に聞こえます。しかし、業務フローの中には「人の判断が入ることで価値が生まれている」工程が必ずあります。たとえば、クレーム対応における熟練オペレーターの声色の判断や、契約書レビューでのベテラン法務担当の「この条項は先方の意図と違う気がする」という直感。データ上は自動化できそうに見えても、その判断こそが顧客との信頼関係を支えている。こうした工程をAIに丸ごと置き換えると、効率は上がっても、守るべきものが静かに失われていきます。</p>
<p>ステップ1の「<a href="#%E3%82%B9%E3%83%86%E3%83%83%E3%83%971%EF%BC%9A%E8%AA%B2%E9%A1%8C%E3%82%92%E3%80%8C%E6%AD%A3%E3%81%97%E3%81%8F%E3%80%8D%E8%A6%8B%E3%81%A4%E3%81%91%E3%82%8B">AIで解くべき課題かどうかの見極め</a>」が甘いと、ここにはまります。</p>
<h3>落とし穴2：ドメインエキスパート不在のまま業務フローを設計する</h3>
<p>エンジニアだけで「こう組み込めば効率的だろう」と業務フローを設計してしまうケース。技術的には合理的でも、現場の実態と噛み合わない、机上の空論で設計が進行してしまいます。</p>
<p>セクション3で挙げた「<a href="#%E7%B5%84%E7%B9%94%E3%81%AE%E4%BA%8B%E6%83%85%E3%81%A8%E6%84%8F%E6%80%9D%E6%B1%BA%E5%AE%9A%E3%81%AE%E7%B5%8C%E8%B7%AF">組織の事情と意思決定の経路</a>」。これを知っているのは、何年もその業務を回してきた人だけです。エンジニアがどれほど優れていても、この層の知識は外から取得できません。</p>
<h3>落とし穴3：「作って渡す」で終わりにしてしまう</h3>
<p>「エージェント、完成しました。マニュアルも書きました。あとはよろしくお願いします」。</p>
<p>この引き渡し方は、ほぼ確実に定着しません。エージェントは従来のシステムとは違い、使い方や問いかけ方によって振る舞いが変わります。現場の人が「こう聞けばこう返る」という感覚を掴むまでには、作った人と一緒に使ってみる期間が要ります。</p>
<p>もうひとつ見落とされがちなのが、<strong>UI/UXの設計</strong> です。エージェントと聞くと、つい「チャットUI」を思い浮かべがちですが、チャットはあくまで暫定的なインターフェースにすぎません。現場の人が本当に求めているのは「チャットで何でも聞ける」体験ではなく、「いつもの業務の流れの中で、自然にAIの力が効いている」体験です。それはボタンひとつで起動するワークフローかもしれないし、既存ツールの中に溶け込んだ提案機能かもしれない。チャットUIで得たフィードバックを手がかりに、ユーザーが本当に求める体験を作り込んでいく ― この工程を「渡して終わり」にすると、永遠にチャットの域を出られません。</p>
<p>使っていく中で「ここはもう少しこうしてほしい」というフィードバックが生まれる。そのフィードバックをその場で反映できる ― この即応性が、エージェントが業務に馴染むかどうかの分岐点になります。</p>
<hr>
<p>これらの落とし穴に共通するのは、<strong>技術と業務の間に「翻訳者」がいない</strong> ということです。</p>
<p>エージェントにせよ何にせよ、<strong>使ってもらってなんぼ</strong> です。どれだけ精緻に作り込んでも、現場で使われなければ価値はありません。そして「使われる」ためには、技術的な完成度よりも、業務への馴染み方のほうがはるかに重要です。エンジニアとドメインエキスパートが同じ机で一緒に考える体制さえあれば、これらの失敗の多くは防げます。</p>
<p>次のセクションでは、その「一緒に考える」を実現するための協業モデルについてお話しします。</p>
<hr>
<h2>5. 今後の展望 ― Forward Deployed Engineer（FDE）という協業の形</h2>
<p>最後に、「ニンベンのついた自働化」を現場に届けるための、IT企業との新しい協業モデルについてお話しします。</p>
<p><a href="https://blog.palantir.com/a-day-in-the-life-of-a-palantir-forward-deployed-software-engineer-45ef2de257b1">Forward Deployed Engineer（FDE）</a> とは、エンジニア自身が顧客の現場に入り込み、課題のヒアリングから実装・運用定着まで一気通貫で担う職種です。</p>
<p>起源は米国の Palantir Technologies が確立した FDSE（Forward Deployed Software Engineer） とされています。名前の由来は軍事用語の「Forward Deployed（前線展開）」で、「製品を納品するだけでは使われない、エンジニアが現場に入って初めて価値が生まれる」という哲学から生まれました。</p>
<p>従来のIT企業では、エンジニアは社内でシステムを開発し、営業・PM・カスタマーサクセスを介して顧客と接するのが一般的です。FDEはこの構造を変え、エンジニアが顧客と直接対話しながら、要件定義・実装・定着支援までをすべて担います。コンサルタントと異なるのは「自ら手を動かす」点です。</p>
<p>具体的には、<strong>作れるエンジニア自身が、課題を持っている現場に直接入り込んで、一緒に考える</strong>。プロトタイプを一緒に触りながら、同じ机で議論する。セクション4で挙げた<a href="#%E8%90%BD%E3%81%A8%E3%81%97%E7%A9%B43%EF%BC%9A%E3%80%8C%E4%BD%9C%E3%81%A3%E3%81%A6%E6%B8%A1%E3%81%99%E3%80%8D%E3%81%A7%E7%B5%82%E3%82%8F%E3%82%8A%E3%81%AB%E3%81%97%E3%81%A6%E3%81%97%E3%81%BE%E3%81%86">「作って渡す」で終わりにしてしまう</a>という落とし穴の裏返しとも言えます。作って渡すのではなく、作りながら一緒に使う。その距離感が、エージェントの定着を左右します。</p>
<p><img src="/assets/blog/authors/s.wada/20260323/%E3%82%B9%E3%83%A9%E3%82%A4%E3%83%8931.webp" alt="価値創出を加速する：Forward Deployed Model"></p>
<table>
<thead>
<tr>
<th>役割</th>
<th>担うこと</th>
</tr>
</thead>
<tbody><tr>
<td><strong>FDE</strong>（IT側）</td>
<td>技術的な複雑さを引き受ける。AIの限界と可能性を正直に伝える。「これはできます、これは今は難しいです」を明確にする。</td>
</tr>
<tr>
<td><strong>ドメインエキスパート</strong>（業務側）</td>
<td>業務の文脈を提供する。「このデータならここから取れる」「この件は誰に聞けばいい」「この申請は私が通します」という現場の力を発揮する。</td>
</tr>
</tbody></table>
<p>この2つが掛け合わさったとき、初めて「ニンベンのついた自働化」が現場に根付く。私はそう信じています。</p>
<p>KTCは、この「FDEとドメインエキスパートの共創」を、自分たちの現場で実践し続けていきます。困りごとを見つけ、試し、形にして、届ける。そのサイクルの中で得た知見を、こうした場で発信していくことが、私にできる貢献のひとつだと考えています。</p>
<hr>
<p>ここまで読んでいただき、ありがとうございました！</p>
<p>「AIエージェント」という言葉が少し身近になり、「うちの現場でも何かできそうだな」と感じていただけたなら、この記事を書いた甲斐があります。</p>
<p>ぜひ一緒に、「ニンベンのついた自働化」を実装していきましょう。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/blog/authors/s.wada/20260323/cover.webp" length="0" type="image/webp"/>
        </item>
        <item>
            <title><![CDATA[回帰テストにおけるPlaywright vs Autify NoCodeWeb 徹底比較]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-12-回帰テストにおけるPlaywright vs Autify 徹底比較/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-12-回帰テストにおけるPlaywright vs Autify 徹底比較/</guid>
            <pubDate>Thu, 12 Mar 2026 12:00:00 GMT</pubDate>
            <description><![CDATA[回帰テストにおけるPlaywright vs Autify NoCodeWeb の比較について紹介させていただきます]]></description>
            <content:encoded><![CDATA[<h1>はじめに</h1>
<p>Webアプリケーションの回帰テストを自動化する際、適切なツールの選択は品質保証とチームの生産性に大きく影響します。</p>
<h2>プロジェクト背景</h2>
<p>KINTOテクノロジーズ（以下、KTC）では、これまでAutify NoCodeWebを活用して回帰テストの自動化を進め、品質保証体制を構築してきました。Autify NoCodeWebのノーコードプラットフォームは、QA専任メンバーが中心となってテスト自動化を迅速に導入する上で非常に有効であり、多くの成果を上げてきました。
しかし、プロジェクトの成長に伴い、新たな課題も見えてきました:</p>
<ul>
<li>より高速なテスト実行が求められるようになった</li>
<li>CSVファイルの編集・アップロードなど、複雑なファイル操作を伴うテストシナリオの増加</li>
<li>データ駆動テストによる大量のテストパターンの実行ニーズ</li>
<li>エンジニアチームの拡大により、コードベースのテスト資産の管理が可能になった</li>
</ul>
<p>このような背景から、現在のKTCの体制と要件に最適なツールを再検討する必要が生じました。本記事では、これまでお世話になってきたAutify NoCodeWebと、新たな選択肢としてのPlaywrightを、実際の回帰テストシナリオにおいて詳細に比較します。
どちらのツールも優れた特徴を持っており、組織の状況によって最適な選択は異なります。本記事が、皆様のツール選定の一助となれば幸いです。</p>
<h1>ツール概要</h1>
<h2>Playwright</h2>
<ul>
<li>開発元: Microsoft</li>
<li>タイプ: オープンソースのE2Eテストフレームワーク</li>
<li>対応言語: JavaScript/TypeScript、Python、.NET、Java
対応ブラウザ: <ul>
<li>PC：Chromium（Chrome、Edge）、Firefox、WebKit（Safari相当）</li>
<li>モバイル：デバイスエミュレーション（Chromium、WebKit）
　※実機のモバイルブラウザ操作は非対応</li>
</ul>
</li>
<li>特徴: コードベースで柔軟性が高く、高速な実行速度</li>
</ul>
<h2>Autify NoCodeWeb</h2>
<ul>
<li>開発元: オーティファイ株式会社（日本企業）</li>
<li>タイプ: ノーコードAI搭載テスト自動化プラットフォーム</li>
<li>対応ブラウザ:<ul>
<li>PC：Chrome、Edge、Firefox、Safari（WebKit）</li>
<li>モバイル：iOS、Android</li>
</ul>
</li>
<li>特徴: 操作をレコーディングしてテストシナリオを作成、AI による要素認識と自動修復機能</li>
</ul>
<h1>ツール選択のためのデシジョンフローチャート</h1>
<p>自社に最適なツールを選ぶ際の判断フローを視覚化しました。このフローチャートを参考に、組織の状況に応じた選択を行ってください。</p>
<pre><code class="language-mermaid">  graph TD
  Start[QAチームにプログラミング可能なエンジニアがいる]
  Start --&gt;|No| AutifyNoCodeWeb1[Autify NoCodeWeb: ノーコードで容易、迅速な導入、AI自動修復]
  Start --&gt;|Yes| Speed{実行速度を重視?}
  
  Speed --&gt;|Yes| Playwright1[Playwright: 高速、柔軟、無料]
  Speed --&gt;|No| Requirements{要件に応じて選択}
  
  Requirements --&gt;|インフラ管理は避けたい| AutifyNoCodeWeb2[Autify NoCodeWeb]
  Requirements --&gt;|メール連携や頻繁なUI変更がある| AutifyNoCodeWeb2
  Requirements --&gt;|コストを優先したい| Playwright2[Playwright]
  Requirements --&gt;|データ駆動テストや複雑なファイル操作がある| Playwright2
</code></pre>
<h2>フローチャートの使い方</h2>
<p>このデシジョンフローは、以下の優先順位で判断することを推奨しています：</p>
<ul>
<li>チーム構成の確認: まず、開発チームにプログラミング可能なエンジニアがいるかを確認します。エンジニアリソースが限られている場合は、Autify NoCodeWebが最適な選択となります。</li>
<li>実行速度の重視度: エンジニアがいる場合、次に実行速度の重要性を評価します。CI/CDパイプラインでの高速フィードバックが重要な場合、Playwrightが適しています。</li>
<li>詳細要件の評価: 実行速度がそれほど重要でない場合は、具体的なテスト要件に基づいて判断します：</li>
</ul>
<pre><code class="language-mermaid"> graph LR
  C1[インフラ管理は避けたい] --&gt; AutifyNoCodeWeb[Autify NoCodeWeb]
  C2[メール連携や頻繁なUI変更がある] --&gt; AutifyNoCodeWeb[Autify NoCodeWeb]
  C3[コストを優先したい] --&gt; Playwright
  C4[データ駆動テストや複雑なファイル操作がある] --&gt; Playwright
</code></pre>
<ul>
<li>ハイブリッドアプローチの検討: 上記の要件が混在している場合、両ツールを併用するハイブリッドアプローチも有効な選択肢です。</li>
</ul>
<h1>機能別詳細比較</h1>
<table>
<thead>
<tr>
<th align="left">#</th>
<th align="left">比較項目</th>
<th align="left">Playwright</th>
<th align="left">Autify NoCodeWeb</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left"><strong>CSVの編集とアップロード</strong></td>
<td align="left">✅ 可能</td>
<td align="left">⚠️ 制限あり</td>
</tr>
<tr>
<td align="left">2</td>
<td align="left"><strong>特定ファイルのダウンロード</strong></td>
<td align="left">✅ 可能</td>
<td align="left">⚠️ 検証に制限</td>
</tr>
<tr>
<td align="left">3</td>
<td align="left"><strong>特定ステップのスクリーンショット</strong></td>
<td align="left">✅ 柔軟なカスタマイ즈可能</td>
<td align="left">✅ 自動取得で便利</td>
</tr>
<tr>
<td align="left">4</td>
<td align="left"><strong>画面上の文字状態の判断</strong></td>
<td align="left">✅ 詳細な検証可能</td>
<td align="left">✅ AI認識で安定</td>
</tr>
<tr>
<td align="left">5</td>
<td align="left"><strong>データ駆動テストの循環使用</strong></td>
<td align="left">✅ 可能</td>
<td align="left">⚠️ 制限あり</td>
</tr>
<tr>
<td align="left">6</td>
<td align="left"><strong>異なる画面間の切り替え</strong></td>
<td align="left">✅ 完全対応</td>
<td align="left">✅ 対応</td>
</tr>
<tr>
<td align="left">7</td>
<td align="left"><strong>外部メール内容の確認</strong></td>
<td align="left">✅ API連携で対応可能</td>
<td align="left">✅ 統合機能で便利</td>
</tr>
<tr>
<td align="left">8</td>
<td align="left"><strong>動的要素のロケート</strong></td>
<td align="left">✅ 高精度な制御</td>
<td align="left">✅ 高精度な制御 / JS指定</td>
</tr>
<tr>
<td align="left">9</td>
<td align="left"><strong>画面の比較(VRT)</strong></td>
<td align="left">✅ ピクセル単位の精密比較</td>
<td align="left">✅ AI支援で大規模変更に対応</td>
</tr>
<tr>
<td align="left">10</td>
<td align="left"><strong>スクリプトの実装難易度</strong></td>
<td align="left">⚠️ プログラミングスキル必要</td>
<td align="left">✅ ノーコードで容易</td>
</tr>
<tr>
<td align="left">11</td>
<td align="left"><strong>スクリプトの修正難易度</strong></td>
<td align="left">✅ テキスト編集で迅速</td>
<td align="left">⚠️ GUI操作が必要</td>
</tr>
<tr>
<td align="left">12</td>
<td align="left"><strong>スクリプトの実行速度</strong></td>
<td align="left">✅ 基準速度 (高速)</td>
<td align="left">⚠️ 比較的遅い傾向</td>
</tr>
</tbody></table>
<h2>1. CSVの編集とアップロード</h2>
<h3>Playwrightの場合:</h3>
<p><code>input[type=&quot;file&quot;]</code> 要素に対して<code>setInputFiles()</code> メソッドを使用することで、CSVファイルのアップロードが柔軟に実装できます。また、ファイルの動的生成やデータ駆動テストとの組み合わせも可能です。コードベースの利点を活かし、複雑なファイル操作シナリオに対応できます。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>基本的なファイルアップロード機能は提供されていますが、複雑なCSV編集を伴うシナリオには制約があります。シンプルなファイルアップロードであれば、ノーコードで簡単に実装できる点は大きなメリットです</p>
<h2>2. 特定ファイルのWebページからのダウンロード</h2>
<h3>Playwrightの場合:</h3>
<p><code>page.waitForEvent(&#39;download&#39;)</code> を使用してダウンロードイベントを捕捉し、ファイル名や内容の検証まで完全に制御できます。ダウンロードしたファイルの内容を自動的に検証するシナリオも実装可能です</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>ダウンロード操作の記録と実行は可能です。基本的なダウンロード動作の確認には十分対応しており、ノーコードで実装できる利点があります。より詳細なファイル検証が必要な場合は、他の手段との組み合わせを検討する必要があります。</p>
<h2>3. 特定ステップのスクリーンショット</h2>
<h3>Playwrightの場合:</h3>
<p><code>page.screenshot()</code> や<code>locator.screenshot()</code> を使用して、任意のタイミングで全画面または特定要素のスクリーンショットを取得できます。保存先やファイル名も自由に設定可能で、細かい制御が必要な場合に優れています</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>全てのテストステップで自動的にスクリーンショットが撮影されるため、設定の手間が不要です。テスト失敗時の原因調査が容易になり、特にテスト自動化に不慣れなメンバーでも、確実に証跡を残せる点が優れています。</p>
<h2>4. 画面上の文字状態の判断</h2>
<h3>Playwrightの場合:</h3>
<p><code>expect(locator).toHaveText()</code> 、<code>toContainText()</code> 、<code>toBeVisible()</code> など、豊富なアサーションメソッドで文字列の存在、内容、表示状態を詳細に検証できます。正規表現による柔軟なパターンマッチングも可能で、複雑な検証ロジックに対応できます。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>テキストの存在確認や表示状態の検証が可能です。特にAIによる要素認識により、画面デザインが変更されても同じテキスト要素を識別できる点が優れています。HTMLの細かい変更に強く、メンテナンスコストを削減できます</p>
<h2>5. データ駆動テストの循環使用</h2>
<h3>Playwrightの場合:</h3>
<p>テストデータを配列やCSVファイルから読み込み、<code>test.describe()</code> やforループを使用して複数のデータセットで同じテストロジックを実行できます。テストの再利用性が非常に高く、大量のテストパターンを効率的に実行できます。</p>
<pre><code class="language-javascript">   // CSVファイルからデータを読み込む
    testData = await readCSV(&#39;C:\\××××××××\\testData4.csv&#39;);
   
    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;
</code></pre>
<h3>Autify NoCodeWebの場合:</h3>
<p>個別のテストシナリオを作成することで、複数のパターンに対応できます。ノーコードで各シナリオを管理できるため、プログラミングの知識がなくても運用可能な点がメリットです。ただし、データ量が多い場合はシナリオ数が増加します。</p>
<h2>6. 異なる画面間の切り替え</h2>
<h3>Playwrightの場合:</h3>
<p>複数タブ、複数ウィンドウ、iframe間の切り替えを完全にサポートしています。<code>page.context().pages()</code> で全ページを取得したり、<code>page.waitForEvent(&#39;popup&#39;)</code> で新しいページを待機することができます。複雑な画面遷移ロジックも実装可能です。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>画面遷移やタブ切り替えの操作を記録・実行できます。基本的な画面間の移動には十分対応しており、ノーコードで実装できる利点があります。</p>
<h2>7. 外部メール内容の確認（URLのクリックなど）</h2>
<h3>Playwrightの場合:</h3>
<p>メールテストAPIサービス（例：MailSlurp、Mailinator）と連携してメール内容を取得し、URLを抽出してナビゲーションすることが可能です。柔軟な連携が可能ですが、追加の実装とAPI費用が必要になる場合があります。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>メール検証機能がプラットフォームに組み込まれており、追加の設定や実装なしでメール内のリンクをクリックしたり、内容を確認したりできます。この統合機能は大きな強みであり、特に非エンジニアのQAメンバーでも簡単に利用できる点が優れています。</p>
<h2>8. XPathなどによる頻繁に変動する要素のロケート</h2>
<h3>Playwrightの場合:</h3>
<p>CSS Selector、XPath、text、roleなど、多様なロケーター戦略をサポートしています。複数のロケーターを組み合わせたり、厳密な条件指定が可能で、動的要素に対しても高い精度で特定できます。</p>
<pre><code class="language-python">            # ENT番号取得
            xpath1 = &#39;//*[@id=&quot;app&quot;]/div/main/div/div[1]/div[1]/div[2]/div/div[3]/div[1]&#39;
            display_text1 = (await page.locator(f&#39;xpath={xpath1}&#39;).text_content() or &#39;&#39;).strip()
            last1 = display_text1[-5:]
            shinsa_number = &#39;97016QAP00&#39; + last1

            # メールアドレス取得
            xpath2 = &#39;//*[@id=&quot;app&quot;]/div/main/div/div[1]/div[1]/div[2]/div/div[7]/div[2]&#39;
            display_text2 = (await page.locator(f&#39;xpath={xpath2}&#39;).text_content() or &#39;&#39;).strip()

            # 名前取得
            xpath0 = &#39;//*[@id=&quot;app&quot;]/div/main/div/div[1]/div[1]/div[2]/div/div[7]/div[1]/a&#39;
            display_text0 = (await page.locator(f&#39;xpath={xpath0}&#39;).text_content() or &#39;&#39;).strip()
            lastname = display_text0[4:]
</code></pre>
<h3>Autify NoCodeWebの場合:</h3>
<p>AIによる要素認識を採用しており、HTMLが変更されても要素を識別しようとします。この機能は画面の小規模な変更に対して非常に強く、手動でのメンテナンスを大幅に削減できます。特にデザイン調整が頻繁に行われる開発フェーズでは、この自動修復機能が大きな価値を発揮します。複雑な動的要素については、認識精度を確認しながら運用することが推奨されます。
そして、Javascriptによって要素の指定も簡単にできます。</p>
<pre><code class="language-javascript">            function getEmailInputValue() {
              var selector = &quot;#__next &gt; main &gt; div &gt; div &gt; div.o-emailPasswordForm &gt; div &gt; div.m-inputField &gt; div:nth-child(1) &gt; div &gt; div.m-inputField__container &gt; div &gt; div &gt; input[type=email]&quot;;
              var element = document.querySelector(selector);
              if (!element) {
                throw new Error(&quot;Error: cannot find the element with selector(&quot; + selector + &quot;).&quot;);
              }
              return element.value;
            }

            // 実行例
            console.log(getEmailInputValue());
</code></pre>
<h2>9. 画面の比較（ビジュアルリグレッションテスト）</h2>
<h3>Playwrightの場合:</h3>
<p><code>toHaveScreenshot()</code> メソッドでピクセルレベルの画面比較が可能です。差分の許容範囲を設定したり、特定領域をマスクしたりできます。細かい視覚的変更の検出に優れており、意図しないUI変更を確実に捉えます</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>画面全体の変更を検出し、AIが変更箇所を識別します。特に大規模なデザイン変更時には、変更点の確認とテストシナリオの更新が比較的容易です。AIによる変更の影響分析機能により、どのテストシナリオを更新すべきかの判断がしやすく、大規模リニューアル時のメンテナンス工数を削減できる点が優れています。</p>
<h2>10. スクリプトの実装難易度</h2>
<h3>Playwrightの場合:</h3>
<p>JavaScript/TypeScriptなどのプログラミング言語とテストフレームワークの知識が必要です。習得には一定の時間がかかりますが、公式ドキュメントが充実しており、コミュニティも活発です。エンジニアチームが確立されている組織に適しています。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>ブラウザ操作を記録するだけでテストシナリオが作成できるため、プログラミング経験がない非エンジニアでも容易に使用できます。この実装の容易さは、Autify NoCodeWebの最大の強みの一つです。QA専任メンバーが主体となってテスト自動化を推進できるため、エンジニアリソースが限られている組織や、迅速にテスト自動化を開始したい場合に特に有効です。</p>
<h2>11. スクリプトの修正難易度</h2>
<h3>Playwrightの場合:</h3>
<p>テキストエディタでスクリプトを直接編集できるため、小規模な修正は数秒で完了します。バージョン管理システム（Git）との親和性も高く、差分確認やロールバックが容易です。複数人での並行開発やコードレビュー文化とも相性が良いです。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>GUI上で操作を再記録するか、手動で修正する必要があります。ただし、AIによる自動修復機能により、画面の小規模な変更には自動的に対応されるため、実際の修正作業は最小限に抑えられます。この自動修復機能は、メンテナンスコストの削減に大きく貢献します。</p>
<h1>12. スクリプトの実行速度</h1>
<h3>Playwrightの場合:</h3>
<p>ヘッドレスモードでの実行やネットワークリクエストの最適化により、非常に高速なテスト実行が可能です。並列実行にも標準対応しており、大規模なテストスイートでも短時間で完了します。</p>
<h3>Autify NoCodeWebの場合:</h3>
<p>クラウドベースのプラットフォームであり、ネットワークレイテンシーや処理のオーバーヘッドにより、Playwrightと比較して実行速度が遅くなる傾向があります。ただし、実際の速度差はテストケースの複雑さ、ネットワーク環境、Autify NoCodeWebのサーバー負荷などの要因によって大きく変動する可能性があります。大規模なテストスイートでは実行時間が増加する可能性がありますが、並列実行機能を活用することで全体の実行時間を最適化できます。</p>
<p>:::message
実行速度は環境やテストケースによって大きく異なるため、具体的な数値比較は控えます。各ツールの特性を理解し、実際の使用環境でのパフォーマンスを評価することをお勧めします。
:::</p>
<h1>追加の比較ポイント</h1>
<h2>📊 要約比較表</h2>
<table>
<thead>
<tr>
<th align="left">項目</th>
<th align="left">Playwright (エンジニア主導)</th>
<th align="left">Autify NoCodeWeb (QA・非エンジニア主導)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>コスト</strong></td>
<td align="left">完全無料 (OSS)</td>
<td align="left">サブスクリプション型 (有料)</td>
</tr>
<tr>
<td align="left"><strong>導入障壁</strong></td>
<td align="left">プログラミングスキルが必要</td>
<td align="left">低い (ノーコードで即時開始)</td>
</tr>
<tr>
<td align="left"><strong>CI/CD</strong></td>
<td align="left">柔軟かつ強力な統合</td>
<td align="left">シンプルなAPI連携</td>
</tr>
<tr>
<td align="left"><strong>メンテナンス</strong></td>
<td align="left">コードベース・Git管理</td>
<td align="left">AIによる自動修復 (Self-healing)</td>
</tr>
</tbody></table>
<h2>1. コストと導入障壁</h2>
<h3>Playwright:</h3>
<ul>
<li>完全無料のオープンソース</li>
<li>学習コストは必要だが、長期的なランニングコストはゼロ</li>
<li>CI/CD環境への組み込みも容易</li>
<li>エンジニアチームの人件費は考慮が必要</li>
</ul>
<h3>Autify NoCodeWeb:</h3>
<ul>
<li>サブスクリプション型の有料サービス</li>
<li>初期導入が簡単で、迅速にテスト自動化を開始できる</li>
<li>テストシナリオ数や実行回数に応じた費用体系</li>
<li>インフラ管理コストが不要</li>
<li>エンジニアリソースが限られている場合、トータルコストで優位性がある場合も</li>
</ul>
<h2>2.チーム構成との適合性</h2>
<h3>Playwrightが適しているチーム:</h3>
<ul>
<li>エンジニア主導のQA体制が整っている</li>
<li>コードレビュー文化が定着している</li>
<li>複雑なテストロジックや高度なカスタマイズが必要</li>
<li>Git等のバージョン管理システムを活用している</li>
</ul>
<h3>Autify NoCodeWebが適しているチーム:</h3>
<ul>
<li>QA専任メンバーが中心（プログラミング経験が少ない）</li>
<li>エンジニアリソースが限られている</li>
<li>迅速にテスト自動化を開始したい</li>
<li>メンテナンスコストを抑えたい（AIによる自動修復活用）</li>
<li>ノーコードでテスト資産を管理したい</li>
</ul>
<h2>3. CI/CD統合</h2>
<h3>Playwright:</h3>
<ul>
<li>GitHub Actions、GitLab CI、Jenkins など主要CI/CDツールとの統合が容易</li>
<li>テスト結果のレポート生成、アーティファクト保存が柔軟</li>
<li>並列実行、シャーディングなど高度な実行戦略が可能</li>
<li>開発フローに深く統合できる</li>
</ul>
<h3>Autify NoCodeWeb:</h3>
<ul>
<li>APIを介したCI/CD統合が可能</li>
<li>独自のテスト実行環境を使用</li>
<li>クラウドベースのため、インフラ管理不要
CI/CD統合の設定がシンプル</li>
</ul>
<h2>4.メンテナンス性と長期運用</h2>
<h3>Playwright:</h3>
<ul>
<li>スクリプトをバージョン管理できる</li>
<li>リファクタリングやスクリプトの再利用が容易</li>
<li>コミュニティが活発で、最新のベストプラクティスにアクセスしやすい</li>
<li>長期的なスクリプト資産の管理に優れる</li>
</ul>
<h3>Autify NoCodeWeb:</h3>
<ul>
<li>AIによる要素の自動認識で、画面変更時のメンテナンス工数を削減</li>
<li>自動修復機能により、軽微な変更への対応が自動化される</li>
<li>プラットフォーム上での一元管理が可能</li>
<li>ノーコードのため、担当者の変更による影響が少ない</li>
</ul>
<h1>それぞれのツールが特に優れているシーン</h1>
<h2>Playwrightが最適なケース</h2>
<ul>
<li>大量のデータパターンテスト: 同一ロジックで数百〜数千パターンのテストデータを処理する必要がある場合</li>
<li>高頻度の実行: CI/CDパイプラインで1日に何度もテストを実行し、迅速なフィードバックが必要な場合</li>
<li>複雑なファイル操作: CSV編集、複数ファイルの同時アップロード、ダウンロードファイルの内容検証など</li>
<li>エンジニア主導のQA: 開発チームとQAチームが密接に連携し、テストスクリプトもコードレビューの対象とする場合</li>
<li>長期的な資産管理: テストスクリプトをソースコードと同様に管理し、継続的に改善していく場合</li>
</ul>
<h2>Autify NoCodeWebが最適なケース</h2>
<p>迅速な導入: プログラミング経験のないQAメンバーが、短期間でテスト自動化を開始したい場合
メール連携テスト: 外部メールの検証を含むシナリオが多い場合
頻繁なUI変更: デザイン調整が頻繁に行われる環境で、AI自動修復機能を活用したい場合
インフラ管理の負担軽減: テスト実行環境の構築・管理リソースが限られている場合
ノーコード資産管理: テスト資産をコード化せず、ビジュアルに管理したい場合
大規模リニューアル: 画面全体の大幅な変更時に、AIによる影響分析と効率的な更新が必要な場合</p>
<h2>KTCにおける選択理由</h2>
<p>KTCでは、これまでAutify NoCodeWebによって品質保証の基盤を築いてきましたが、プロジェクトの成長と共にいろいろな課題が顕在化しました。（上記のプロジェクト背景で述べた課題）
これら課題を解決する選択肢として、Playwrightを導入することにしました。ただし、これはAutify NoCodeWebを完全に置き換えるものではありません:</p>
<p>Playwrightが担う領域: データ駆動テスト、高速実行が求められるCI/CD統合、複雑なファイル操作を伴うシナリオ
Autify NoCodeWebが引き続き価値を発揮する領域: メール連携テスト、ノーコードで管理すべきシナリオ、QA専任メンバーが主導するテスト</p>
<p>両ツールの強みを活かしたハイブリッドアプローチにより、KTCの品質保証体制をさらに強化していく予定です。</p>
<h1>まとめ：どちらを選ぶべきか</h1>
<h2>Playwrightの主な強み:</h2>
<ul>
<li>高速な実行速度</li>
<li>柔軟なカスタマイズ性</li>
<li>精密な要素制御とデータ駆動テスト</li>
<li>オープンソースでコストゼロ</li>
<li>バージョン管理システムとの親和性</li>
</ul>
<h2>Autify NoCodeWebの主な強み:</h2>
<ul>
<li>ノーコードで実装が容易</li>
<li>AIによる自動修復でメンテナンスコスト削減</li>
<li>統合されたメール検証機能</li>
<li>非エンジニアでも運用可能</li>
<li>インフラ管理不要</li>
</ul>
<p>最適な選択は、組織の状況によって異なります:</p>
<ul>
<li>エンジニアリソースが限られ、迅速にテスト自動化を開始したい → Autify NoCodeWeb</li>
<li>エンジニアチームが確立され、高度なカスタマイズと高速実行が必要 → Playwright</li>
<li>両方のメリットを活かしたい → ハイブリッドアプローチ</li>
</ul>
<p>どちらのツールも、現代のWebアプリケーション開発において品質を担保するための重要な選択肢です。本記事の比較内容を参考に、自社のチーム構成、スキルセット、プロジェクト要件、予算、長期的な運用計画などを総合的に考慮して、最適なツールを選定してください。
Autifyは世界中で支持されているノーコードテスト自動化プラットフォームであり、特にエンジニアリソースが限られている組織において、品質保証体制を迅速に構築できる優れたソリューションです。KTCも、Autifyのサービスを通じて多くの成果を上げてきました。
今後も、両ツールの進化に注目し、それぞれの強みを最大限に活用していくことが重要です。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/blog/authors/ro/6.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[技術者と障害当事者がチームで実現するアクセシビリティの「当たり前品質」：デブサミ2026参加レポート]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-11-devsumi-accessibility-as-standard/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-11-devsumi-accessibility-as-standard/</guid>
            <pubDate>Wed, 11 Mar 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[2月19日に参加したGovTech東京のアクセシビリティセッションの様子をレポートします]]></description>
            <content:encoded><![CDATA[<p>こんにちは。Engineering OfficeのAccessibility Advocate、辻勝利です。
少し前になりますが、2月19日にDevelopers Summit 2026（デブサミ2026）に参加し、一般財団法人GovTech東京によるセッション「アクセシビリティを“あたりまえ品質”に！！」を傍聴してきました。</p>
<p>登壇者の一人である松村道生さんは私の知人であり、同時期に新たな環境へ身を投じた仲間でもあります。彼がGovTech東京という組織において、どのようにアクセシビリティ推進を開発プロセスに組み込んでいるのか、その実践を参考にしたいと考えたのが参加の動機でした。</p>
<p>30分という限られた時間でしたが、アクセシビリティを「付加価値」ではなく「当然備わっているべき品質」と定義し、組織的に取り組む姿勢が非常に明確なセッションでしたので、今回はその内容を簡単にお伝えします。</p>
<h2>1. 効率化の裏側にある「課題」の実態</h2>
<p>セッションの前半では、視覚障害当事者でもある松村さんより、現在のデジタル化・効率化がもたらした課題が共有されました。</p>
<p>近年、サービスの効率化や自動化が「良いこと」として捉えられる傾向があり、様々なところで実際にいろいろなサービスの効率化が図られています。
もちろん、人材不足などの様々な要因により致し方ないと考えられる側面もありますが、下記の事例は私たち視覚障害者の「それでは済まされない現実」をあらわにする内容で、私も一つ一つうなずきながら聞きました。</p>
<ul>
<li>マイナンバー設定の課題： 役所にスクリーンリーダー環境が整備されていなかったため、秘匿すべきパスワードを職員に口頭で伝えて代筆・設定してもらうしかなかった経験。</li>
<li>対面サービスの減少： 駅の「みどりの窓口」削減により自動券売機が主流となったことで、独力での切符購入が困難になった現状。</li>
<li>行政申請の壁： コロナ禍のワクチン接種予約など、視覚障害者が独力で完結できない設計のままリリースされたサービスの実態。</li>
</ul>
<p>これらの事例を通じて、「世の中を便利にするための自動化が、結果として一部の都民を排除してしまっている」という切実な現状が示されました。</p>
<h2>2. 行政サービスにおける「唯一性」と責任</h2>
<p>特に印象に残ったのが、行政サービス特有の責任に関するお話でした。</p>
<p>民間サービスであれば、もし「サービスA」がアクセシビリティの問題で使えなくても、ユーザーは代替手段として「サービスB」を選択できる可能性があります。しかし、行政サービスである「東京アプリ」は唯一無二の存在であり、他に選択肢がありません。</p>
<p>「使えないから他を使う」という逃げ道がない以上、最初から全都民が等しく使える状態でリリースしなければならない。この「代替不可能な公共インフラとしての責任感」が、GovTech東京がアクセシビリティを最優先事項に据える最大の根拠であることを再認識しました。</p>
<h2>3. シフトレフト：開発工程へのアクセシビリティの組み込み</h2>
<p>山内晨吾さんが担当されたパートでは、これらの課題を「後付け」ではなく、開発の最上流から解決する「シフトレフト」の実践手法が紹介されました。</p>
<ul>
<li>デザイン段階からの設計（Figma）： コンポーネント単位で要件を定義し、UI設計時に品質を確保。</li>
<li>テストコードによる自動検証： 機械的にチェック可能な項目を自動化し、デグレード（品質低下）を防止。</li>
<li>AIレビューの活用： LLM（大規模言語モデル）等を活用し、コードレビュー段階でアクセシビリティの不備を検知。</li>
</ul>
<p>GovTech東京では、山内さん（エンジニア）と松村さん（当事者視点）が密に連携し、技術的な仕組みと実際の課題の体験が双方向でフィードバックされる体制が確立されています。チームとして高度に機能していることが、発表の端々から伝わってきました。</p>
<h2>4. 「なくては困る」を基準にする開発文化</h2>
<p>セッションの核となっていた、アクセシビリティを「あったらいいね（魅力品質）」から「なくては困る（当たり前品質）」へ変えていくという視点は、私が取り組んでいる「アクセシビリティを社内文化にする」という活動とも強く共鳴するものです。</p>
<p>この業界で20年以上アクセシビリティの啓発に従事していますが、当事者意識（オーナーシップ）と技術的な合理性がこれほど高いレベルで融合した発表には、なかなか出会えるものではありません。</p>
<h2>おわりに</h2>
<p>イベント終了後の「Ask the Speaker」では、お二人に直接ご挨拶する機会を得ました。現場で格闘している方々と対話し、今後の連携の可能性についても言葉を交わせたことは大きな収穫でした。</p>
<p>今回のセッションで得た知見を、私自身のプロジェクトにおける「アクセシビリティの文化定着」にも確実に活かしていきたいと考えています。</p>
<hr>
<p>参考リンク</p>
<ul>
<li><a href="https://event.shoeisha.jp/devsumi/20260218/session/6457">デブサミ2026 セッション詳細</a></li>
<li><a href="https://codezine.jp/news/detail/23205">CodeZine：アクセシビリティのシフトレフトを実現！「東京アプリ」の開発プロセス改善</a></li>
</ul>
<hr>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/common/thumbnail_default_×2.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[ソフトウェアの依存関係アップデートはRenovateにした理由]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-11-ソフトウェアの依存関係アップデートはRenovateにした理由/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-11-ソフトウェアの依存関係アップデートはRenovateにした理由/</guid>
            <pubDate>Wed, 11 Mar 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[ソフトウェアの依存関係アップデートをDependabotと迷い続けた結果、Renovateに決定した理由について紹介します。]]></description>
            <content:encoded><![CDATA[<h1>ソフトウェアの依存関係アップデートはRenovateにした理由</h1>
<p>DBREグループで、DevSecOps担当を自称している栗原です。</p>
<p>タイトルの通り、ソフトウェア依存モジュールのアップデートにRenovateを採用しました。GitHub Dependabotと迷い続けましたが、この記事で紹介するDependabotにはない3つの利点が非常に魅力的だったため、Renovateを採用するにいたりました。Renovateを紹介している記事はよく見かけるので、あまり語られていないおすすめの実行方法についてと、私が惹かれた3つのポイントについて説明します。</p>
<h2>Renovateとは</h2>
<p><a href="https://github.com/renovatebot/renovate">Renovate</a>は、ソフトウェアの依存関係を自動でアップデートしてくれるOSSツールです。Dependabotと同様に、リポジトリのルートに設定ファイル（<code>renovate.json</code>）を配置して、Renovateを実行すると、依存関係のアップデートPRを自動で送ってくれます。</p>
<p>2026年3月現在は無料で利用可能ですが、<a href="https://www.mend.io/renovate/">Mend社による買収</a>後、将来的に有償化される可能性がある点は留意しておく必要があります。ただし、現時点ではOSSとして活発に開発が続いており、セルフホスティングも可能なため、柔軟な運用が可能です。</p>
<p>類似機能である、Dependabotとの詳細比較は<a href="https://docs.renovatebot.com/bot-comparison/">公式のbot比較ページ</a>に譲りますが、Dependabotより高機能なのは間違いないです。個人的には設定の柔軟性が圧倒的に高く、複数リポジトリでの設定の共通化など、エンタープライズでの利用に適していると感じています。</p>
<p>ちなみに、この記事ではSCMはGitHubであることを前提にしておりますが、GitLabなど他のSCMを使われている方にも参考になるかと思います。</p>
<h2>おすすめの実行方法</h2>
<p>他社さんの記事などをみかけると、<a href="https://github.com/apps/renovate">Mend Renovate App</a>（一番手っ取り早い）、<a href="https://github.com/renovatebot/github-action">公式のGitHub Actions</a>が紹介されていることが多いですが、私がおすすめしたいのは、<a href="https://docs.renovatebot.com/self-hosted-configuration/">CLIでの実行</a>です。</p>
<p>Renovateは依存定義ファイル（package.json）だけではなく、lockfile（package-lock.json）も更新してくれますが、その際に実行環境にインストールされているパッケージマネージャ（npm）を実行して実現します。つまり実際の開発環境と同じツールを使えるのがベストなわけです。前者の2つは、プレビルドされた環境はあるものの、厳密にやろうとすると、Renovate実行用のコンテナをカスタマイズするなどが必要ですが、CLI実行であれば、他のワークフローで使っている環境セットアップの処理がそのまま転用できます。</p>
<p>特に我々は<a href="https://blog.kinto-technologies.com/posts/2023-05-29-serverless-with-monorepo-nx/">Monorepo</a>を採用しており、複数の言語、パッケージマネージャ（Go、Python uv、Node.js yarn等）を使っているプロジェクトでは、それぞれのツールのバージョンを揃える必要があるため、CLI実行の恩恵が大きいです。</p>
<p>こちらは実際に我々が使っているGitHub Actionsです。</p>
<pre><code class="language-yaml">name: Update Deps Via Renovate
on:
  schedule:
    - cron: &#39;0 * * * *&#39;
  workflow_dispatch:

concurrency:
  group: &quot;${{ github.event.repository.name }}-update-deps-via-renovate&quot;
  cancel-in-progress: false

env:
  LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL || &#39;&#39; }}

jobs:
  renovate:
    runs-on: ubuntu-latest
    steps:
      # 他のワークフローとも共通化しているセットアッププロセス
      - name: checkout codebase and setup runtime
        id: setup-runtime
        uses: kinto-dev/action-dbre-setup-runtime@v3
        with:
          # 後ほど紹介しますが、共通renovate設定ファイルを利用するため、
          # 通常のGITHUB_TOKENではなく、GitHub Appのインストールアクセストークンを利用
          github-app-id: ${{ vars.GH_APP_ID }}
          github-app-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
          go-project: &quot;true&quot;
          python-uv-project: &quot;true&quot;

      # GitHub Actionsであれば、Node.jsランタイムがデフォルトでインストールされているので、npxで直接renovateを実行可能。
      - name: run renovate
        run: npx --yes --package renovate -- --token=&quot;${{ steps.setup-runtime.outputs.github-app-install-token }}&quot; &quot;${{ github.repository }}&quot;
</code></pre>
<p>以上がおすすめの実行方法です。それでは次章からおすすめの機能を紹介していきます。</p>
<h2>おすすめ機能1: インラインスクリプトもアップデートの対象にできる</h2>
<p>Dependabotでは基本的に設定ファイル（package.jsonやgo.mod等）に定義された依存関係のみがアップデート対象になりますが、Renovateは<a href="https://docs.renovatebot.com/modules/manager/regex/">Custom Manager</a>機能により、正規表現でマッチさせた任意の文字列をアップデート対象にできます。</p>
<p>例えば、以下のようにnpm scriptとしてDocker Hubのイメージをタグ指定して実行しているケースを考えてみます。</p>
<pre><code class="language-json">// package.json
{
  &quot;scripts&quot;: {
    &quot;gha_lint&quot;: &quot;docker run -i --init --rm -v $INIT_CWD/.github/workflows:/workflows rhysd/actionlint:1.7.7 -color $(ls .github/workflows/*.yml | awk -F &#39;/&#39; &#39;{print \&quot;/workflows/\&quot;$NF}&#39;)&quot;
  }
}
</code></pre>
<p>このようなインラインスクリプトに依存モジュールのバージョンがハードコードされるケースも、Renovateはアップデートの対象にしてくれます。renovate.jsonに以下の設定を追加するだけで実現できます。</p>
<pre><code class="language-json">&quot;customManagers&quot;: [
    {
      &quot;customType&quot;: &quot;regex&quot;,
      &quot;fileMatch&quot;: [
        &quot;^package\\.json$&quot;
      ],
      &quot;matchStrings&quot;: [
        &quot;docker run [^;]*? (?&lt;depName&gt;[^:\\s]+):(?&lt;currentValue&gt;[^\\s]+)&quot;
      ],
      &quot;datasourceTemplate&quot;: &quot;docker&quot;,
      &quot;versioningTemplate&quot;: &quot;docker&quot;,
      &quot;depTypeTemplate&quot;: &quot;shell-script-docker-inline&quot;
    }
]
</code></pre>
<p>この設定により、<code>rhysd/actionlint:1.7.7</code>の部分が検出され、新しいバージョンがリリースされると自動でPRが作成されます。正規表現でマッチングするため、Dockerfile、シェルスクリプト、CI/CDの設定ファイルなど、あらゆるファイルに対して適用可能です。Dependabotではカバーできない領域まで自動アップデートの対象にできるのは、運用負荷の軽減に大きく貢献します。</p>
<h2>おすすめ機能2: ローカルで設定ファイルをデバッグできる</h2>
<p><a href="https://docs.renovatebot.com/configuration-options/#packagerules">アップデートPRのグルーピング</a>など、ファインチューニングをしようと思うと、設定ファイルのトライアンドエラーがつらいです。これはDependabotでも同じだと思いますが、Renovateは開発PCでも動かせるCLIがあるため、手元でカジュアルに設定ファイルとにらめっこが可能です。</p>
<pre><code class="language-bash">$ LOG_LEVEL=debug npx renovate --platform=local --dry-run=full | tee renovate-dryrun.txt
</code></pre>
<p>この<code>--dry-run</code>オプションを使うと、実際にPRを作成せずに、どのような更新が検出されるかをローカルで確認できます。設定を変更して即座に結果を確認できるため、トライアンドエラーのサイクルが非常に高速です。</p>
<p>設定ファイルのvalidatorも付随しています。</p>
<pre><code class="language-bash">$ npx --yes --package renovate -- renovate-config-validator
</code></pre>
<p>このコマンドで、renovate.jsonの構文エラーや設定の妥当性をチェックできます。CI/CDに組み込んでおけば、設定ミスによる実行エラーを事前に防ぐことができます。</p>
<p>えっ...しょぼくない？と思われたかもしれませんが、Dependabotの場合は設定を変更するたびにGitHubにpushして結果を待つ必要があり、フィードバックループが長いです。Renovateはローカルで即座に確認できるため、スピーディーに設定ファイルを完成させることができました。個人的には大きなメリットであると考えます。</p>
<h2>おすすめ機能3: 設定ファイルを共通化できる</h2>
<p>苦労して完成させた設定ファイルをSSOT（Single Source of Truth）にしたいですよね。Renovateには<a href="https://docs.renovatebot.com/config-presets/">Config Presets</a>という、設定ファイルの共通化機能があります。</p>
<p>共通設定リポジトリに<code>default.json</code>を配置し、そこにベースとなる設定を記述します。例えば、PRのラベル設定、スケジュール設定、グルーピングルールなど、組織として統一したい設定をまとめておきます。</p>
<p>利用側の設定ファイルはこれだけで済みます。</p>
<pre><code class="language-json">{
  &quot;$schema&quot;: &quot;https://docs.renovatebot.com/renovate-schema.json&quot;,
  &quot;extends&quot;: [&quot;github&gt;kinto-dev/dbre-renovate-config&quot;]
}
</code></pre>
<p><code>github&gt;</code>プレフィックスでGitHubリポジトリを指定するだけで、共通設定を読み込むことができます。ブランチやタグを指定することも可能です（例: <code>github&gt;kinto-dev/dbre-renovate-config#v1.0.0</code>）。</p>
<p>もちろん、利用側リポジトリ特有の設定を拡張することもできます。</p>
<pre><code class="language-json">{
  &quot;$schema&quot;: &quot;https://docs.renovatebot.com/renovate-schema.json&quot;,
  &quot;extends&quot;: [&quot;github&gt;kinto-dev/dbre-renovate-config&quot;],
  &quot;ignorePaths&quot;: [
    &quot;backup/**&quot;
  ]
}
</code></pre>
<p>この機能により、複数リポジトリで共通の設定を使いつつ、各リポジトリ固有の要件にも対応できます。組織で管理するリポジトリが増えれば増えるほど、この機能の恩恵は大きくなります。</p>
<h2>まとめ</h2>
<p>以上、Renovateのおすすめの実行方法と、Dependabotにはない3つの魅力的な機能を紹介させていただきました！</p>
<p>改めてまとめると以下の通りです。</p>
<ol>
<li><strong>CLI実行で開発環境と同じツールを使える</strong> - 既存のCI/CDワークフローを流用できる</li>
<li><strong>インラインスクリプトもアップデート対象にできる</strong> - Custom Managerで正規表現マッチング</li>
<li><strong>ローカルでデバッグできる</strong> - 高速なフィードバックループで設定を洗練できる</li>
<li><strong>設定ファイルを共通化できる</strong> - 複数リポジトリで一貫した運用が可能</li>
</ol>
<p>最近全部AIでいいんじゃないかと思うことも多々ありますが、決められた定型作業であれば非AIツールもまだまだ有益だと信じて、ツールボックスを拡充していければと思います。お読みいただきありがとうございます。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/common/thumbnail_default_×2.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[AWS Configの記録頻度最適化でコストを大幅削減]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-10-aws-config-cost-optimization/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-10-aws-config-cost-optimization/</guid>
            <pubDate>Tue, 10 Mar 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[AWS Configの記録頻度を最適化し、セキュリティ要件を維持しながら月間コストを約80%削減した取り組みを紹介します。]]></description>
            <content:encoded><![CDATA[<h2>はじめに</h2>
<p>こんにちは、KINTOテクノロジーズ CloudSecurityグループの小林です。</p>
<p>皆さん、AWS Configのコストが高いなと思ったことはありませんか？
今回、記録方式の最適化で約80%のコスト削減を実現しました。
本記事ではその過程と得られた知見を共有します。</p>
<h2>本記事の対象読者</h2>
<ul>
<li>AWS Configのコストが気になっている方</li>
<li>AWSのコスト最適化に取り組んでいる方</li>
<li>セキュリティ要件とコストのバランスを考えている方</li>
<li>大規模なAWS環境を運用している方</li>
<li>Control TowerやOrganizationsを使って複数アカウントを管理している方</li>
</ul>
<h2>AWS Configとは</h2>
<p>AWS Configは、AWSリソースの設定変更を記録・追跡するサービスです。</p>
<p>主な用途は以下の通りです：</p>
<ul>
<li>リソース設定の変更履歴を記録</li>
<li>コンプライアンスルールへの準拠状況を監視</li>
<li>セキュリティ基準違反の検知</li>
<li>設定変更の監査証跡として活用</li>
</ul>
<h2>背景：なぜ見直しが必要だったのか</h2>
<p>AWS Configは便利なサービスですが、
記録回数に応じて課金されるため、大規模環境ではコストが大きくなりがちです。</p>
<p>主な要因は以下の通りです。</p>
<ul>
<li>記録対象リソースの増加（特にネットワーク関連リソース）</li>
<li>連続記録モードによる頻繁な記録</li>
</ul>
<h3>Control Tower環境での課題</h3>
<p>弊社では AWS Control Towerを使用して複数アカウントを管理しています。
AWS Configのコスト削減を検討する中で、取りうる選択肢は以下の2つでした。</p>
<p><strong>選択肢1: 現状維持（全て連続記録）</strong></p>
<ul>
<li>コストが高いまま</li>
<li>Control Towerのベストプラクティスに従う</li>
</ul>
<p><strong>選択肢2: StackSet (aws-control-tower-customizations) で記録頻度を最適化</strong></p>
<p><strong>AWSソリューション:</strong> <a href="https://github.com/aws-solutions/aws-control-tower-customizations">aws-control-tower-customizations</a></p>
<ul>
<li>大幅なコスト削減が期待できる</li>
<li>Control Towerが作成したConfigリソースを変更することになる</li>
</ul>
<h3>Control Tower のベストプラクティスとの兼ね合い</h3>
<p>AWS Control Towerの公式ドキュメントには、以下のような記載があります：</p>
<blockquote>
<p>「AWS Control Towerによって作成されたリソースを変更または削除しないでください。」</p>
</blockquote>
<p>出典元: <a href="https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/getting-started-guidance.html">AWS Control Tower リソースの作成と変更に関するガイダンス</a></p>
<p>この記載により、選択肢2の採用には慎重な姿勢を取らざるを得ませんでした。</p>
<p><strong>具体的な懸念事項は以下の通りです。</strong></p>
<ul>
<li>Configレコーダーの変更がControl Towerの動作に影響しないか</li>
<li>ドリフト検出機能が正しく動作するか</li>
<li>コンプライアンスレポートの正確性が保たれるか</li>
<li>ランディングゾーンの更新やOUの再登録が必要にならないか</li>
</ul>
<p>このため、コスト削減の必要性は認識していたものの、実施に踏み切れない状況が続いていました。</p>
<h3>記録回数の実態</h3>
<p>記録回数を調査したところ、以下のリソースタイプの記録回数が特に多いことがわかりました。</p>
<p><strong>記録回数が多かったリソースタイプ TOP4：</strong></p>
<ol>
<li>EC2 NetworkInterface - ネットワークインターフェースの状態変化</li>
<li>EC2 Subnet - サブネットの状態変化</li>
<li>EC2 SecurityGroup - セキュリティグループの関連付け変化</li>
<li>Config ResourceCompliance - コンプライアンスチェック</li>
</ol>
<p><strong>なぜこれらの記録回数が多いのか？</strong></p>
<p>連続記録モードでは、リソースに何らかの変更（内部状態の変化も含む）があるたびに記録されます。
これらのリソースの記録が多い原因は、ENIの作成/削除を起点とした連鎖的な記録にありました。</p>
<p><img src="/assets/blog/authors/i.kobayashi/2026-03/01.png" alt="ENI変更を起点とした連鎖的な記録の流れ"></p>
<p><strong>① EC2 NetworkInterface（根本原因）</strong></p>
<ul>
<li>ECSタスクの起動/停止に伴いENIが頻繁に作成・削除されており、そのたびにConfigの記録が発生<ul>
<li>VPC接続を有効化したLambda関数の場合も同様です。</li>
</ul>
</li>
</ul>
<p><strong>② EC2 Subnet（ENI に連動）</strong></p>
<ul>
<li>ENIの作成・削除に伴い、対象のサブネットの設定項目が記録<ul>
<li>VPC接続を有効化したLambda関数の作成時も同様です。</li>
</ul>
</li>
</ul>
<p><strong>③ EC2 SecurityGroup（ENI に連動）</strong></p>
<ul>
<li>ENIの作成・削除に伴い、その ENIに関連付けられたSecurityGroupの設定項目も記録</li>
</ul>
<p><strong>④ Config ResourceCompliance（すべてに連動）</strong></p>
<ul>
<li><code>AWS::Config::ResourceCompliance</code>は、Configルールによって評価されたリソースのコンプライアンス状態の変化を記録するリソースタイプです。<ul>
<li>上記の各リソースで新しい設定項目が記録されるたびにConfigルールの評価が走り、その結果がResourceComplianceとして記録されます。</li>
</ul>
</li>
</ul>
<p><strong>まとめると:</strong>
ENIの変更が起点となり、関連するSubnet、SecurityGroupの記録が連鎖的に発生し、
さらにそれぞれのConfigルール評価が走ることで、記録回数が増加していました。
コンテナやサーバーレスを多用している環境ほど、この傾向は顕著になります。</p>
<h2>解決策：記録頻度の最適化</h2>
<p>検証の結果、選択肢2の<a href="https://github.com/aws-solutions/aws-control-tower-customizations">aws-control-tower-customizations</a>はControl Towerの検出コントロールやドリフト検出に影響しないことが判明しました。
こちらのソリューションはControl Tower側で変更があった場合にもドリフトが発生しないよう設計されているため、安全に記録頻度の変更を展開できると判断し、選択肢2の実施に踏み切りました。</p>
<h3>方針：リソースタイプごとに記録方式を分ける</h3>
<p>すべてのリソースを一律に変更するのではなく、コスト構造を分析した上でリソースタイプごとに最適な記録方式を選択しました。</p>
<p><strong>日次記録に変更したリソース：</strong></p>
<ul>
<li>EC2 NetworkInterface</li>
<li>EC2 Subnet</li>
<li>EC2 SecurityGroup</li>
</ul>
<p><strong>連続記録のまま維持したリソース：</strong></p>
<ul>
<li>上記以外のリソースタイプ</li>
<li>Config ResourceCompliance<ul>
<li><a href="https://docs.aws.amazon.com/ja_jp/config/latest/developerguide/select-resources-console.html#select-resources-console-considerations">日次記録非対応</a></li>
</ul>
</li>
</ul>
<p>連続記録は記録回数ベース、日次記録はリソース数ベースの課金です。
記録回数がリソース数に対して大幅に多いリソースタイプだけを日次記録に変更し、
それ以外は連続記録のまま維持するのが最もコスト効率が良い方法です。</p>
<h3>展開方法</h3>
<p>記録頻度の変更は<a href="https://github.com/aws-solutions/aws-control-tower-customizations">aws-control-tower-customizations</a>を利用し、管理アカウント上でCloudFormationテンプレートを展開することで、Control Tower管理下の全アカウントに一括適用しました。</p>
<h3>セキュリティへの影響</h3>
<p>日次記録にすると、変更の途中経過は記録されません。
また、Configルールの評価タイミングはルールのトリガー方式（変更通知トリガーか、定期評価か）によって異なります。</p>
<p>スケジュールベースの定期評価ルールは、記録頻度にかかわらず設定された評価間隔で実行されます。
一方で、設定変更検知ベース（変更通知トリガー）のルールについては、日次記録の場合、評価に利用される設定情報が最大24時間前の状態となるため、実際の設定違反検知が最大24時間遅延しうる点に注意が必要です。</p>
<p>ただし、弊社環境では以下のサービスと併用することで、セキュリティ要件は維持できると判断しました。</p>
<ul>
<li>CloudTrailでAPIレベルの変更履歴は引き続き記録される</li>
<li>Security Hubでのセキュリティ準拠チェック</li>
<li>GuardDutyでの異常検知</li>
<li>SIEMサービスを利用した通知・分析</li>
</ul>
<h2>結果</h2>
<p>以下はCost Explorerでの日別コスト推移です。
切り替え前後でコストが大幅に低下していることがわかります。</p>
<p><img src="/assets/blog/authors/i.kobayashi/2026-03/02.png" alt="Cost Explorer日別コスト推移"></p>
<h2>学び・Tips</h2>
<h3>1. コスト構造の理解が重要</h3>
<p>AWS Configの料金は「記録回数」に基づくため、以下を理解することが重要です。</p>
<ul>
<li>どのリソースタイプが多く記録されているか</li>
<li>なぜそのリソースが頻繁に記録されるのか</li>
<li>記録頻度を変更できるリソースはどれか</li>
</ul>
<p>リソースタイプ別の記録回数は、CloudWatch メトリクス（AWS/Config ネームスペース）の<code>ConfigurationItemsRecorded</code>をResourceType別に確認できます。
AIエージェントにCloudWatchを参照させて調査することもできます。
また、リソース数は以下のコマンドで取得できます。</p>
<pre><code class="language-bash">aws configservice get-discovered-resource-counts --region ap-northeast-1
</code></pre>
<h3>2. この最適化が向いていないケース</h3>
<ul>
<li>すべてのリソースでリアルタイム記録が必須</li>
<li>変更の途中経過も記録が必要</li>
<li>セキュリティ要件が厳しく、日次記録では不十分</li>
<li>Configルールを利用した自動修復などを利用している</li>
</ul>
<h2>まとめ</h2>
<p>今回は記録回数の多いリソースタイプを特定し、日次記録に切り替えることで約80%のコスト削減を実現しました。
セキュリティ要件はCloudTrail、Security Hub、SIEMなどで補完できるため、実運用上の問題もありません。</p>
<p>本記事が同様の課題を抱えている方の参考になれば幸いです。</p>
<h2>参考資料</h2>
<ul>
<li><a href="https://aws.amazon.com/jp/config/pricing/">AWS Config 料金</a></li>
<li><a href="https://docs.aws.amazon.com/config/latest/developerguide/stop-start-recorder.html">AWS Config の記録モード</a></li>
</ul>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/common/thumbnail_default_×2.png" length="0" type="image/png"/>
        </item>
        <item>
            <title><![CDATA[Claude Code のサンドボックス機能を徹底検証してみた]]></title>
            <link>https://blog.kinto-technologies.com/posts/2026-03-09-claude-code-sandbox/</link>
            <guid>https://blog.kinto-technologies.com/posts/2026-03-09-claude-code-sandbox/</guid>
            <pubDate>Mon, 09 Mar 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[Claude Codeのサンドボックスとpermissions.denyの2段構えについて、仕組みの解説から10種類のバイパス試行まで、macOS環境で検証した結果をまとめました]]></description>
            <content:encoded><![CDATA[<p>こんにちは！
Principal Generative AI Engineerの森田です。私の所属するAIファーストGでは、社内の生成AI活用にとどまらず、販売店やトヨタグループにおけるAI活用支援を行っております。</p>
<p>KINTOテクノロジーズでは、AIファーストを掲げ、全社員が必要な生成AIツールを申請し利用することができます。開発に関するものだけでもClaude Code、GitHub Copilot、Devin、Kiroなど、開発者が選べる環境が整っています。</p>
<p>今回は、社内でも特に利用者が多いClaude Codeのサンドボックス機能について調査しました。サンドボックスとは、Bashコマンドの実行をファイルシステム・ネットワークの両面からOSレベルで隔離するセキュリティ機能です。</p>
<h2>はじめに</h2>
<p>Claude Codeを使っていると、こんな場面に遭遇しないでしょうか。</p>
<p>コードの修正やコマンドの実行を任せると、操作のたびに「許可しますか？（Y/N）」と確認が入ります。意図しない操作を防ぐための仕組みなので当然ではあるのですが、これが何十回と続くと正直つらい。かといって、確認なしの自動承認モードにするのは怖い。プロンプトインジェクションやサプライチェーン攻撃など、外部からの脅威を考えると、何でも自動承認するわけにはいきません。</p>
<p>毎回確認していたら承認疲れで結局よく読まずに「Y」を押し続けてしまう。これが一番よくないパターンです。私自身、まさにこの状態に陥っていました。</p>
<p><img src="/assets/blog/authors/kazuaki.morita/2026-03-09-claude-code-sandbox/image01.png" alt=""></p>
<p>そんな中、社内の勉強会で同僚の太田さんがサンドボックス機能を紹介していました。ファイルシステムとネットワークの操作範囲をOSレベルで制限することで、「この範囲内なら自由にやらせていい。万が一おかしな操作があっても、被害を最小限に抑えることができる」という状態を作れるという説明でした。</p>
<p>承認疲れから解放されつつ、セキュリティも確保できる。早速自分でも追加調査を行い、実際にどこまで堅牢なのかを手元で検証してみました。本記事はその結果をまとめたものです。なお、検証はmacOS（Seatbelt）環境で行っています。</p>
<h2>サンドボックスとは</h2>
<p>Claude Codeのサンドボックスは、Bashコマンドの実行をファイルシステム・ネットワークの両面からOSレベルで隔離するセキュリティ機能です。</p>
<table>
<thead>
<tr>
<th>領域</th>
<th>デフォルトの制限</th>
</tr>
</thead>
<tbody><tr>
<td>ファイルシステム</td>
<td>カレントディレクトリ配下は読み書き可能。それ以外は読み取り専用</td>
</tr>
<tr>
<td>ネットワーク</td>
<td>許可されたドメインのみアクセス可（ホワイトリスト形式）</td>
</tr>
</tbody></table>
<p>OSのネイティブ機能で強制されるのが大きな特徴です。macOSではSeatbelt（カーネルレベルのサンドボックス機構）、Linux/WSL2ではbubblewrapが使われます。</p>
<h3>なぜ自動承認が安全になるのか</h3>
<p>サンドボックスが有効な状態では、書き込みがプロジェクト内に閉じ、ネットワーク通信も許可ドメインに制限されます。つまり、プロジェクトに関係のないファイルが破壊されたり、未許可のサーバーにデータが送信されたりすることがありません。最悪の事態がプロジェクト内に収まることが保証されるため、自動承認しても安心できるというわけです。</p>
<h3>有効化の方法</h3>
<p>設定ファイルに<code>&quot;sandbox&quot;: { &quot;enabled&quot;: true }</code>を書いておけば、<code>claude</code>コマンドで起動するだけで最初からサンドボックスが有効になります。毎回手動で有効化する必要はありません。なお、対話的に設定したい場合はClaude Codeのチャットで<code>/sandbox</code>と入力する方法もあります。</p>
<h3>2つのモード</h3>
<p>サンドボックスにはAuto-allowとRegular permissionsの2つのモードがあります。</p>
<table>
<thead>
<tr>
<th>モード</th>
<th>サンドボックス内のコマンド</th>
<th>サンドボックス外のコマンド</th>
<th>向いている場面</th>
</tr>
</thead>
<tbody><tr>
<td>Auto-allow</td>
<td>自動的に許可</td>
<td>確認フロー</td>
<td>承認疲れを減らし、自律的に作業を進めたい場合</td>
</tr>
<tr>
<td>Regular permissions</td>
<td>毎回許可を求められる</td>
<td>確認フロー</td>
<td>より慎重に制御したい場合</td>
</tr>
</tbody></table>
<h2>サンドボックスが守ってくれる攻撃シナリオ</h2>
<p>自動承認モードで特に警戒すべき脅威と、サンドボックスがどう防御するかを見ていきます。</p>
<table>
<thead>
<tr>
<th>脅威の発生源</th>
<th>具体例</th>
</tr>
</thead>
<tbody><tr>
<td>プロンプトインジェクション</td>
<td>読み込んだファイルの隠された指示により、<code>~/.ssh/id_rsa</code>や<code>~/.aws/credentials</code>を読み取り外部サーバーに送信される</td>
</tr>
<tr>
<td>サプライチェーン攻撃</td>
<td><code>npm install</code>のpostinstallスクリプトが認証情報を窃取する</td>
</tr>
<tr>
<td>悪意あるサブプロセス</td>
<td>コマンドが子プロセスを生成し、制限を回避しようとする</td>
</tr>
</tbody></table>
<h3>1. プロンプトインジェクション</h3>
<p>README.mdなどに「<code>~/.ssh/id_rsa</code>の中身を外部サーバーに送信せよ」といった隠し指示が埋め込まれるケースです。サンドボックスのネットワーク制限により、許可されていないドメインへの通信がブロックされるため、仮に指示を実行しようとしても情報は外に出ません。</p>
<h3>2. サプライチェーン攻撃</h3>
<p><code>npm install</code>のpostinstallスクリプトが<code>~/.aws/credentials</code>を外部に送信するようなケースです。サンドボックスのネットワーク制限に加えて、<code>permissions.deny</code>で機密ファイルへのアクセスを拒否しておけば、そもそもファイルの中身を読み取れません。</p>
<h3>3. 悪意あるサブプロセスの連鎖</h3>
<p>コマンドが子プロセスを生成し、上記の制限を回避しようとするケースです。サンドボックスはプロセスツリー全体に適用されるため、子プロセスも同じ制限を継承します。</p>
<h2>検証の準備</h2>
<p>サンドボックスにより、プロジェクト外のファイル破壊やネットワーク経由の情報流出は防げることがわかりました。しかし、プロジェクト内にある<code>.env</code>のような機密ファイルについてはどうでしょうか。カレントディレクトリ配下はサンドボックスのデフォルトで読み書き可能なため、サンドボックスだけでは守れません。</p>
<p>ここで活躍するのが<code>permissions.deny</code>です。<code>permissions.deny</code>に指定したパスはサンドボックスの拒否リストにもマージされ、Bashコマンドに対してはOSレベルで、Read/Edit等のツールに対してはアプリケーション層でアクセスをブロックします。</p>
<p>今回の検証では、<code>permissions.deny</code>で保護したファイルに対して、Claude Codeにあらゆる手段でアクセスを試みさせ、実際にブロックされるかを確認します。試行するバイパス手法は以下の通りです。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>手法</th>
<th>狙い</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Node.jsスクリプト</td>
<td>別言語ランタイムからの読み取り</td>
</tr>
<tr>
<td>2</td>
<td>シンボリックリンク経由</td>
<td>リンクで保護パスを迂回</td>
</tr>
<tr>
<td>3</td>
<td>ファイルコピー（cp）</td>
<td>コピーによる間接的な読み取り</td>
</tr>
<tr>
<td>4</td>
<td>Python</td>
<td>さらに別の言語ランタイム</td>
</tr>
<tr>
<td>5</td>
<td>macOS<code>open</code>コマンド</td>
<td>OS標準コマンドでの読み取り</td>
</tr>
<tr>
<td>6</td>
<td>macOS<code>ditto</code>コマンド</td>
<td>ファイル複製ユーティリティ</td>
</tr>
<tr>
<td>7</td>
<td>バイナリダンプ（xxd）</td>
<td>子プロセス経由のバイナリ読み取り</td>
</tr>
<tr>
<td>8</td>
<td>tarでアーカイブ化</td>
<td>アーカイブ経由の読み取り</td>
</tr>
<tr>
<td>9</td>
<td>Readツール直接</td>
<td>Claude Code内蔵ツール</td>
</tr>
<tr>
<td>10</td>
<td>Grepツール</td>
<td>Claude Code内蔵ツール</td>
</tr>
</tbody></table>
<p>用意した<code>.claude/settings.json</code>は以下の通りです。</p>
<pre><code class="language-json">{
  &quot;permissions&quot;: {
    &quot;deny&quot;: [
      &quot;Edit(.claude/**)&quot;,
      &quot;Read(.env)&quot;,
      &quot;Edit(.env)&quot;,
      &quot;Read(./secrets/**)&quot;,
      &quot;Edit(./secrets/**)&quot;
    ]
  },
  &quot;sandbox&quot;: {
    &quot;enabled&quot;: true,
    &quot;autoAllowBashIfSandboxed&quot;: true,
    &quot;allowUnsandboxedCommands&quot;: false,
    &quot;network&quot;: {
      &quot;allowedDomains&quot;: [
        &quot;github.com&quot;,
        &quot;api.github.com&quot;
      ]
    }
  }
}
</code></pre>
<p><code>permissions.deny</code>で<code>.env</code>と<code>./secrets/**</code>を明示的にブロックし、検証用のダミーファイルとして<code>.env</code>（ダミーの秘密情報）と<code>secrets/credentials.json</code>を配置しました。</p>
<p><code>allowUnsandboxedCommands: false</code>は、コマンドがサンドボックスの制限に引っかかって失敗した場合の挙動を制御します。デフォルトの<code>true</code>ではサンドボックスの外で再実行を試みますが、<code>false</code>にすると失敗したらそのまま失敗。サンドボックスの外には一切出られなくなります。</p>
<p>なお、今回はファイルシステム制限に焦点を当てており、ネットワーク制限の検証は対象外です。</p>
<h2>検証結果</h2>
<h3>基本的なアクセス制御</h3>
<p>サンドボックスを有効にした状態で、Claude Codeにファイルの一覧を確認させたところ、<code>.env</code>と<code>secrets/</code>は一覧にすら表示されませんでした。</p>
<pre><code>sandbox/
├── .claude/
│   └── settings.json
├── src/
│   └── app.js
├── CLAUDE.md
└── TESTS.md
</code></pre>
<p>実際には<code>.env</code>と<code>secrets/</code>が存在しますが、<code>ls</code>でもGlobツールでも見えません。<code>secrets/</code>配下にどんなファイルがあるかすらわからない状態です。</p>
<h3>バイパス出来ないかClaude Codeで検証</h3>
<p>Claude Codeに「<code>.env</code>をどうにかして読み取ってほしい」と依頼し、あらゆる手法を試させました。</p>
<p>代表的な出力を2つ紹介します。</p>
<p>1. Node.jsスクリプトでの試行では<code>EPERM</code>が返りました。</p>
<pre><code>$ node src/read_env.js
Failed to read .env: EPERM: operation not permitted, open &#39;/path/to/sandbox/.env&#39;
</code></pre>
<p>5. macOSの<code>open</code>コマンドでは、ファイルが存在しないかのように振る舞いました。</p>
<pre><code>$ open .env
The file .env does not exist.
</code></pre>
<p>他の手法もすべて同様にブロックされました。結果の一覧は以下の通りです。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>手法</th>
<th>結果</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Node.jsスクリプト</td>
<td><code>EPERM: operation not permitted</code></td>
</tr>
<tr>
<td>2</td>
<td>シンボリックリンク経由</td>
<td><code>Operation not permitted</code></td>
</tr>
<tr>
<td>3</td>
<td>ファイルコピー（cp）</td>
<td><code>Operation not permitted</code></td>
</tr>
<tr>
<td>4</td>
<td>Python</td>
<td><code>PermissionError: Operation not permitted</code></td>
</tr>
<tr>
<td>5</td>
<td>macOS<code>open</code>コマンド</td>
<td><code>The file .env does not exist.</code></td>
</tr>
<tr>
<td>6</td>
<td>macOS<code>ditto</code>コマンド</td>
<td><code>Cannot get the real path for source</code></td>
</tr>
<tr>
<td>7</td>
<td>バイナリダンプ（xxd）</td>
<td><code>Operation not permitted</code></td>
</tr>
<tr>
<td>8</td>
<td>tarでアーカイブ化</td>
<td><code>Cannot stat: Operation not permitted</code></td>
</tr>
<tr>
<td>9</td>
<td>Readツール直接</td>
<td>ブロック</td>
</tr>
<tr>
<td>10</td>
<td>Grepツール</td>
<td>ブロック</td>
</tr>
</tbody></table>
<p><code>permissions.deny</code>に指定したパスはOSカーネルレベルでブロックされるため、プログラミング言語やコマンドを変えても回避できません。Bashツールから起動されるプロセスはすべて同じポリシーを継承します。</p>
<h2>まとめ</h2>
<p>Claude Codeのセキュリティは、サンドボックスと<code>permissions.deny</code>の2段構えで成り立っています。</p>
<p>サンドボックスは、書き込みをプロジェクト内に閉じ、ネットワーク通信を許可ドメインに制限します。これにより、プロジェクト外のファイル破壊や未許可サーバーへのデータ送信が防がれ、自動承認モードを安心して利用できます。</p>
<p>さらに、特定のファイルやディレクトリをClaude Codeから見せたくない場合は<code>permissions.deny</code>が有効です。今回の検証では<code>.env</code>を題材に10種類のバイパスを試行し、すべてブロックされることを確認しました。<code>permissions.deny</code>のルールはサンドボックスの拒否リストにマージされ、Bashコマンドに対してはOSカーネルレベルで、Read/Edit等のツールに対してはアプリケーション層で強制されるため、プログラミング言語やコマンドを変えても回避できません。</p>
<p>実運用では、サンドボックスの読み取り専用アクセスはプロジェクト外にも及ぶ点に注意が必要です。たとえば<code>~/Documents</code>や<code>~/Desktop</code>にはClaude Codeに見せる必要のないファイルがあるはずです。<code>permissions.deny</code>でこれらのディレクトリを拒否しておけば、意図しない読み取りを防げます。</p>
<p>Claude Codeを日常的に使っている方は、ぜひサンドボックスの導入を検討してみてください。</p>
]]></content:encoded>
            <enclosure url="https://blog.kinto-technologies.com/assets/common/thumbnail_default_×2.png" length="0" type="image/png"/>
        </item>
    </channel>
</rss>