PackerでUnityが動作するUbuntu環境AMIをビルドする

こんにちは、カバー株式会社エンジニアのAです。
本記事では表題の通り、Unityが実行可能なUbuntu環境のAMIをビルドした際のPackerコードをご紹介します。

## はじめに
私はカバー株式会社で、タレントの皆さんが日々の配信で使う「ホロライブアプリ」の開発に携わっています。
ホロライブアプリはUnityで開発しており、いままでこのアプリのテスト・ビルドはオンプレマシンをself-hosted runnerとして登録したgithub actionsで行っていました。しかし、開発の規模の拡大に伴い、オンプレマシンの管理のしにくさや柔軟な運用ができないなどの問題点が浮上しました。
これに対して、AWS上にスケール可能なCI環境を構築しました。EC2インスタンスを管理するOSSとして、こちらを採用しました。

https://github.com/philips-labs/terraform-aws-github-runner

これを使用することで、actions workflowが実行されるごとにEC2 spot instanceが立ち上がり、インスタンスをrunnerとして登録してworkflowの処理を行い、処理が終了すると自動的にインスタンスも終了されるシステムを構築することが可能です。
さて、このシステムでインスタンスを立ち上げる際、Unityを事前にインストールした環境のAMIを作成し、そのAMIをもとにUnity実行環境のインスタンスを立ち上げる必要があります。今回は上記リポジトリの`terraform-aws-github-runner/images/ubuntu-focal/github_agent.ubuntu.pkr.hcl`で提供されていたpackerコードサンプルをもとに、Unity実行可能なAMIをビルドしました。

