KiroCrew公式DockerイメージをEC2で動かしてTailscale経由の接続を試してみた

KiroCrew公式DockerイメージをEC2で動かしてTailscale経由の接続を試してみた

前回EC2上に直接インストールしたKiroCrewを、Dockerコンテナを隔離境界として運用し、Tailscale Serve経由でtailnet内からアクセスできる構成を試しました。SSMによる有人セットアップ、named volume構成、アクセスログに記録される項目も確認します。
2026.08.06

はじめに

前回はEC2へKiroCrewを直接インストールし、SSHトンネルでアクセスしました。今回はKiroCrewの公式Dockerイメージをコンテナで起動し、Tailscale Serve経由でtailnet内からアクセスする構成を確認しました。

https://dev.classmethod.jp/articles/kiro-crew-remote-on-ec2/

Tailscale Serveは、tailnet内の端末へローカルサービスを公開する機能です。Dockerのポート公開はループバックに限定し、tailnet内の別端末から実際につながるところまで通しました。あわせて、セッションと設定を保持するnamed volumeの構成と、アクセスログの記録内容も見ています。

https://tailscale.com/docs/features/tailscale-serve

検証内容

検証環境

項目 構成
インフラ CloudFormationで構築したAmazon EC2
EC2 t4g.medium / arm64 / Amazon Linux 2023 / ap-northeast-1
ソフトウェア Docker 25.0.16 / Compose v5.4.0 / Tailscale 1.102.2 / KiroCrew 0.1.3
ストレージ named volume(/home/kirocrew
KiroCrew ghcr.io/kirodotdev/kirocrew:stable(Docker Compose起動)
アクセス経路 Tailscale Serve経由のtailnet内アクセス

EC2インスタンス、VPC、セキュリティグループはCloudFormationテンプレートで作成し、インスタンスへの接続はSSMセッションマネージャーに寄せています。

KiroCrewをDockerコンテナで隔離する

KiroCrewは /opt/kirocrew/compose.yaml のDocker Composeで起動します。ポートの公開先はループバックアドレスに限定しました。tailnetへの公開はTailscale Serveだけが担います。

services:
  kirocrew:
    image: ghcr.io/kirodotdev/kirocrew:stable
    ports:
      - "127.0.0.1:5478:5476"
    environment:
      - KIROCREW_ALLOW_UNSANDBOXED=1

起動後にポートの公開状態を確認すると、意図どおりループバックだけにバインドされていました。

127.0.0.1:5478->5476/tcp

KIROCREW_ALLOW_UNSANDBOXED=1 は、隔離境界をDockerコンテナに置く今回の構成の前提として設定したもので、KiroCrew全般の安全性を保証するものではありません。

SSMで初期設定を行う

初期設定にはTailscaleの認証、Kiro CLIのdevice flow認証、kirocrew setup、Tailscale Serveの設定が含まれます。いずれも認証URLの表示やコードの入力といった対話操作を伴うため、これらの初期設定は自動化せず、SSMセッションマネージャー経由のターミナル操作としました。

aws ssm start-session --target "<INSTANCE_ID>"
cd /opt/kirocrew

# Tailscaleの認証(表示されたURLをブラウザで開いて承認)
sudo tailscale up

# コンテナ内でKiro CLIとKiroCrewの初期設定
sudo docker compose exec kirocrew kiro-cli login --use-device-flow
sudo docker compose exec kirocrew kirocrew setup

ループバックに公開したポートをTailscale Serveの転送先に指定します。

sudo tailscale serve --bg --yes "http://127.0.0.1:5478"
sudo tailscale serve status
https://<HOSTNAME>.<TAILNET_NAME>.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:5478

tailnet only は、公開範囲をtailnet内に限定する設定です。今回の検証ではfunnelは使用しません。

Serve URLを後続の設定で使うため、tailscale serve statusの出力から取得します。

SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
case "$SERVE_URL" in
  https://*) echo "Serve URL: $SERVE_URL" ;;
  *) echo "ERROR: Tailscale Serve URL not found" >&2; exit 1 ;;
esac

取得したServe URLをKiroCrewへ登録して再起動します。

sudo docker compose exec -T kirocrew kirocrew config set \
  dashboard.url "$SERVE_URL"

sudo docker compose restart kirocrew
sudo docker compose exec -T kirocrew kirocrew config get dashboard.url
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health

設定値、コンテナ状態、ローカルhealthの応答がいずれも期待どおりでした。

Tailscale Serve経由で接続する

Tailscaleに接続済みの別端末から、Serveで割り当てられたURLへヘルスチェックを投げました。

curl "https://<HOSTNAME>.<TAILNET_NAME>.ts.net/api/health"
{"ok": true}

ダッシュボードのアクセストークンを一時保存する

ダッシュボードを開く場合は、コンテナ内でダッシュボードトークンを発行します。tokenはチャット・ログ・Gitへ出力せず、EC2ローカルの一時ファイルへ保存します。

