🐙

個人開発でAI Agentを用いた並列開発のためにPR毎の開発環境を作成する

に公開

はじめに

この記事は、趣味で行っている個人開発で、並列実行しているAIエージェントの成果物をどのように確認しているかについて書いています。ベストな方法というよりは、こういう方法もあるという一例として書いているので、参考程度に読んでいただけると幸いです。

経緯

最近はAIを用いたVibe Codingの波もあり、個人開発においては十分に活用させてもらっている。具体的にはGithub Copilot Agentを用いており以下のような流れで開発をしている。

  1. Github Issuesに新機能の開発要件を複数起票(自分の担当)
  2. Copilotを各IssueにAssignをして並列開発
  3. Copilotの開発完了通知をWebhook(Discord)で受け取る
  4. ※各PRのコードレビュー及び動作確認を実施(自分の担当)

※の工程では、フロントの修正があった場合、確認が大変という課題がある。これまでは修正後のスクリーンショットをコメントとして投稿させたり、e2e テストで期待した実装になっているか確認していた。ただやはりフロントの改修は自分の目で見て実際に触って確かめたいという思いがあり、修正の動作確認を行うようになってから手間がかかっていた。

方針

Copilot AgentがIssue毎にPRを作成してくれるので、PRを作成し作業が終わったら修正内容が展開された開発環境がプロビジョニングされ、PR CommentにそのURLが投稿されるといった仕組みを用意する方針とした。今回は修正内容の確認のみで、インテグレーションな部分の確認は必要としていないので、修正内容がDocker(Compose)で立ち上がってポチポチできればよいとした。
PR毎にDockerを立ち上げるならばら色々な方法が考えられるが、今回は個人開発。

  • なるべくお金をかけたくない
  • とはいえコストを気にしてCopilot Agentの並列実行を抑えたくない
  • 手軽に開発環境を立てたい

という前提がある。安直に思いついた案としては

  • CloudRunで各Dockerを立ち上げる構成
  • さくらVPSを借りてそこでホスティング

など考えた。前者はまだdocker compose単位での稼働機能はPreviewで正式にリリースされていないため自分でDockerをつなぎ合わせたインフラを作る必要がある。また課金も稼働時間課金のため読めない部分がある。後者についてはDockerおすすめ構成(4vCPU, 4GB RAM)で月額3,740円ほど。PR毎の開発環境構築という意味ではややスペックに不安が残る。どうしたものか...と悩んだが家に眠っていたミニPC(4CPU 4Thread、16GB RAM)があったことを思い出し、これを利用したら電気代のみ(実質タダ)なので活用してみることにした。

仕組み

Copilot Agentが、GitHubベースで動く仕組みなので基本はそこで完結させたい。自前のPC x GitHubの活用であればSelf-Hosted-Runnerで対応してしまおうと考えた。
ざっくり以下のようなもの。

今回は外部インターネットに公開しないため、ミニPCへのアクセスは家のローカルネットワーク上にDHCPで固定IPを振っていて、開発マシンからアクセスできるようにしている。

対応

ミニPC側

今回はubuntuをミニPCにインストールしていて、そこでself-hosted-runnerを動かせるようにする必要がある。主な手順は以下。

1. システム準備 (Ubuntu)

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git ca-certificates jq unzip

2. docker / compose インストール

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
docker compose version || sudo apt install -y docker-compose-plugin

3. Runner 専用ユーザ作成 (推奨)

sudo useradd -m -s /bin/bash ghrunner
sudo usermod -aG docker ghrunner
sudo su - ghrunner

4. Runner ダウンロード & 設定

登録トークンを取得: Repository Settings → Actions → Runners → New self-hosted runner → Linux x64選択。
具体的な手順はGitHubサイト上に丁寧に示されているので、その順にやるのみ。

5. systemd サービス化

sudo 権限を持つユーザ (root など) で実行:

sudo su - root
cd /home/ghrunner/actions-runner
./svc.sh install
./svc.sh start
systemctl status actions.runner.<owner>-<repo>.home-runner-1.service

以上。

アプリケーション側

複数PRでdocker compose環境を立ち上げるので、

  • docker composeの起動はPR番号(とリポジトリ名など)をプロジェクト名として付与
  • 公開ポートが被らないように、PR番号のhash値を用いてポート番号を決める

といったことを気をつけるだけでコンフリクトはほぼなくなるはず。
自分が利用しているものと若干異なるが、以下サンプルの起動workflow。

name: PR Preview Compose Environment

on:
    pull_request:
        types: [opened, synchronize, reopened]
        branches: [main]

permissions:
    contents: read
    pull-requests: write

concurrency:
    group: pr-preview-${{ github.event.pull_request.number }}
    cancel-in-progress: true