## できたもの
```hcl:github_agent.ubuntu.pkr.hcl
packer {
  required_plugins {
    amazon = {
      version = ">= 0.0.2"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "runner_version" {
  description = "The version (no v prefix) of the runner software to install https://github.com/actions/runner/releases. The latest release will be fetched from GitHub if not provided."
  default     = null
}

variable "region" {
  description = "The region to build the image in"
  type        = string
  default     = "eu-west-1"
}

variable "security_group_id" {
  description = "The ID of the security group Packer will associate with the builder to enable access"
  type        = string
  default     = null
}

variable "subnet_id" {
  description = "If using VPC, the ID of the subnet, such as subnet-12345def, where Packer will launch the EC2 instance. This field is required if you are using an non-default VPC"
  type        = string
  default     = null
}

variable "associate_public_ip_address" {
  description = "If using a non-default VPC, there is no public IP address assigned to the EC2 instance. If you specified a public subnet, you probably want to set this to true. Otherwise the EC2 instance won't have access to the internet"
  type        = string
  default     = null
}

variable "instance_type" {
  description = "The instance type Packer will use for the builder"
  type        = string
  default     = "t3.medium"
}

variable "root_volume_size_gb" {
  type    = number
  default = 8
}

variable "ebs_delete_on_termination" {
  description = "Indicates whether the EBS volume is deleted on instance termination."
  type        = bool
  default     = true
}

variable "global_tags" {
  description = "Tags to apply to everything"
  type        = map(string)
  default     = {}
}

variable "ami_tags" {
  description = "Tags to apply to the AMI"
  type        = map(string)
  default     = {}
}

variable "snapshot_tags" {
  description = "Tags to apply to the snapshot"
  type        = map(string)
  default     = {}
}

variable "custom_shell_commands" {
  description = "Additional commands to run on the EC2 instance, to customize the instance, like installing packages"
  type        = list(string)
  default     = []
}

variable "temporary_security_group_source_public_ip" {
  description = "When enabled, use public IP of the host (obtained from https://checkip.amazonaws.com) as CIDR block to be authorized access to the instance, when packer is creating a temporary security group. Note: If you specify `security_group_id` then this input is ignored."
  type        = bool
  default     = false
}

data "http" github_runner_release_json {
  url             = "https://api.github.com/repos/actions/runner/releases/latest"
  request_headers = {
    Accept               = "application/vnd.github+json"
    X-GitHub-Api-Version = "2022-11-28"
  }
}

locals {
  runner_version = coalesce(var.runner_version, trimprefix(jsondecode(data.http.github_runner_release_json.body).tag_name, "v"))
}

source "amazon-ebs" "githubrunner" {
  ami_name                                  = "github-runner-ubuntu-focal-amd64-${formatdate("YYYYMMDDhhmm", timestamp())}"
  instance_type                             = var.instance_type
  region                                    = var.region
  security_group_id                         = var.security_group_id
  subnet_id                                 = var.subnet_id
  associate_public_ip_address               = var.associate_public_ip_address
  temporary_security_group_source_public_ip = var.temporary_security_group_source_public_ip

  source_ami_filter {
    filters = {
      name                = "*ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["099720109477"]
  }
  ssh_username = "ubuntu"
  tags = merge(
    var.global_tags,
    var.ami_tags,
    {
      OS_Version    = "ubuntu-focal"
      Release       = "Latest"
      Base_AMI_Name = "{{ .SourceAMIName }}"
  })
  snapshot_tags = merge(
    var.global_tags,
    var.snapshot_tags,
  )

  launch_block_device_mappings {
    device_name           = "/dev/sda1"
    volume_type           = "gp3"
    volume_size           = "${var.root_volume_size_gb}"
    delete_on_termination = "${var.ebs_delete_on_termination}"
  }
}

build {
  name    = "githubactions-runner"
  sources = [
    "source.amazon-ebs.githubrunner"
  ]

  provisioner "shell" {
    environment_vars = [
      "DEBIAN_FRONTEND=noninteractive"
    ]
    inline = concat([
      "sudo cloud-init status --wait",
      "sudo apt-get -y update",
      "sudo apt-get -y install ca-certificates curl gnupg lsb-release",
      "sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg",
      "echo deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null",
      "sudo apt-get -y update",
      "sudo apt-get -y install docker-ce docker-ce-cli containerd.io jq git unzip",
      "sudo systemctl enable containerd.service",
      "sudo service docker start",
      "sudo usermod -a -G docker ubuntu",
      "sudo curl -f https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb -o amazon-cloudwatch-agent.deb",
      "sudo dpkg -i amazon-cloudwatch-agent.deb",
      "sudo systemctl restart amazon-cloudwatch-agent",
      "sudo curl -f https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o awscliv2.zip",
      "unzip awscliv2.zip",
      "sudo ./aws/install",
    ], var.custom_shell_commands)
  }

  provisioner "file" {
    content = templatefile("../../submodules/terraform-aws-github-runner/images/install-runner.sh", {
      install_runner = templatefile("../../submodules/terraform-aws-github-runner/modules/runners/templates/install-runner.sh", {
        ARM_PATCH                       = ""
        S3_LOCATION_RUNNER_DISTRIBUTION = ""
        RUNNER_ARCHITECTURE             = "x64"
      })
    })
    destination = "/tmp/install-runner.sh"
  }

  provisioner "shell" {
    env = {
      RUNNER_TARBALL_URL="https://github.com/actions/runner/releases/download/v${local.runner_version}/actions-runner-linux-x64-${local.runner_version}.tar.gz"
    }
    inline = [
      "sudo chmod +x /tmp/install-runner.sh",
      "echo ubuntu | tee -a /tmp/install-user.txt",
      "sudo RUNNER_ARCHITECTURE=x64 RUNNER_TARBALL_URL=$RUNNER_TARBALL_URL /tmp/install-runner.sh",
      "echo ImageOS=ubuntu20 | tee -a /opt/actions-runner/.env"
    ]
  }

  provisioner "file" {
    content = templatefile("../../submodules/terraform-aws-github-runner/images/start-runner.sh", {
      start_runner = templatefile("../../submodules/terraform-aws-github-runner/modules/runners/templates/start-runner.sh", { metadata_tags = "enabled" })
    })
    destination = "/tmp/start-runner.sh"
  }

  provisioner "shell" {
    inline = [
      "sudo mv /tmp/start-runner.sh /var/lib/cloud/scripts/per-boot/start-runner.sh",
      "sudo chmod +x /var/lib/cloud/scripts/per-boot/start-runner.sh",
    ]
  }

  # ec2-userを追加
  provisioner "file" {
    source      = "./defaults.cfg"
    destination = "/tmp/defaults.cfg"
  }
  provisioner "shell" {
    inline = [
      "sudo mv /tmp/defaults.cfg /etc/cloud/cloud.cfg.d/defaults.cfg",
      "sudo adduser ec2-user",
      "sudo usermod -aG sudo ec2-user",
    ]
  }

  # runnerの権限変更
  provisioner "shell" {
    inline = [
      "sudo chown -R ec2-user:ec2-user /opt/actions-runner/",
      "sudo chown -R ec2-user:ec2-user /opt/hostedtoolcache/",
    ]
  }

  # git-lfsのインストール
  provisioner "shell" {
    inline = [
      "sudo apt-get update && sudo apt-get install -y git-lfs",
    ]
  }

  # Unity Hubのインストール
  provisioner "shell" {
    inline = [
      "wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg > /dev/null",
      "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main\" > /etc/apt/sources.list.d/unityhub.list'",
      "sudo apt update && sudo apt install -y unityhub",
    ]
  }

  # Unityのインストール
  provisioner "shell" {
    inline = [
      "sudo apt-get -y install libgconf-2-4 libgtk-3-0 libgl1-mesa-dev xvfb",
      "sudo mkdir -p /opt/Unity/Hub/Editor",
      "sudo chown -R ubuntu:ubuntu /opt/Unity",
      "xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install-path --set /opt/Unity/Hub/Editor",
      "xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install --version 2020.3.33f1 --changeset 915a7af8b0d5 --module linux-il2cpp windows-mono ios",
      "sudo chown -R ec2-user:ec2-user /opt/Unity",
    ]
  }

  # Unity floating licenseの設定
  provisioner "file" {
    source      = "../services-config.json"
    destination = "/tmp/services-config.json"
  }
  provisioner "shell" {
    inline = [
      "sudo mkdir -p /usr/share/unity3d/config",
      "sudo mv /tmp/services-config.json /usr/share/unity3d/config/services-config.json",
      "sudo chown -R root:root /usr/share/unity3d",
    ]
  }

  post-processor "manifest" {
    output     = "manifest.json"
    strip_path = true
  }
}

```