cd /opt/kirocrew
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
TOKEN=$(sudo docker compose exec -T kirocrew kirocrew token --ttl 2h)
sudo install -m 600 /dev/null /run/kirocrew-dashboard-token
printf '%s\n' "$TOKEN" | sudo tee /run/kirocrew-dashboard-token > /dev/null
echo "Token saved to /run/kirocrew-dashboard-token (mode 600)"
echo "URL pattern: ${SERVE_URL}?token=<token>"

保存内容を確認するときは、tokenとホスト名の一部をマスクして表示します。これにより、トークンファイルの内容、パーミッション、ダッシュボード接続用URLを、実値を出さずに確認できます。

TOKEN_FROM_FILE="$(sudo cat /run/kirocrew-dashboard-token)"
MASKED_TOKEN="$(printf '%s' "$TOKEN_FROM_FILE" | sed -E 's/^(.{4}).*(.{4})$/\1...\2/')"
SERVE_HOST="${SERVE_URL#https://}"
MASKED_HOST="$(printf '%s' "$SERVE_HOST" | sed -E 's/^(.{4}).*(.{4})$/\1...\2/')"
printf 'Token file content (masked): %s\n' "$MASKED_TOKEN"
printf 'Token file mode: %s\n' "$(sudo stat -c '%a' /run/kirocrew-dashboard-token)"
printf 'Dashboard URL (masked): https://%s?token=%s\n' "$MASKED_HOST" "$MASKED_TOKEN"

ブラウザーアクセス

tailnetに接続した端末のブラウザーで、次のURLを開きます。URLの token には、/run/kirocrew-dashboard-token に一時保存した値を使用します。

https://<HOSTNAME>.<TAILNET_NAME>.ts.net?token=<token>

ブラウザーでのアクセスが終わったら、EC2上の一時ファイルを削除します。

sudo rm -f /run/kirocrew-dashboard-token

セッションと設定の永続化

Kiro CLIの認証情報とKiroCrewの設定は /home/kirocrew 以下に書き出されます。/home/kirocrew をnamed volumeとしてマウントし、コンテナの書き込み先を1箇所へ集約しています。

services:
  kirocrew:
    volumes:
      - kirocrew-home:/home/kirocrew

volumes:
  kirocrew-home:

access.log もKiroCrewが同じディレクトリへ書き出します。ログ関連の設定(PYTHONPATH と sitecustomize.py のマウント)は付録の compose.yaml を参照してください。ブラウザーからアクセスした後、パーミッションと直近の記録を見てみます。

cd /opt/kirocrew
sudo docker compose exec -T kirocrew stat -c '%a %n' /home/kirocrew/access.log
sudo docker compose exec -T kirocrew sh -c 'tail -n 50 /home/kirocrew/access.log'

実際の出力を抜粋します。

600 /home/kirocrew/access.log
2026-08-06T02:34:55+0000 INFO aiohttp.access: http_request method=POST path=/api/chat status=200 duration_ms=3.4
2026-08-06T02:35:15+0000 INFO aiohttp.access: http_request method=GET path=/api/health status=200 duration_ms=0.5

ダッシュボードトークンはクエリ文字列で渡しますが、access.log には残っていませんでした。本検証では独自のアクセスロガー(付録の sitecustomize.py)を PYTHONPATH 経由で読み込ませており、その結果として記録されたのは methodpathstatusduration_ms の4項目です。ファイルのパーミッションは600です。

まとめ

Dockerコンテナを隔離境界にし、Tailscale Serveでtailnet内へ接続する構成にすると、EC2上のKiroCrewへSSHトンネルに依存しない経路でアクセスできます。複数の端末から同じKiroCrewをtailnet内に公開範囲を限定したトンネル経由で利用したい場合は、この構成を試してみてください。


付録: 再現用ファイル

本文で使用した構成を再現するためのファイルをまとめます。テンプレートとスクリプトには検証時の実ファイルを掲載しています。認証URL、device code、authkey、dashboard tokenなどの秘密情報は含めていません。

CloudFormationテンプレート

templates/kirocrew-ec2.yaml の全文です。

CloudFormationテンプレート(全文)
AWSTemplateFormatVersion: "2010-09-09"
Description: >
  Kiro Crew EC2 (ARM64/t4g.medium/AL2023) in ap-northeast-1.
  Tailscale + Docker + KiroCrew + KiroCLI via UserData.
  SSM-only management. No inbound ports. IMDSv2 required.
  Self-contained: VPC, Subnet, IGW, RouteTable, SG, IAM Role/Profile, EC2.

# ====================================================================
# Parameters
# ====================================================================
Parameters:
  InstanceType:
    Type: String
    Default: t4g.medium
    AllowedValues: [t4g.small, t4g.medium, t4g.large]
    Description: EC2 instance type (ARM64 Graviton2)

  AmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
    Description: Amazon Linux 2023 ARM64 AMI (SSM Parameter)

  RootVolumeSize:
    Type: Number
    Default: 20
    MinValue: 20
    MaxValue: 100
    Description: Root EBS volume size in GiB

  ProjectName:
    Type: String
    Default: kirocrew-cfn
    Description: Project name used for resource naming and tags

  DeployDate:
    Type: String
    Default: "20260806"
    Description: Deployment date suffix (YYYYMMDD) for resource naming

