個人開発でAI Agentを用いた並列開発のためにPR毎の開発環境を作成する
はじめに
この記事は、趣味で行っている個人開発で、並列実行しているAIエージェントの成果物をどのように確認しているかについて書いています。ベストな方法というよりは、こういう方法もあるという一例として書いているので、参考程度に読んでいただけると幸いです。
経緯
最近はAIを用いたVibe Codingの波もあり、個人開発においては十分に活用させてもらっている。具体的にはGithub Copilot Agentを用いており以下のような流れで開発をしている。
- Github Issuesに新機能の開発要件を複数起票(自分の担当)
- Copilotを各IssueにAssignをして並列開発
- Copilotの開発完了通知をWebhook(Discord)で受け取る
- ※各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