## 解説
前半は元リポジトリからほぼ書き換わっていません、ざっくりactions-runnerやcloudwatch agentのインストールをしてます。Unityを動作可能にするために行った変更点のみ解説します。

### OSの選定
EC2 spot instanceはAWSの余剰リソースを安価に使えるオプションです。

https://aws.amazon.com/jp/ec2/spot/

安価に使えることのトレードオフとして、spotインスタンスはEC2が容量を必要としたときに中断される可能性があります。そのため、今回のCI環境のような
- 必要な時のみ起動する
- 途中停止しても影響が少ない

ワークロードには適しています。

Linuxインスタンスはon-demandから70% offくらいの料金で使用でき、windows/macOSと比較して安価に利用できることを確認したため、Linuxで実行可能なものはできる限りLinuxで実行する方針にしました。
現在の開発環境であるUnity2020.3.33f1の動作要件で、扱い慣れているUbuntu20.04をsource AMIとしました（amazon linux 2でUnityが実行可能かは検証していません…）。

### Unity Hubのインストール
CLIからUnity Editorをインストールするには、Unity Hub経由でインストールする方法と、DLしたインストーラーからインストールする方法があります。後者の方法は公式のドキュメントで説明されています。

https://docs.unity3d.com/ja/2020.3/Manual/InstallingUnity.html