# ====================================================================
# Resources
# ====================================================================
Resources:

  # ------------------------------------------------------------------
  # VPC & Network
  # ------------------------------------------------------------------
  KiroCrewVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/24
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-vpc-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName
        - Key: ManagedBy
          Value: CloudFormation

  KiroCrewInternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-igw-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName

  KiroCrewVPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref KiroCrewVPC
      InternetGatewayId: !Ref KiroCrewInternetGateway

  KiroCrewSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref KiroCrewVPC
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: ap-northeast-1a
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-subnet-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName

  KiroCrewRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref KiroCrewVPC
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-rtb-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName

  KiroCrewDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: KiroCrewVPCGatewayAttachment
    Properties:
      RouteTableId: !Ref KiroCrewRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref KiroCrewInternetGateway

  KiroCrewSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref KiroCrewSubnet
      RouteTableId: !Ref KiroCrewRouteTable

  # ------------------------------------------------------------------
  # Security Group: inbound完全クローズ, outboundはTailscale/Docker/SSM用HTTPS
  # ------------------------------------------------------------------
  KiroCrewSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${ProjectName}-sg-${DeployDate}"
      GroupDescription: "Kiro Crew EC2: No inbound. Outbound HTTPS SSM Docker Tailscale"
      VpcId: !Ref KiroCrewVPC
      # Inbound: 完全クローズ (SSMはポート不要)
      SecurityGroupIngress: []
      # Outbound: HTTPS + Tailscale UDP
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
          Description: HTTPS for SSM endpoints, Docker pull, Tailscale control
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
          Description: HTTP for package mirrors (dnf)
        - IpProtocol: udp
          FromPort: 41641
          ToPort: 41641
          CidrIp: 0.0.0.0/0
          Description: Tailscale WireGuard UDP
        - IpProtocol: udp
          FromPort: 3478
          ToPort: 3478
          CidrIp: 0.0.0.0/0
          Description: Tailscale DERP/STUN
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-sg-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName
        - Key: ManagedBy
          Value: CloudFormation

  # ------------------------------------------------------------------
  # IAM Role: SSM Core + ECR (KiroCrew image pull)
  # ------------------------------------------------------------------
  KiroCrewEC2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${ProjectName}-ec2-role-${DeployDate}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        # SSMによるインスタンス管理
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
      Tags:
        - Key: Project
          Value: !Ref ProjectName
        - Key: ManagedBy
          Value: CloudFormation

  KiroCrewEC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub "${ProjectName}-ec2-profile-${DeployDate}"
      Roles:
        - !Ref KiroCrewEC2Role

  # ------------------------------------------------------------------
  # EC2 Instance
  # ------------------------------------------------------------------
  KiroCrewEC2Instance:
    Type: AWS::EC2::Instance
    DependsOn:
      - KiroCrewVPCGatewayAttachment
      - KiroCrewDefaultRoute
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref AmiId
      SubnetId: !Ref KiroCrewSubnet
      IamInstanceProfile: !Ref KiroCrewEC2InstanceProfile
      SecurityGroupIds:
        - !Ref KiroCrewSecurityGroup

      # IMDSv2必須: HopLimit=2 (コンテナ内からメタデータ取得可能)
      MetadataOptions:
        HttpTokens: required
        HttpPutResponseHopLimit: 2
        HttpEndpoint: enabled

      # ルートボリューム: 暗号化gp3
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            VolumeType: gp3
            VolumeSize: !Ref RootVolumeSize
            Encrypted: true
            DeleteOnTermination: true

      # UserDataはbootstrap.shをbase64エンコードして埋め込む
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          # Kiro Crew EC2 ARM64 bootstrap: SSM + Docker + Tailscale + KiroCrew
          # CFN Stack: ${AWS::StackName} / Region: ${AWS::Region}
          # Date: ${DeployDate}
          #
          # Human steps (NOT automated here):
          #   1. sudo tailscale up
          #   2. sudo docker compose exec kirocrew kiro-cli login --use-device-flow
          #   3. sudo docker compose exec kirocrew kirocrew setup
          #   4. sudo tailscale serve --bg --yes http://127.0.0.1:5478
          #   -> See runbooks/human-operations-runbook.md for full details
          set -euo pipefail

          LOG=/var/log/kirocrew-bootstrap.log
          exec > >(tee -a "$LOG") 2>&1

          echo "===== Kiro Crew bootstrap started: $(date -Is) ====="
          echo "Stack: ${AWS::StackName}"
          echo "Region: ${AWS::Region}"

          # AL2023のSSM Agentを有効化
          systemctl enable --now amazon-ssm-agent || true

          # ssm-user作成 + sudo権限付与
          useradd -m -s /bin/bash ssm-user 2>/dev/null || true
          echo 'ssm-user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ssm-agent-users
          chmod 440 /etc/sudoers.d/ssm-agent-users

          # Docker
          dnf install -y docker
          systemctl enable --now docker
          usermod -aG docker ssm-user || true

          # Docker Compose ARM64プラグイン (pinned v5.4.0)
          COMPOSE_VERSION=v5.4.0
          install -d -m 0755 /usr/local/lib/docker/cli-plugins
          curl --fail --silent --show-error --location \
            "https://github.com/docker/compose/releases/download/${!COMPOSE_VERSION}/docker-compose-linux-aarch64" \
            --output /usr/local/lib/docker/cli-plugins/docker-compose
          chmod 0755 /usr/local/lib/docker/cli-plugins/docker-compose

          # swapfile (メモリ補助 1GiB)
          if ! swapon --show --noheadings | grep -q . && [ ! -e /swapfile ]; then
            fallocate -l 1G /swapfile
            chmod 600 /swapfile
            mkswap /swapfile
            swapon /swapfile
            echo '/swapfile none swap sw 0 0' >> /etc/fstab
          fi

          # Tailscale (認証は人間が実施)
          curl -fsSL https://tailscale.com/install.sh | sh
          systemctl enable --now tailscaled

          # KiroCrew作業ディレクトリ
          install -d -m 0755 /opt/kirocrew

          # HTTP access log: クエリ文字列・認証情報を記録しない専用ロガー
          cat > /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py <<'PY'
          """Safe query-free aiohttp access logging for Kiro Crew gateway."""
          from __future__ import annotations
          import logging, os
          from logging.handlers import RotatingFileHandler
          try:
              from aiohttp import web_log
              from aiohttp.web_runner import AppRunner
              class SafeAccessLogger(web_log.AbstractAccessLogger):
                  def log(self, request, response, time):
                      path = getattr(request, "path", "<unknown>")
                      if not isinstance(path, str): path = "<unknown>"
                      self.logger.info("http_request method=%s path=%s status=%s duration_ms=%.1f",
                          getattr(request,"method","<unknown>"), path[:512],
                          getattr(response,"status","<unknown>"), time*1000.0)
              _orig = AppRunner.__init__
              def _safe_init(self, app, *, handle_signals=False, access_log_class=SafeAccessLogger, **kw):
                  return _orig(self, app, handle_signals=handle_signals, access_log_class=access_log_class, **kw)
              if not getattr(AppRunner,"_kirocrew_safe_access_logging",False):
                  AppRunner.__init__ = _safe_init
                  AppRunner._kirocrew_safe_access_logging = True
              al = logging.getLogger("aiohttp.access")
              al.setLevel(logging.INFO); al.propagate = True
              lp = os.environ.get("KIROCREW_ACCESS_LOG_FILE","/home/kirocrew/access.log")
              if not any(getattr(h,"_kirocrew_safe_access",False) for h in al.handlers):
                  h = RotatingFileHandler(lp, maxBytes=2*1024*1024, backupCount=3, encoding="utf-8")
                  h.setLevel(logging.INFO)
                  h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s",datefmt="%Y-%m-%dT%H:%M:%S%z"))
                  h._kirocrew_safe_access = True; al.addHandler(h)
                  try: os.chmod(lp, 0o600)
                  except OSError: pass
          except Exception: pass
          PY
          chmod 0644 /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py

          # Docker Composeファイル
          cat > /opt/kirocrew/compose.yaml <<'COMPOSE'
          name: kirocrew-ec2

          services:
            kirocrew:
              image: ghcr.io/kirodotdev/kirocrew:stable
              container_name: kirocrew-ec2-gateway
              restart: unless-stopped
              ports:
                - "127.0.0.1:${!KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
              volumes:
                - kirocrew-ec2-home:/home/kirocrew
                - ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
              environment:
                PYTHONPATH: /opt/kirocrew/patches
                KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
                KIROCREW_ALLOW_UNSANDBOXED: ${!KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}

          volumes:
            kirocrew-ec2-home:
              name: ${!KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}
          COMPOSE

          # 環境変数ファイル
          VOLUME_SUFFIX=$(date +%Y%m%d)
          cat > /opt/kirocrew/.env <<ENV
          KIROCREW_HOST_PORT=5478
          KIROCREW_VOLUME_NAME=kirocrew-ec2-home-cfn-${!VOLUME_SUFFIX}
          KIROCREW_ALLOW_UNSANDBOXED=1
          ENV
          chmod 600 /opt/kirocrew/.env

          # コンテナ起動
          cd /opt/kirocrew
          docker compose config --quiet
          docker compose pull
          docker compose up -d --force-recreate

          # ヘルスチェック (最大120秒)
          for attempt in $(seq 1 60); do
            if curl --fail --silent http://127.0.0.1:5478/api/health >/dev/null 2>&1; then
              echo "Kiro Crew health: OK (attempt=${!attempt})"
              break
            fi
            if [ "${!attempt}" -eq 60 ]; then
              echo "ERROR: health check failed"
              docker compose logs --tail=100 kirocrew || true
              exit 1
            fi
            sleep 2
          done

          # sandbox設定
          docker compose exec -T kirocrew kirocrew config set \
            agent.sandbox_allow_unsandboxed_exec true || true

          echo "--- versions ---"
          docker --version
          docker compose version
          tailscale version
          echo "--- container status ---"
          docker compose ps
          echo "===== bootstrap completed: $(date -Is) ====="

      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-ec2-${DeployDate}"
        - Key: Project
          Value: !Ref ProjectName
        - Key: ManagedBy
          Value: CloudFormation
        - Key: SandboxPosture
          Value: container-only
        - Key: BootstrapDate
          Value: !Ref DeployDate