jobs:
    preview:
        runs-on: self-hosted
        if: github.event.pull_request.draft == false
        timeout-minutes: 20
        env:
            PROJECT: pr-${{ github.event.pull_request.number }}
            FIXED_IP: ${{ vars.FIXED_IP }}

        steps:
            - name: Checkout code
                uses: actions/checkout@v4

            - name: Clean up previous preview (if any)
                run: docker compose -p "$PROJECT" down -v --remove-orphans || true

            - name: Allocate dynamic ports from PR number hash
                run: |
                    PR_HASH=$(echo -n "${{ github.event.pull_request.number }}" | sha256sum | cut -c1-4)
                    FRONTEND_PORT=$((43000 + 0x$PR_HASH % 100))
                    BACKEND_PORT=$((44000 + 0x$PR_HASH % 100))
                    echo "FRONTEND_PORT=$FRONTEND_PORT" >> $GITHUB_ENV
                    echo "BACKEND_PORT=$BACKEND_PORT" >> $GITHUB_ENV

            - name: Start preview environment
                run: |
                    FRONTEND_PORT=$FRONTEND_PORT BACKEND_PORT=$BACKEND_PORT docker compose -p "$PROJECT" up -d --build

            - name: Wait for backend health
                run: |
                    for i in {1..30}; do
                        if curl -fsS "http://${{ env.FIXED_IP }}:$BACKEND_PORT/api/health"; then exit 0; fi
                        sleep 2
                    done
                    exit 1

            - name: Post PR comment with preview URLs
                uses: actions/github-script@v7
                env:
                    PR_NUMBER: ${{ github.event.pull_request.number }}
                    FRONTEND_PORT: ${{ env.FRONTEND_PORT }}
                    BACKEND_PORT: ${{ env.BACKEND_PORT }}
                    FIXED_IP: ${{ env.FIXED_IP }}
                with:
                    script: |
                        const pr = process.env.PR_NUMBER;
                        const frontend = `http://${process.env.FIXED_IP}:${process.env.FRONTEND_PORT}`;
                        const backend = `http://${process.env.FIXED_IP}:${process.env.BACKEND_PORT}/api/health`;
                        const marker = '<!-- preview-env-comment -->';
                        const body = `${marker}\nPreview for PR #${pr}\nFrontend: ${frontend}\nBackend health: ${backend}\nEnvironment will be removed when PR is closed.`;
                        const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: pr });
                        const existing = comments.find(c => c.body && c.body.includes(marker));
                        if (existing) {
                            await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
                        } else {
                            await github.rest.issues.createComment({ ...context.repo, issue_number: pr, body });
                        }
# Note:
# - FIXED_IPはミニPCのローカル固定IPに合わせて変更してください。
# - docker-compose.ymlのcontainer_nameは削除してください(並列実行のため)。

削除workflow

name: PR Self-Hosted Compose Cleanup

on:
  pull_request:
    types: [closed]
    branches: [ main ]
  workflow_dispatch:
    inputs:
      pr_number:
        description: "PR number to clean (manual)"
        required: true
        type: string

permissions:
  contents: read
  pull-requests: write
  issues: write

jobs:
  cleanup:
    runs-on: self-hosted
    timeout-minutes: 10
    env:
      PROJECT: pr-${{ github.event.pull_request.number || inputs.pr_number }}
      PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
    steps:
      - name: List stacks (before)
        run: docker ps --format '{{.Names}}' || true
      - name: Down compose project
        run: |
          echo "Cleaning project $PROJECT"
          docker compose -p "$PROJECT" down -v --remove-orphans || true
          if command -v systemctl >/dev/null 2>&1; then
            systemctl stop autodown-$PROJECT.service 2>/dev/null || true
            systemctl disable autodown-$PROJECT.service 2>/dev/null || true
          fi

      - name: Update PR comment (destroy notice)
        if: ${{ env.PR_NUMBER != '' }}
        uses: actions/github-script@v7
        env:
          PR_NUMBER: ${{ env.PR_NUMBER }}
        with:
          script: |
            const marker = '<!-- preview-env-comment -->';
            const prNumber = Number(process.env.PR_NUMBER);
            const destructionNote = `${marker}\n🧹 Preview environment for PR #${prNumber} has been destroyed (PR closed).`;
            const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: prNumber, per_page: 100 });
            const existing = comments.find(c => c.body && c.body.includes(marker));
            if (existing) {
              await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body: destructionNote });
            } else {
              await github.rest.issues.createComment({ ...context.repo, issue_number: prNumber, body: destructionNote });
            }

      - name: Post cleanup prune (light)
        run: |
          docker image prune -f >/dev/null 2>&1 || true
          docker volume prune -f >/dev/null 2>&1 || true

      - name: Summary
        run: |
          echo "### Cleanup Summary" >> $GITHUB_STEP_SUMMARY
          echo "Project cleaned: $PROJECT" >> $GITHUB_STEP_SUMMARY

※自分のコードをもとに公開できるように AI に直してもらった定義です。誤りがあればご指摘いただけると助かります。

成果物イメージ

PRのオープン/クローズ等で以下のようなコメントが付与されるようになる
起動コメント
削除コメント

パスワードなどもおもむろにのせているが、一時的な環境ですぐ壊すものなので利便性をとって良しとしている。

その他注意事項など

今回の構成はあくまでローカルネットワーク環境で動かす構成としてつくっている。ポートを動的に取ったりなどインターネット公開向けのシステムとしてはセキュリティ的に厳しいのでそういった要件では利用しないこと。

おわりに

本番構築する際には、self-hosted-runner x k8sの構成にしてもう少し仕組化された環境に作り替えようと思います。

Discussion