しかし後者の方法では、Unityの[プラットフォーム依存コンパイル](https://docs.unity3d.com/ja/2021.2/Manual/PlatformDependentCompilation.html)に必要なモジュールを追加することができません。そのため今回は、まずUnity Hubをインストールし、Hub経由で必要モジュールを含めたEditorのインストールを行いました。

```hcl
# Unity Hubのインストール
provisioner "shell" {
    inline = [
        "wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg > /dev/null",
        "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main\" > /etc/apt/sources.list.d/unityhub.list'",
        "sudo apt update && sudo apt install -y unityhub",
    ]
}
```
こちらは公式のドキュメント通りです。

https://docs.unity3d.com/hub/manual/InstallHub.html#install-hub-linux

### Unity Editorのインストール
```hcl
# Unityのインストール
provisioner "shell" {
    inline = [
        "sudo apt-get -y install libgconf-2-4 libgtk-3-0 libgl1-mesa-dev xvfb",
        "sudo mkdir -p /opt/Unity/Hub/Editor",
        "sudo chown -R ubuntu:ubuntu /opt/Unity",
        "xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install-path --set /opt/Unity/Hub/Editor",
        "xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install --version 2020.3.33f1 --changeset 915a7af8b0d5 --module linux-il2cpp windows-mono ios",
        "sudo chown -R ec2-user:ec2-user /opt/Unity",
    ]
}
```
一行ずつ解説します。
```bash
sudo apt-get -y install libgconf-2-4 libgtk-3-0 libgl1-mesa-dev xvfb
```
Unityの実行に必要なライブラリをインストールしています。
```bash
sudo mkdir -p /opt/Unity/Hub/Editor
sudo chown -R ubuntu:ubuntu /opt/Unity
```
今回は諸所の都合により、AMIをビルドする際のユーザーとrunnerを実行するユーザーが異なるという状況のため、`/opt`下にUnityをインストールしてます。
上コマンドでインストール先のディレクトリを作成し、オーナーをAMIビルド時のユーザーである`ubuntu`に変更しています。

EC2インスタンスはheadless環境ですが、素直に`--headless`とつけてunityhubを実行しても以下のエラーが出ます。
```bash:出力
Illegal instruction (core dumped)
```
そこでxvfbを使って仮想ディスプレイを作り、unityhubを実行しました（GPUまわりのエラーを吐かれましたが無視してます）。
```bash
xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install-path --set /opt/Unity/Hub/Editor
```
Unityのインストール先を先に作成したディレクトリに指定します。
```bash
xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless install --version 2020.3.33f1 --changeset 915a7af8b0d5 --module linux-il2cpp windows-mono ios
```
Unity Editorをインストールします。この際、`--version`で指定するインストール可能なバージョンは
```bash
xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless editors --releases
```
で確認できます。
```bash:出力
2023.3.0a15
2023.2.1f1
2022.3.14f1
2021.3.32f1
```
インストールしたいEditorのバージョンがここで表示されない場合、`--changeset`を指定することでインストール可能になります。バージョンとchangesetの対応はUnityのRelease notesから確認可能です。

https://unity.com/ja/releases/editor/archive

`--module`からインストール可能なモジュールは
```bash
xvfb-run --auto-servernum --server-args='-screen 0 640x480x24' unityhub --headless help
```
で確認できます。
```bash:出力
Commands:
  editors
    description: list the releases and installed editors
    alias: e
    example: unityhub --headless editors -r
    options:
      [default]         list of available releases and installed editors on your machine combined
      --releases|-r     only list of available releases promoted by Unity
      --installed|-i    only list of installed editors on your machine
      --add <path>      locating and associating an editor from a stipulated path

  install-path
    description: set/get the path where the Unity editors will be installed
    alias: ip
    example: unityhub --headless ip -s ~/Unity/Hub/Editor/
    options:
      --set|-s <path>   set the install path to the given path
      --get|-g                  returns the install path

  install
    description:
      installs a new editor either from the releases list or archive
    alias: i
    example: unityhub --headless install --version 2019.1.11f1 --changeset 9b001d489a54
    options:
      --version|-v <version>            editor version to be installed (e.g. 2019.1.11f1) - required
      --changeset|-c    <changeset> changeset of the editor if it is not in the release list (e.g. 9b001d489a54)
                    - required if the version is not in the releases. see editors -r
      --module|-m <moduleid>            see install-modules for more information
      --childModules|--cm     automatically installs all child modules of selected modules

  install-modules
    description:
      download and install a module (e.g. build support) to an installed editor
    alias: im
      example: unityhub --headless install-modules --version 2019.1.11f1 -m ios android
    options:
      --version|-v <version>    version of the editor to add the module to - required
      --module|-m  <moduleid> the module id. The followings are the available values depending on version. You can specify multiple values, separated by spaces.
                  For a full list, please visit https://docs.unity3d.com/hub/manual/HubCLI.html

                  Android Build Support: android
                  Android SDK & NDK Tools: android-sdk-ndk-tools
                  OpenJDK: android-open-jdk
                  iOS Build Support: ios
                  Linux Build Support (IL2CPP): linux-il2cpp
                  Mac Build Support (Mono): mac-mono
                  Windows Build Support (Mono): windows-mono
                  WebGL Build Support: webgl
                  Facebook Gameroom Build Support: facebook-games
                  Documentation: documentation
                  Language packs: language-ja, language-ko, language-zh-cn, language-zh-hant, language-zh-hans
      --childModules|--cm     automatically installs all child modules of selected modules
```

### Unity floating licenseの設定
Unity Editor実行時のライセンスには、Unity Build Serverのfloating licenseを用いました。
[こちら](https://synamon.hatenablog.com/entry/2022/03/29/190000)を参考に、Unity実行インスタンスを立ち上げるVPCとは別の、ライセンスサーバー用VPCを構築し、Peering接続によりVPC間で通信可能にしました。

```hcl
# Unity floating licenseの設定
provisioner "file" {
    source      = "../services-config.json"
    destination = "/tmp/services-config.json"
}
provisioner "shell" {
    inline = [
        "sudo mkdir -p /usr/share/unity3d/config",
        "sudo mv /tmp/services-config.json /usr/share/unity3d/config/services-config.json",
        "sudo chown -R root:root /usr/share/unity3d",
    ]
}
```
floating licenseの設定は、configファイルを指定のディレクトリに置くだけです。

https://docs.unity3d.com/licensing/manual/ClientConfig.html

## おわりに
今回のCI環境構築は、こちらの記事シリーズを大いに参考にさせていただきました。

https://synamon.hatenablog.com/entry/2022/09/22/153523

本記事で紹介したのは今回構築したシステム全体のほんの一要素です。今後機会があれば、他の部分や全体についての記事も書くかもしれません（書かないかもしれません）。
本記事が誰かの参考になれば幸いです、最後までお読みいただきありがとうございました。

## 参考
- https://synamon.hatenablog.com/entry/2022/09/22/153523
- https://github.com/philips-labs/terraform-aws-github-runner
- https://aws.amazon.com/jp/ec2/spot/
- https://docs.unity3d.com/ja/2020.3/Manual/InstallingUnity.html
- https://docs.unity3d.com/hub/manual/InstallHub.html#install-hub-linux
- https://forum.unity.com/threads/unable-run-the-editor-without-a-desktop-environment-in-batchmode-with-nographics.609058/
- https://docs.unity3d.com/licensing/manual/ClientConfig.html