# ====================================================================
# Outputs
# ====================================================================
Outputs:
  InstanceId:
    Description: EC2 Instance ID
    Value: !Ref KiroCrewEC2Instance
    Export:
      Name: !Sub "${AWS::StackName}-InstanceId"

  VpcId:
    Description: VPC ID
    Value: !Ref KiroCrewVPC
    Export:
      Name: !Sub "${AWS::StackName}-VpcId"

  SubnetId:
    Description: Subnet ID
    Value: !Ref KiroCrewSubnet
    Export:
      Name: !Sub "${AWS::StackName}-SubnetId"

  SecurityGroupId:
    Description: Security Group ID
    Value: !Ref KiroCrewSecurityGroup
    Export:
      Name: !Sub "${AWS::StackName}-SecurityGroupId"

  IAMRoleArn:
    Description: IAM Role ARN
    Value: !GetAtt KiroCrewEC2Role.Arn

  SSMConnectCommand:
    Description: SSM Session Manager connect command
    Value: !Sub "aws ssm start-session --target ${KiroCrewEC2Instance} --region ${AWS::Region}"

  BootstrapLogCommand:
    Description: Bootstrap log tail command via SSM
    Value: !Sub "aws ssm send-command --instance-ids ${KiroCrewEC2Instance} --document-name AWS-RunShellScript --parameters commands='tail -100 /var/log/kirocrew-bootstrap.log' --region ${AWS::Region}"

Docker Composeファイル

UserDataで /opt/kirocrew/compose.yaml に配置するCompose定義です。Composeは ghcr.io/kirodotdev/kirocrew:stable を使用し、ホスト側の公開先をloopbackに限定します。

compose.yaml
name: kirocrew-ec2

services:
  kirocrew:
    image: ghcr.io/kirodotdev/kirocrew:stable
    container_name: kirocrew-ec2-gateway
    restart: unless-stopped
    ports:
      - "127.0.0.1:${KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
    volumes:
      - kirocrew-ec2-home:/home/kirocrew
      - ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
    environment:
      PYTHONPATH: /opt/kirocrew/patches
      KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
      # AL2023/Docker runtimeではsandbox無効化が必要。コンテナが唯一の隔離境界。
      KIROCREW_ALLOW_UNSANDBOXED: ${KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}

volumes:
  kirocrew-ec2-home:
    name: ${KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}

UserData bootstrap

EC2初期構築時に実行する userdata/bootstrap.sh の全文です。

userdata/bootstrap.sh(全文)
#!/bin/bash
# Kiro Crew EC2 ARM64 bootstrap: SSM + Docker + Tailscale + KiroCrew
# 2026-08-06 ap-northeast-1 CloudFormation用 UserData
#
# 人間が実施する手順 (UserDataでは行わない):
#   - sudo tailscale up                           # Tailscale認証
#   - sudo docker compose exec kirocrew kiro-cli login --use-device-flow  # KiroCLI認証
#   - sudo docker compose exec kirocrew kirocrew setup                     # KiroCrew初期設定
#   - sudo tailscale serve --bg --yes http://127.0.0.1:5478               # Tailscale Serve設定
#   - Runbook: runbooks/human-operations-runbook.md 参照
#
# セキュリティポスチャ:
#   - IMDSv2必須 (MetadataOptions: HttpTokens=required)
#   - SSMのみ管理アクセス (inboundポート不要)
#   - DockerはloopbackのみPublish (127.0.0.1:5478)
#   - Tailscale ServeはtailnetのみPublish (funnel不使用)
set -euo pipefail

LOG=/var/log/kirocrew-bootstrap.log
exec > >(tee -a "$LOG") 2>&1

echo "===== Kiro Crew bootstrap started: $(date -Is) ====="

# AL2023にはSSM Agentが含まれている。起動を確認する。
systemctl enable --now amazon-ssm-agent || true

# ssm-userを作成してDockerグループに追加・sudo権限付与
useradd -m -s /bin/bash ssm-user 2>/dev/null || true
echo 'ssm-user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ssm-agent-users
chmod 440 /etc/sudoers.d/ssm-agent-users

# Dockerをインストールする。AL2023はdnfを使う。
dnf install -y docker
systemctl enable --now docker
usermod -aG docker ssm-user || true

# Docker Compose ARM64プラグイン (固定バージョン)
COMPOSE_VERSION=v5.4.0
install -d -m 0755 /usr/local/lib/docker/cli-plugins
curl --fail --silent --show-error --location \
  "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-aarch64" \
  --output /usr/local/lib/docker/cli-plugins/docker-compose
chmod 0755 /usr/local/lib/docker/cli-plugins/docker-compose

# swapfile (4GiBインスタンスのメモリ補助)
if ! swapon --show --noheadings | grep -q . && [ ! -e /swapfile ]; then
  fallocate -l 1G /swapfile
  chmod 600 /swapfile
  mkswap /swapfile
  swapon /swapfile
  echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi

# Tailscaleをインストールするが認証は人間が実施する
curl -fsSL https://tailscale.com/install.sh | sh
systemctl enable --now tailscaled

# KiroCrew作業ディレクトリ作成
install -d -m 0755 /opt/kirocrew

# HTTP access log: クエリ文字列・認証情報を記録しない専用ロガー
cat > /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py <<'PY'
"""Safe query-free aiohttp access logging for the Kiro Crew gateway.

PYTHONPATH/sitecustomize.py 経由でロードされる。
記録対象: HTTP method, path(クエリなし), status, duration_ms のみ。
query, header, cookie, authorization, IP, body は記録しない。
"""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
try:
    from aiohttp import web_log
    from aiohttp.web_runner import AppRunner
    class SafeAccessLogger(web_log.AbstractAccessLogger):
        def log(self, request, response, time):
            path = getattr(request, "path", "<unknown>")
            if not isinstance(path, str):
                path = "<unknown>"
            self.logger.info(
                "http_request method=%s path=%s status=%s duration_ms=%.1f",
                getattr(request, "method", "<unknown>"),
                path[:512],
                getattr(response, "status", "<unknown>"),
                time * 1000.0,
            )
    _original_init = AppRunner.__init__
    def _safe_app_runner_init(self, app, *, handle_signals=False, access_log_class=SafeAccessLogger, **kwargs):
        return _original_init(self, app, handle_signals=handle_signals, access_log_class=access_log_class, **kwargs)
    if not getattr(AppRunner, "_kirocrew_safe_access_logging", False):
        AppRunner.__init__ = _safe_app_runner_init
        AppRunner._kirocrew_safe_access_logging = True
    access_logger = logging.getLogger("aiohttp.access")
    access_logger.setLevel(logging.INFO)
    access_logger.propagate = True
    log_path = os.environ.get("KIROCREW_ACCESS_LOG_FILE", "/home/kirocrew/access.log")
    if not any(getattr(h, "_kirocrew_safe_access", False) for h in access_logger.handlers):
        handler = RotatingFileHandler(log_path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8")
        handler.setLevel(logging.INFO)
        handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S%z"))
        handler._kirocrew_safe_access = True
        access_logger.addHandler(handler)
        try:
            os.chmod(log_path, 0o600)
        except OSError:
            pass
except Exception:
    pass
PY
chmod 0644 /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py

# Docker Composeファイル
cat > /opt/kirocrew/compose.yaml <<'COMPOSE'
name: kirocrew-ec2

services:
  kirocrew:
    image: ghcr.io/kirodotdev/kirocrew:stable
    container_name: kirocrew-ec2-gateway
    restart: unless-stopped
    ports:
      - "127.0.0.1:${KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
    volumes:
      - kirocrew-ec2-home:/home/kirocrew
      - ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
    environment:
      PYTHONPATH: /opt/kirocrew/patches
      KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
      # AL2023/Docker runtimeではsandbox無効化が必要。コンテナが唯一の隔離境界。
      KIROCREW_ALLOW_UNSANDBOXED: ${KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}

volumes:
  kirocrew-ec2-home:
    name: ${KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}
COMPOSE

# 環境変数ファイル (mode 600)
VOLUME_SUFFIX=$(date +%Y%m%d)
cat > /opt/kirocrew/.env <<ENV
KIROCREW_HOST_PORT=5478
KIROCREW_VOLUME_NAME=kirocrew-ec2-home-cfn-${VOLUME_SUFFIX}
# AL2023/Docker runtimeでKiro CLI・agentを動かすために必要。
# コンテナが唯一の隔離境界となることをoperatorが明示承認。
KIROCREW_ALLOW_UNSANDBOXED=1
ENV
chmod 600 /opt/kirocrew/.env

# コンテナ起動
cd /opt/kirocrew
docker compose config --quiet
docker compose pull
docker compose up -d --force-recreate

# ヘルスチェック (最大120秒待機)
for attempt in $(seq 1 60); do
  if curl --fail --silent http://127.0.0.1:5478/api/health >/dev/null 2>&1; then
    echo "Kiro Crew health: OK (attempt=${attempt})"
    break
  fi
  if [ "$attempt" -eq 60 ]; then
    echo "ERROR: Kiro Crew health check failed after 120s"
    docker compose logs --tail=100 kirocrew || true
    exit 1
  fi
  sleep 2
done

# sandbox設定
docker compose exec -T kirocrew kirocrew config set \
  agent.sandbox_allow_unsandboxed_exec true || true

# バージョン確認ログ
echo "--- versions ---"
docker --version
docker compose version
tailscale version
echo "--- container status ---"
docker compose ps

printf '%s\n' '====================================='
printf '%s\n' 'Bootstrap completed. Human steps:'
printf '%s\n' '  1. sudo tailscale up'
printf '%s\n' '  2. sudo tailscale status'
printf '%s\n' '  3. cd /opt/kirocrew && sudo docker compose exec kirocrew kiro-cli login --use-device-flow'
printf '%s\n' '  4. sudo docker compose exec kirocrew kirocrew setup'
printf '%s\n' '  5. sudo tailscale serve --bg --yes http://127.0.0.1:5478'
printf '%s\n' '  6. See runbooks/human-operations-runbook.md for full details'
printf '%s\n' '====================================='

echo "===== Kiro Crew bootstrap completed: $(date -Is) ====="

SSM有人操作Runbook

SSMセッション開始後の認証、初期設定、Tailscale Serveの設定、ダッシュボードトークンの取得、接続確認、access.log確認までの手順です。Tailscale ACLの具体例は今回の検証範囲に含めません。

human-operations-runbook.md
# Kiro Crew EC2 - 人間操作 Runbook

CloudFormationでデプロイしたEC2に対して、人間がSSMセッション経由で実施する手順です。

## 対象環境

| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-1 |
| スタック名 | `kirocrew-cfn-20260806`(デプロイ時に指定したもの) |
| Docker host port | `127.0.0.1:5478` |
| Compose directory | `/opt/kirocrew` |
| Bootstrap log | `/var/log/kirocrew-bootstrap.log` |

> **注意**: 認証URL、device code、Tailscaleトークン、dashboard tokenをこのRunbook、チャット、Gitへ保存しないでください。

---

## 事前確認: Bootstrap完了とSSM Online

CFnデプロイ後、EC2がSSM Onlineになるまで2〜5分待ちます。

```bash
# InstanceIdはCFnスタックのOutputsから取得
STACK_NAME=kirocrew-cfn-20260806
REGION=ap-northeast-1

INSTANCE_ID=$(aws cloudformation describe-stacks \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' \
  --output text)

echo "InstanceId: $INSTANCE_ID"

# SSM Online確認 (Online になるまで繰り返す)
aws ssm describe-instance-information \
  --region "$REGION" \
  --filters "Key=InstanceIds,Values=$INSTANCE_ID" \
  --query 'InstanceInformationList[0].{Id:InstanceId,PingStatus:PingStatus,PlatformType:PlatformType}' \
  --output table
```

SSMセッションを開きます。

```bash
aws ssm start-session --target "$INSTANCE_ID" --region "$REGION"
```

Bootstrap完了を確認します(UserDataの最終行)。

```bash
sudo tail -30 /var/log/kirocrew-bootstrap.log
```

期待する末尾:

```
===== bootstrap completed: 2026-08-06T...
```

コンテナとヘルスを確認します。

```bash
cd /opt/kirocrew
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health
```

期待値: `{"ok": true}`

---

## Step 1: Tailscale 認証

### 1-1. Tailscale up (認証URL表示)

SSMセッション内で実行します。

```bash
sudo tailscale up
```

表示された認証URLをブラウザで開き、ログインを完了します。

> 認証URLをこのRunbook・チャット・Gitへ保存しないでください。

### 1-2. Tailscale status 確認

```bash
sudo tailscale status
sudo tailscale version
```

`Connected` と表示されることを確認します。

---

## Step 2: Kiro CLI ライセンス認証

### 2-1. device-flow認証

SSMセッション内で実行します。

```bash
cd /opt/kirocrew
sudo docker compose exec kirocrew kiro-cli login --use-device-flow
```

表示されたURLとdevice codeを使ってブラウザでログインします。

> 認証URL、device codeをこのRunbook・チャット・Gitへ保存しないでください。

### 2-2. 認証確認

```bash
sudo docker compose exec -T kirocrew kiro-cli whoami 2>/dev/null || \
sudo docker compose exec -T kirocrew kiro-cli status 2>/dev/null || true
```

---

## Step 3: Kiro Crew 初期設定

### 3-1. kirocrew setup

SSMセッション内で実行します(対話操作が必要)。

```bash
sudo docker compose exec kirocrew kirocrew setup
```

推奨設定:

| 項目 | 推奨値 |
|---|---|
| Workspace | デフォルト(そのままEnter) |
| Slack | スキップ(no) |
| Slash command | `kirocrew` |
| Timezone | `Asia/Tokyo` |
| Playwright MCP | インストールする(yes) |
| AWS cloud launch | スキップ(no) |

### 3-2. Kiro Crew doctor

```bash
sudo docker compose exec -T kirocrew kirocrew doctor
sudo docker compose ps
```

---

## Step 4: Tailscale Serve 設定

### 4-1. Serve 起動 (tailnet only)

```bash
sudo tailscale serve --bg --yes "http://127.0.0.1:5478"
```

### 4-2. Serve URL 取得

```bash
sudo tailscale serve status
```

期待値の例(hostnameは各環境で異なる):

```
https://<TAILSCALE_HOSTNAME> (tailnet only)
|-- / proxy http://127.0.0.1:5478
```

`tailnet only` と表示されることを確認します。`funnel` は使用しません。

### 4-3. Serve URL を変数に設定

```bash
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
case "$SERVE_URL" in
  https://*) echo "Serve URL: $SERVE_URL" ;;
  *) echo "ERROR: Tailscale Serve URL not found" >&2; exit 1 ;;
esac
```

---

## Step 5: Kiro Crew に Serve URL を登録

```bash
cd /opt/kirocrew

sudo docker compose exec -T kirocrew kirocrew config set \
  dashboard.url "$SERVE_URL"

sudo docker compose restart kirocrew
```

設定を確認します。

```bash
sudo docker compose exec -T kirocrew kirocrew config get dashboard.url
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health
```

---

## Step 6: Dashboard Token 取得

### 6-1. 短期トークン発行

```bash
cd /opt/kirocrew
TOKEN=$(sudo docker compose exec -T kirocrew kirocrew token --ttl 2h)
```

### 6-2. ブラウザ接続用URLをファイルへ一時保存

> tokenをチャット・ログ・Gitへ出力しないでください。

```bash
# EC2ローカルのみに一時保存 (mode 600)
echo "$TOKEN" | sudo tee /run/kirocrew-dashboard-token > /dev/null
sudo chmod 600 /run/kirocrew-dashboard-token
echo "Token saved to /run/kirocrew-dashboard-token (mode 600)"
echo "URL pattern: ${SERVE_URL}?token=<token>"
```

### 6-3. token読み取り・使用・削除

SSMセッション内で実行します。

```bash
# 読み取り (token値はこのRunbookへコピーしない)
sudo cat /run/kirocrew-dashboard-token

# ブラウザでアクセス後、即時削除
sudo rm -f /run/kirocrew-dashboard-token
```

---

## Step 7: 接続確認

### 7-1. EC2ホスト上

```bash
curl --fail --silent "http://127.0.0.1:5478/api/health"
sudo docker compose ps
sudo tailscale serve status
```

### 7-2. Tailscale接続済みの別端末から

tailnetに接続したMac等から実行します(SSHトンネル不要)。

```bash
SERVE_URL="<tailscale serve statusで確認したURL>"
curl --connect-timeout 5 --max-time 10 --fail "${SERVE_URL}/api/health"
```

期待値: `{"ok": true}`

### 7-3. ブラウザアクセス

tailnetに接続した端末のブラウザで以下を開きます。

```
https://<TAILSCALE_HOSTNAME>?token=<token>
```

---

## Step 8: Access Log 確認

```bash
cd /opt/kirocrew
# ファイルパーミッション確認
sudo docker compose exec -T kirocrew stat -c '%a %n' /home/kirocrew/access.log

# 直近50行確認 (method, path, status, duration_msのみ記録)
sudo docker compose exec -T kirocrew sh -c 'tail -n 50 /home/kirocrew/access.log'
```

access logはmethod・queryなしpath・HTTP status・duration_msのみを記録します。
query string、header、cookie、authorization、IP、bodyは記録しません。

---

## トラブルシューティング

### `Host header not allowed.`

```bash
cd /opt/kirocrew
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
sudo docker compose exec -T kirocrew kirocrew config set dashboard.url "$SERVE_URL"
sudo docker compose restart kirocrew
curl --fail --silent "http://127.0.0.1:5478/api/health"
```

### Bootstrap未完了

```bash
sudo tail -50 /var/log/kirocrew-bootstrap.log
sudo systemctl status docker
sudo docker compose -f /opt/kirocrew/compose.yaml ps
```

### コンテナが起動しない

```bash
cd /opt/kirocrew
cat .env  # シークレット値は含まれない
sudo docker compose logs --tail=50 kirocrew
sudo docker compose up -d --force-recreate
```

---

## 停止・ロールバック

Tailscale Serve のみ停止する場合:

```bash
sudo tailscale serve --https=443 off
```

Tailscale Serve を停止してlocalhostのみに戻す場合:

```bash
cd /opt/kirocrew
sudo tailscale serve --https=443 off
sudo docker compose exec -T kirocrew kirocrew config set \
  dashboard.url "http://localhost:5478"
sudo docker compose restart kirocrew
```

---

## セキュリティ上の注意

- Tailscale Serve は `tailnet only` で使用し、`tailscale funnel` は使用しない
- Docker のポートを `0.0.0.0:5478` へ変更しない(loopbackのみ)
- Tailscale ACL でアクセス可能なユーザー・デバイスを制限する
- Kiro Crew の短期ダッシュボードトークンを共有・保存しない
- `KIROCREW_ALLOW_UNSANDBOXED=1` はコンテナが唯一の隔離境界となることを意味する。認証情報のコンテナ内保存リスクをoperatorが承認したうえで運用する
- SSM セッションは操作後に `exit` で切断する

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事