Java - Spring Batchのjarをビルドした際にDockerイメージもビルドする(Gradle)

Spring Batchで作成した.jarをDockerコンテナとして実行したい事情があった。
Gradleのビルド時にイメージビルドまでまとめてできないかと調べが、情報が少なそうだったので書く。

環境

  • JDK: OpenJDK Runtime Environment Temurin-17.0.6+10 (build 17.0.6+10)
  • Spring Boot: 2.7.10
  • Spring Batch: 4.3.8
  • Gradle: 7.6.1
  • Docker: 23.0.1, build a5ee5b1dfc

やり方

build.gradleに、ビルド結果の.jarを含めてDockerイメージを作成するタスク定義を追加する。
以下3ファイルをコピペすれば動くはず。

./build.gradle
plugins {
  id 'java'
  id 'org.springframework.boot' version '2.7.10-SNAPSHOT'
  id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
  maven { url 'https://repo.spring.io/milestone' }
  maven { url 'https://repo.spring.io/snapshot' }
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-batch'
  runtimeOnly 'com.h2database:h2'
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'

  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'org.springframework.batch:spring-batch-test'
}

+ // buildタスクの実行後、生成された.jarをDockerイメージビルド用にコピーする。
+ // ./build/libs配下に置いたままイメージビルドするとビルドコンテキストが
+ // 大きくなってしまったりignoreを書くのが面倒だったりなので。
+ build.doLast {
+   copy {
+     from "./build/libs/demo-0.0.1-SNAPSHOT.jar"
+     into "./docker/"
+     rename "demo-0.0.1-SNAPSHOT.jar", "demo.jar"
+   }
+   // jarビルド時にDockerイメージのビルドも一緒にやってしまった方が楽な場合もあるかもしれない。
+   // exec {
+   //   executable 'sh'
+   //   args '-c', 'docker build -t sample_img -f ./docker/Dockerfile ./docker/'
+   // }
+ }

+ clean.doFirst {
+   delete './docker/demo.jar'
+ }

+ // Dockerイメージビルドタスク
+ task docker(type: Exec) {
+   executable 'sh'
+   args '-c', 'docker build -t sample_img -f ./docker/Dockerfile ./docker/'
+ }
+ docker.dependsOn build

tasks.named('test') {
  useJUnitPlatform()
}
./src/main/java/com/example/demo/SampleJobConfig.java
package com.example.demo;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import lombok.extern.slf4j.Slf4j;

/**
 * 実行時の引数「nanika」の内容をログ出力するJob.
 * nanikaが指定されない場合は「デフォルト値です!」をログ出力する.
 */
@Configuration
@EnableBatchProcessing
@Slf4j
public class SampleJobConfig {
  @Autowired
  public JobBuilderFactory jobFactory;

  @Autowired
  public StepBuilderFactory stepFactory;

  @Value("${nanika:デフォルト値です!}")
  private String nanika;

  @Bean
  public Job sampleJob(Step sampleStep1) {
    return jobFactory
      .get("sampleJob")
      .incrementer(new RunIdIncrementer())
      .start(sampleStep1)
      .build();
  }

  @Bean
  public Step sampleStep1() {
    return stepFactory
      .get("sampleStep1")
      .tasklet((contrib, context) -> {
        log.warn(nanika);
        return RepeatStatus.FINISHED;
      })
      .build();
  }

}
./docker/Dockerfile
FROM eclipse-temurin:17.0.4.1_1-jre-jammy
RUN addgroup --system spring \
  && adduser --system spring \
  && usermod -aG spring spring
USER spring:spring

COPY ./demo.jar demo.jar
ENTRYPOINT ["java", "-jar", "/demo.jar"]

jarとDockerイメージのビルド

Gradleでdockerタスクを実行すれば、jarのビルドからDockerイメージの作成までやってくれる。

./gradlew docker

Dockerコンテナ実行

実行すると標準出力の警告ログに「なんらかのパラメータ」が表示されるはず。

docker run --rm sample_img --nanika=なんらかのパラメータ

Java - Visual Studio CodeでDomaの注釈処理を使う

Visual Studio CodeDomaの注釈処理を使ってDao付近を自動生成したくて調べたが、情報がなさそうだったので書く。

環境

やり方

事前準備

自動生成が動くにはorg.eclipse.buildship.core.prefsが生成されている必要があるようだ。
このファイルの中身はbuild.gradleの依存関係がインポートされる際に生成されるが、インポートの時点でファイルがなかったり空ファイルだったりした場合は中身の生成がスキップされてしまい、その後のソースの自動生成も反応してくれない。
このため、最初にファイルを作成し以下2行を記載しておく。

.settings/org.eclipse.buildship.core.prefs
connection.project.dir=
eclipse.preferences.version=1

注釈処理の有効化

Visual Studio CodeでGradleを使って注釈処理するという内容は以下リンクにて言及されていた。
これを参考にDomaも扱えるようにする。
Is Annotation Processing Supported for Gradle project? · Issue #1039 · redhat-developer/vscode-java · GitHub

注釈処理の依存関係を追加

build.gradle
plugins {
  id 'org.springframework.boot' version '2.7.4'
  id 'io.spring.dependency-management' version '1.0.14.RELEASE'
  id 'java'
  id 'war'
+ // 注釈処理
+ id 'com.diffplug.eclipse.apt' version '3.39.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
  providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'

  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'

+ // doma関連
+ implementation 'org.seasar.doma:doma-core:2.53.1'
+ implementation 'org.seasar.doma.boot:doma-spring-boot-starter:1.6.0'
+ annotationProcessor 'org.seasar.doma:doma-processor:2.53.1'
}

+ // 自動生成ファイルの出力先ディレクトリ
+ eclipse {
+   jdt {
+     apt {
+       genSrcDir = file('src-gen/main/java')
+       genTestSrcDir = file('src-gen/test/java')
+     }
+   }
+ }

+ // SQLテンプレートのコピータスク
+ task _copySqls(type: Copy) {
+   from 'src/main/resources/META-INF'
+   into 'bin/default/META-INF'
+ }

tasks.named('test') {
  useJUnitPlatform()
}

設定ファイル生成

以下のビルドタスクを実行して設定ファイルを生成する。

  • eclipseJdtApt
  • eclipseJdt
  • eclipseFactorypath

Visual Studio Codeを再起動

再起動するとビルドタスクで自動生成ファイルの出力先として指定したディレクトリ(src-gen)が作成される。

Gradleのリフレッシュ

手元の環境ではこれをやらないと自動生成してくれなかった。
build.gradleに空行を足して保存すれば、依存関係を追記した際のように同期するかを聞いてくるのでyesすればよい。

自動生成の動作確認

ここまでの手順がうまくいっていれば、以下のファイルを作成すると同時にsrc-genディレクトリの配下に_Employee.javaが自動生成されるはず。

src/main/java/com/example/demo/Employee.java
package com.example.demo;

import org.seasar.doma.*;
import lombok.Data;

@Entity
@Data
public class Employee {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  @SequenceGenerator(sequence = "EMPLOYEE_SEQ")
  Integer id;

  String name;
}

サンプルプロジェクト

自動生成されたクラスを使って実際に動作するサンプルとして、http://localhost:8080/employees で待機し、employeeテーブルの内容をJSON形式で応答するAPIを作成してみる。
Spring Bootで新規プロジェクトを作り、この記事のソースをコピペすれば動くはず。

DBを作る

CREATE OR REPLACE DATABASE testdb1;
CREATE OR REPLACE TABLE employee (
  id int not null auto_increment primary key,
  name varchar(255)
);

INSERT INTO employee VALUE (1, 'Lorem ipsum');
INSERT INTO employee VALUE (2, 'dolor sit');
INSERT INTO employee VALUE (3, 'amet consectetur');
INSERT INTO employee VALUE (4, 'adipiscing elit');

SELECT * FROM employee;

ソース類を書く

作成するソースは以下の通り。

  • application.properties
  • Employee.java (自動生成の動作確認で作成したもの)
  • EmployeeDao.java
  • EmployeeController.java
  • all.sql
src/main/resources/application.properties
spring.datasource.url=jdbc:mariadb://mariadb:3306/testdb1
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driverClassName=org.mariadb.jdbc.Driver
src/main/java/com/example/demo/EmployeeDao.java
package com.example.demo;

import java.util.List;
import org.seasar.doma.*;
import org.seasar.doma.boot.ConfigAutowireable;

// @ConfigAutowireableを付けておくと、
// Implを自動生成した際に@Repositoryを付加しておいてくれる。
@Dao
@ConfigAutowireable
public interface EmployeeDao {
  @Select
  List<Employee> all();
}

EmployeeDaoを作成するとall()メソッドでエラーになるが、いったんそのまま進める。
この手順の最後でSQLファイルをbin配下にコピーすることで解消できる。

src/main/java/com/example/demo/EmployeeController.java
package com.example.demo;

import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

@RestController
public class EmployeeController {

  @Lazy
  @Autowired
  private EmployeeService service;

  @GetMapping("/employees")
  public List<Employee> index() throws Exception {
    return service.list();
  }

  @Service
  public class EmployeeService {
    @Autowired
    EmployeeDao dao;

    public List<Employee> list() throws Exception {
      return dao.all();
    }
  }
}
src/main/resources/META-INF/com/example/demo/EmployeeDao/all.sql
SELECT
  /*%expand*/*
FROM
  employee
ORDER BY
  id

SQLファイルをbin配下へコピーする

以下のビルドタスクでSQLファイルをbin配下へコピーする。

  • _copySqls

タスク実行後、EmployeeDaoを再コンパイルするとエラーが解消する。


これでRun and Debugからサーバ起動すれば動くはず。

参考リンク

*1:MariaDBはサンプルプロジェクトで利用

Java - AWSのNLB越しに長時間かかるリクエストを送ると応答待ちのまま終わらなくなる場合の対策

EC2上のWebアプリから別のEC2上にあるWebアプリのAPIをリクエストした際、応答までに350秒以上経過するとリクエストした側が応答待ちのまま終わらなくなる、という事象があった。
調べた時に具体的な対策サンプルが少なそうだったので書く。

構成

だいたいこんな感じ

  • EC2: Linux
  • Web Application: Java17 (Spring Boot 2.7.3)

原因

Network Load Balancer - Elastic Load Balancing

NLBを経由するリクエストがアイドル状態のまま350秒以上経過すると、NLBはその接続を切断する。
この時通知などは行われないため、リクエストした側は切断されたことを検知できず永久に応答を待ち続けることになる。
また、応答した側もエラーにはならないため何が起きたのか分かりづらい。

流れはこんな感じ

対策

TCP KeepAliveで継続的にパケットを流すことで、350秒のカウンターをリセットし続けることができる。
リクエストする側で以下2点を修正する。

  1. リクエスト時のソケット設定でTCP KeepAliveを有効にする
  2. OSの設定でTCP KeepAliveパケット送出開始までの待機時間を350秒未満にする
    TCP KeepAliveパケットの送出開始までの待機時間はOSが制御している。
    デフォルトでは7200秒(2時間)になっているようなので、350秒未満に変更する。

サンプルコード

http://localhost:8080/greetingで待機し、リクエストを受け付けると外部APITCP KeepAliveを有効にしたリクエストを送るサンプル。
Spring Bootでプロジェクトを新規作成し、以下の2ファイルをコピペすれば動くはず。

1. ソース

build.gradle
plugins {
  id 'org.springframework.boot' version '2.7.3'
  id 'io.spring.dependency-management' version '1.0.13.RELEASE'
  id 'java'
  id 'war'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  // ソケットでTCP KeepAliveを設定するために使用
  implementation 'org.apache.httpcomponents:httpclient:4.5.13'
  providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
GreetingController.java
package com.example.demo;

import org.apache.http.config.SocketConfig;
import org.apache.http.impl.client.HttpClients;
import org.slf4j.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.*;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.scheduling.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;

@EnableAsync
@RestController
public class GreetingController {
  private Logger logger = LoggerFactory.getLogger(GreetingController.class);

  @Lazy
  @Autowired
  private ExternalApiAccessor api;

  @GetMapping("/greeting")
  public String greeting() throws Exception {
    logger.info("greeting");
    api.access();
    return "Hello, World!";
  }

  @Bean
  public RestTemplate restTemplate() {
    // ソケットのTCP KeepAliveを有効にする
    var socketCfg = SocketConfig.custom().setSoKeepAlive(true).build();
    var httpclient = HttpClients.custom().setDefaultSocketConfig(socketCfg).build();
    var reqFactory = new HttpComponentsClientHttpRequestFactory(httpclient);
    var template = new RestTemplateBuilder().requestFactory(() -> reqFactory).build();
    return template;
  }

  @Service
  public class ExternalApiAccessor {
    @Autowired
    private RestTemplate restTemplate;

    @Async
    public void access() throws Exception {
      logger.info("API access");
      // NLBの向こう側にいて350秒以上かかるAPI
      var apiUrl = "https://example.com/external_api/heavy_work";
      var response = restTemplate.getForEntity(apiUrl, String.class);
      logger.info(response.getBody());
    }
  }
}

2. OS設定変更

EC2
## 現状確認
# sysctl -a | grep tcp_keepalive
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

## 修正 (このファイルがない場合は新規作成する)
# vi /etc/sysctl.conf
EC2 /etc/sysctl.conf
 :
## TCP KeepAliveパケット送出開始までの待機時間 / 350秒未満にする
net.ipv4.tcp_keepalive_time = 120
 :

これで大丈夫になるはず。

GitLab - オンプレのLXDにGitLabを立てる

GitLabなどを載せているLXDのホストをDebian(buster)から(bullseye)に引っ越すついでにクリーンインストールした。
インストールのやり方などを調べていて、ミドルやアプリを組み合わせた場合のサンプルがもっとあるといいなと思ったので書く。

そりゃみんなSaaS使うよね、という内容。

できあがりイメージ

だいたいこんな感じ

  • 物理マシン(Windows)に入れたVirtualBox上でDebianを動かしLXDのホストにする
  • LXDでGitLab用とDocker用の2つのコンテナを動かす
    • GitLab用コンテナ
      GitLab本体の他、Container Registry(CIで使用するDockerイメージ保管用)と、Pages(カバレッジレポート表示用)を動かす。
      ただ、Pages周りはヘンテコ運用になってしまった。残念。
    • Docker用コンテナ
      GitLab Runnerを動かす。
      図のGitLab Runnerの構成は以前の記事で使用した際のもの。
  • VMはブリッジ接続し、LXDコンテナはポート転送する

I/O激遅になるかもと思ったが、一人で使う分には大丈夫そう。
複数人だとGitLab Runnerを別マシンに移すなりしないと厳しいと思われる。

目安として、GitLabのWebUIはプロジェクトのトップページ(ファイル一覧)の表示が0.9秒くらい。
CIでは、Rustのwarp+mongodbを使ったHello Worldレベルのプロジェクトで、ビルドが1.5分、カバレッジ取得(テスト5ケースほど)が2分くらい。
CI実行中はWebUIの表示も2秒掛かるくらい重くなる。

環境

  • VirtualBox: 6.1.34-150636
  • Debian: 11.3 bullseye
  • LXD: 5.1
  • ZFS: 2.1.4-1~bpo11+1
  • Gitlab: gitlab-ee 14.10.2 (Omnibus)
  • Docker: 20.10.16, build aa7e414

  • VMへの割り当てリソース
    • メモリ: 8,192MB
    • プロセッサ数: 8 (3.1GHz)
    • ストレージ:
      • SATA 0: 20GB /dev/sda (OS用)
      • SATA 1: 50GB /dev/sdb (LXDのストレージバックエンド用)
    • ネットワーク接続: ブリッジアダプター

作る

VirtualBox

VM(ハコ)作成

環境の内容でハコを作る。

OSインストール

Debian 11.3 bullseyeを入れる。
今回の記事で関連しそうな設定は以下の通り。

  • Graphical Installを使用 (sudo未インストール)
  • ホスト名はdebian1
  • ユーザ名はuser1
  • ストレージは小さい方(20GB)が/dev/sdaで、大きい方(50GB)が/dev/sdbになるようにする
  • aptミラーはftp.jp.debian.org
  • Software selectionはSSH Serverのみを選択

ツール類インストール

VM
user1@debian1:~$ su -
root@debian1:~# apt update
root@debian1:~# apt install sudo snapd parted
root@debian1:~# usermod -aG sudo user1
  • この後、user1をログインし直せばsudoできるようになっているはず
  • snapdlxdのインストール用で、partedZFSパーティション作成用
  • 必須ではないが、この他にvim, bash-completion, ncdu, btop(contrib)を入れた

IPアドレス割り当て

  • 静的IP割り当て
VM /etc/network/interfaces
    :
# The primary network interface
- allow-hotplug enp0s3
- iface enp0s3 inet dhcp
+ auto enp0s3
+ iface enp0s3 inet static
+   address 192.0.2.101
+   netmask 255.255.255.0
+   gateway 192.0.2.1
    :
  • サービス再起動
VM
user1@debian1:~$ sudo systemctl restart networking.service

SSH接続設定

サーバコンソールだとコピペできなくてつらいので、早めにssh接続できるようにしておきたい。

PC ~/.ssh/config
  :
+ # LXD host
+ Host debian1
+   HostName 192.0.2.101
+   PreferredAuthentications publickey
+   IdentityFile ~/.ssh/id_rsa

# ついでにgit cloneとかで使う分も追記しておく
+ # git remote
+ Host 192.0.2.101
+   HostName 192.0.2.101
+   Preferredauthentications publickey
+   IdentityFile ~/.ssh/id_rsa
+   Port 1022
  • クライアントマシンからサーバへ鍵を送る
PC
user1@pc1:~$ ssh-copy-id user1@192.0.2.101

# 鍵で接続できるようになったはず
user1@pc1:~$ ssh debian1

エイリアス設定

必須ではないが、やっておくと後々ラク

VM
user1@debian1:~$ echo -e "\n\nalias ll='ls -Flh'\nalias lk='ls -AFlh'" >> ~/.bashrc
user1@debian1:~$ source ~/.bashrc

ZFS

LXDのストレージバックエンドとしてZFSを使う。

以前は、お試しで入れたディレクトリバックエンドをそのまま使い続けていたが、環境を移行しようとlxc publishしようとしたところ、びっくりするほど遅くて諦めたので、今回はZFSを使うことにした。

ストレージの設定 - LXD ドキュメント

インストール

ZFScontribコンポーネントにある。
今回はbackportsからインストールする。

ZFS - Debian Wiki

VM
# contribとbackportsを追加
user1@debian1:~$ sudo sed -i.original -e 's# main$# main contrib#g' /etc/apt/sources.list
user1@debian1:~$ echo "deb http://ftp.jp.debian.org/debian bullseye-backports main contrib" | sudo tee -a /etc/apt/sources.list
user1@debian1:~$ echo "deb-src http://ftp.jp.debian.org/debian bullseye-backports main contrib" | sudo tee -a /etc/apt/sources.list
user1@debian1:~$ sudo apt update

# ZFSインストール
user1@debian1:~$ sudo apt install linux-headers-amd64
user1@debian1:~$ sudo apt install -t bullseye-backports zfsutils-linux
user1@debian1:~$ sudo modprobe zfs

パーティション作成

/dev/sdbZFSで初期化する。

VM
user1@debian1:~$ sudo parted /dev/sdb
GNU Parted 3.4
Using /dev/sdb
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) p
Error: /dev/sdb: unrecognised disk label
Model: ATA VBOX HARDDISK (scsi)
Disk /dev/sdb: 53.7GB
Sector size (logical/physical): 512B/512B
Partition Table: unknown
Disk Flags:
(parted) mklabel gpt
(parted) mkpart zfs1 zfs 2048s 100%
(parted) p
Model: ATA VBOX HARDDISK (scsi)
Disk /dev/sdb: 53.7GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number  Start   End     Size    File system  Name  Flags
 1      1049kB  53.7GB  53.7GB  zfs          zfs1

(parted) q
Information: You may need to update /etc/fstab.

LXD

インストール

snapdで入れる。

Linux Containers - LXD - LXDを使い始めるには

VM
user1@debian1:~$ sudo snap install lxd
user1@debian1:~$ sudo usermod -aG lxd user1
  • この後、user1をログインし直せばlxcコマンドが使えるようになっているはず
  • Ubuntuと比べて、たまにlxcコマンドの反応が遅いような……

初期設定

イメージの自動更新はOFFにしておく。
頻繁にコンテナを作るわけではないので、せっかく最新に保ってくれていても回線やストレージI/Oの無駄遣いになってしまう。

VM
user1@debian1:~$ lxd init
Would you like to use LXD clustering? (yes/no) [default=no]:
Do you want to configure a new storage pool? (yes/no) [default=yes]:
Name of the new storage pool [default=default]: zfs_pool1
Name of the storage backend to use (lvm, zfs, ceph, btrfs, dir) [default=zfs]:
Create a new ZFS pool? (yes/no) [default=yes]:
Would you like to use an existing empty block device (e.g. a disk or partition)? (yes/no) [default=no]: yes
Path to the existing block device: /dev/sdb1
Would you like to connect to a MAAS server? (yes/no) [default=no]:
Would you like to create a new local network bridge? (yes/no) [default=yes]:
What should the new bridge be called? [default=lxdbr0]:
What IPv4 address should be used? (CIDR subnet notation, “auto” or “none”) [default=auto]:
What IPv6 address should be used? (CIDR subnet notation, “auto” or “none”) [default=auto]: none
Would you like the LXD server to be available over the network? (yes/no) [default=no]:
Would you like stale cached images to be updated automatically? (yes/no) [default=yes]: no
Would you like a YAML "lxd init" preseed to be printed? (yes/no) [default=no]:

GitLab用コンテナ作成

コンテナ(ハコ)作成

  • 作成したコンテナを誤って削除できないようガードを掛ける
    Linux Containers - LXD - Advanced guide
    Method 1のやり方(プロンプトを出す)だと「アーハイハイ」でEnterしてしまいがちなので、明確なオペレーションが発生するMethod 2のやり方にした。
  • VMの外からHttpなどでアクセスできるようにポート転送する
VM
# コンテナ作成
user1@debian1:~$ lxc launch images:debian/bullseye gitlab1

# コンテナの誤削除防止
user1@debian1:~$ lxc config set gitlab1 security.protection.delete=true

# ポート転送
#  - 1081: GitLabのWebUI
#  - 1086: GitLabのContainer Registry
#  - 1022: gitクライアント向けのssh (こっちを22番にした方がいいかも?)
user1@debian1:~$ lxc config device add gitlab1 http1081_gitlab   proxy listen=tcp:0.0.0.0:1081 connect=tcp:127.0.0.1:1081 bind=host
user1@debian1:~$ lxc config device add gitlab1 http1086_registry proxy listen=tcp:0.0.0.0:1086 connect=tcp:127.0.0.1:1086 bind=host
user1@debian1:~$ lxc config device add gitlab1 ssh1022_gitlab    proxy listen=tcp:0.0.0.0:1022 connect=tcp:127.0.0.1:22 bind=host

GitLabインストール

今回はLXD上で運用するため、公式手順とは異なるやり方をした。
また、旧環境からデータを移行する場合は個別の考慮が必要になる。

Download and install GitLab | GitLab

  • LXDコンテナで動かすためにカーネルパラメータの変更をスキップ
    通常の手順ではインストールスクリプト内のreconfigureによってカーネルパラメータが変更されるが、非特権コンテナではエラーになる。
    以下のように進めることでこれを回避する。

    1. apt install時にEXTERNAL_URLを指定しない
      指定しなければreconfigureは実行されない。
    2. GitLabのインストール後、設定ファイルでカーネルパラメータを変更しないよう指定
    3. 手動でgitlab-ctl reconfigureを実行

    Unable to install Gitlab CE in LXC container (permission denied on read-only file system) (#336774) · Issues · GitLab.org / GitLab · GitLab

  • 旧環境からデータを移行する場合はGitLabのバージョンを旧環境と合わせる
    GitLabのバックアップは取得したバージョンと同じバージョンにしかリストアできない。
    このため、移行する場合は旧環境を最新バージョンにしておくか、新環境へいったん旧バージョンを入れてリストアしてから最新版へ更新する必要がある。
    今回は後者のやり方にした。
    Restore GitLab

VM
user1@debian1:~$ lxc exec gitlab1 bash
LXC GitLab
# aptをJPミラーに向けておく
root@gitlab1:~# sed -i.original 's#/deb.debian.org/debian #/ftp.jp.debian.org/debian #g' /etc/apt/sources.list
root@gitlab1:~# apt update
root@gitlab1:~# apt install curl openssh-server ca-certificates perl

# メール配信しないので`postfix`はインストールしない

root@gitlab1:~# curl https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh | bash

# - `EXTERNAL_URL`を指定しない
# - 旧環境からデータ移行する場合は旧環境と同じバージョンをインストールする(ここでは仮に14.8.2としておく)
root@gitlab1:~# apt install gitlab-ee=14.8.2-ee.0

設定ファイル編集

GitLabを起動する前に、設定ファイルでエラーの回避指定や起動するサービスの選別などをしておく。

WebUIアクセス用URL指定

通常の手順ではインストール時に指定するEXTERNAL_URL

LXC GitLab /etc/gitlab/gitlab.rb
  :
- external_url 'http://gitlab.example.com'
+ external_url 'http://192.0.2.101:1081'
  :
カーネルパラメータ変更のスキップ

gitlab-ctl reconfigure実行時にカーネルパラメータを変更しないようにする。

Unable to install Gitlab CE in LXC container (permission denied on read-only file system) (#336774) · Issues · GitLab.org / GitLab · GitLab

LXC GitLab /etc/gitlab/gitlab.rb
  :
##! Attempt to modify kernel paramaters. To skip this in containers where the
##! relevant file system is read-only, set the value to false.
- # package['modify_kernel_parameters'] = true
+ package['modify_kernel_parameters'] = false
  :
(有効化) GitLab Pages

CIのカバレッジレポート表示用。

GitLab Pages administration | GitLab

IP指定だと思い通りにいかなかったので、ここだけ間に合わせのホスト名を割り振り、クライアントPCのhostsにも対応するホスト名を追記してやり過ごすことにした。

LXC GitLab /etc/gitlab/gitlab.rb
  :
- # pages_external_url "http://pages.example.com/"
- # gitlab_pages['enable'] = false
+ pages_external_url "http://gitlab-pages.test:1081/"
+ gitlab_pages['enable'] = true
  :

実際に生成されるURLでは、pages_external_urlで定義したホストの先頭にプロジェクトが属するグループ名が付加される。
例えば上記の定義であれば、hogeグループに属するfugaプロジェクトのCIでカバレッジレポートを生成した場合、URLの先頭部分は次のようになる。
http://hoge.gitlab-pages.test:1081/-/fuga/

このため、クライアントPCのhostsにもグループ数分のエントリを追加するか、DNSを立てる必要がある。

PC
user1@pc1:~$ echo "192.0.2.101   hoge.gitlab-pages.test" >> /etc/hosts

力及ばずヘンテコな運用になってしまい無念である。

(有効化) GitLab Container Registry

CIでのDockerイメージ格納用。
ポート番号はGitLabのWbUIとは別にしておく。

GitLab Container Registry administration | GitLab

LXC GitLab /etc/gitlab/gitlab.rb
  :
- # registry_external_url 'https://registry.example.com'
+ registry_external_url 'http://192.0.2.101:1086'
  :
(無効化) Prometheus

メトリクス収集。
欲しい情報はGitLabの基本的な機能で足りているので今回は使わない。

Monitoring GitLab with Prometheus | GitLab

LXC GitLab /etc/gitlab/gitlab.rb
  :
# To completely disable prometheus, and all of it's exporters, set to false
- # prometheus_monitoring['enable'] = true
+ prometheus_monitoring['enable'] = false
  :
(無効化) Grafana

Prometheusが収集したメトリクスをダッシュボードで視覚化してくれる。
Prometheus同様今回は使わない。

Grafana Dashboard Service | GitLab

LXC GitLab /etc/gitlab/gitlab.rb
  :
- # grafana['enable'] = true
+ grafana['enable'] = false
  :
(無効化) GitLab agent server for Kubernetes (KAS)

Kubernetesクラスターとの接続を構成・監視する?仕組みらしい。
Kubernetesが分かっていないせいか説明を読んでもサッパリだが、使ってないはずなのでヨシ!

Install the GitLab agent server for Kubernetes (KAS) | GitLab

LXC GitLab /etc/gitlab/gitlab.rb
  :
##! Enable GitLab KAS
- # gitlab_kas['enable'] = true
+ gitlab_kas['enable'] = false
  :

編集内容を反映

LXC GitLab
# 反映
root@gitlab4:~# gitlab-ctl reconfigure

# WebUIの起動確認 / 起動するまでは`502`
root@gitlab3:~# curl -ILs localhost:1081 | grep HTTP

旧環境のバックアップをリストア

移行しないならこの手順は不要。

Back up and restore GitLab
バックアップ/リストア関連の手順は割と頻繁に変わってそう。
earlier/laterがいっぱいある。

旧環境からバックアップファイルを転送
LXC GitLab
# 旧環境 → GitLabコンテナ
root@gitlab1:~# sftp user1@old_server
sftp> get gitlab_config_1652870735_2022_05_18.tar
sftp> get 1652869171_2022_05_18_14.8.2-ee_gitlab_backup.tar
sftp> bye
リストア
LXC GitLab
# バックアップファイルを所定の場所に配置
root@gitlab1:~# mkdir /etc/gitlab/config_backup/
root@gitlab1:~# mv gitlab_config_1652870735_2022_05_18.tar /etc/gitlab/config_backup/
root@gitlab1:~# mv 1652869171_2022_05_18_14.8.2-ee_gitlab_backup.tar /var/opt/gitlab/backups/
root@gitlab1:~# chown git:git /var/opt/gitlab/backups/1652869171_2022_05_18_14.8.2-ee_gitlab_backup.tar

# サービス停止
root@gitlab1:~# gitlab-ctl stop puma
root@gitlab1:~# gitlab-ctl stop sidekiq

# リストアして再起動
root@gitlab1:~# gitlab-backup restore BACKUP=1652869171_2022_05_18_14.8.2-ee
root@gitlab1:~# gitlab-ctl reconfigure
root@gitlab1:~# gitlab-ctl restart
root@gitlab1:~# gitlab-rake gitlab:check SANITIZE=true

# 何かのチェック
root@gitlab1:~# gitlab-rake gitlab:doctor:secrets
root@gitlab1:~# gitlab-rake gitlab:artifacts:check
root@gitlab1:~# gitlab-rake gitlab:lfs:check
root@gitlab1:~# gitlab-rake gitlab:uploads:check
最新バージョンへ更新

リストアのために古いバージョンのGitLabをインストールしていた場合は、最新バージョンへ更新しておく。
たまにUpgrade pathsに引っ掛かることがある。

LXC GitLab
# 最新版に直接アップグレードできそうか見てみる
root@gitlab1:~# apt update
root@gitlab1:~# apt upgrade -s
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Calculating upgrade... Done
The following packages will be upgraded:
  gitlab-ee
1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst gitlab-ee [14.8.2-ee.0] (14.10.2-ee.0 gitlab-ee:1/bullseye [amd64])
Conf gitlab-ee (14.10.2-ee.0 gitlab-ee:1/bullseye [amd64])

# Upgrade pathsを見ると、14.8.2からは14.9.0を経由しないと
# 最新版(14.10.2)にできないので、いったん14.9.0に更新する
root@gitlab1:~# apt install gitlab-ee=14.9.0-ee.0

# 14.9.0が入ったら最新版へ
root@gitlab1:~# apt upgrade

Docker用コンテナ作成

コンテナ(ハコ)作成

LXDコンテナ内でDockerコンテナを動かせるよう、コンテナのネストを許可しておく。

VM
# コンテナ作成 / ネストを許可
user1@debian1:~$ lxc launch images:debian/bullseye docker1 -c security.nesting=true

# コンテナの誤削除防止
user1@debian1:~$ lxc config set docker1 security.protection.delete=true

Dockerインストール

公式の手順通りで大丈夫。
Install Docker Engine on Debian | Docker Documentation

VM
user1@debian1:~$ lxc exec docker1 bash
LXC Docker
# aptをJPミラーに向けておく
root@docker1:~# sed -i.original 's#/deb.debian.org/debian #/ftp.jp.debian.org/debian #g' /etc/apt/sources.list

root@docker1:~# apt-get remove docker docker-engine docker.io containerd runc

root@docker1:~# apt-get update
root@docker1:~# apt-get install ca-certificates curl gnupg lsb-release

root@docker1:~# curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
root@docker1:~# echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
root@docker1:~# apt-get update
root@docker1:~# apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin

root@docker1:~# docker run --rm hello-world

GitLab Runnerインストール

GitLabのWebUIでRunnersの編集画面にコマンドが書いてあるのでコピペ。
WebUIへログインするためのパスワードはGitLabの新規ユーザ登録を参照。

Menu > Admin > Overview(サイドメニュー) > Runners > Register an instance runner > Show runner installation and registration instructions

gitlab-runner register指定サンプル:

LXC Docker
root@docker1:~# sudo gitlab-runner register --url http://192.0.2.101:1081/ --registration-token b41a-9VWVPFsjiqv-AJB
Runtime platform                                    arch=amd64 os=linux pid=5329 revision=f761588f version=14.10.1
Running in system-mode.

Enter the GitLab instance URL (for example, https://gitlab.com/):
[http://192.0.2.101:1081/]:
Enter the registration token:
[b41a-9VWVPFsjiqv-AJB]:
Enter a description for the runner:
[docker1]: Example Runner 1
Enter tags for the runner (comma-separated):

Enter optional maintenance note for the runner:

Registering runner... succeeded                     runner=b41a-9VW
Enter an executor: docker-ssh+machine, kubernetes, docker, shell, ssh, virtualbox, custom, docker-ssh, parallels, docker+machine:
docker
Enter the default Docker image (for example, ruby:2.7):
debian:stable-slim
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

gitlab-runnerユーザをdockerグループに追加

しないとCI実行時にエラー。

  • dial unix /var/run/docker.sock: connect: permission denied.
LXC Docker
root@dockers:~# usermod -aG docker gitlab-runner

gitlab-runnerユーザの.bash_logoutを無効化

しないとCI実行時にエラー。

  • ERROR: Job failed: prepare environment: exit status 1. Check https://docs.gitlab.com/runner/shells/index.html#shell-profile-loading for more information

Types of shells supported by GitLab Runner | GitLab

LXC Docker
root@dockers:~# mv /home/gitlab-runner/.bash_logout{,.bk}

コンテナレジストリへのHTTPアクセスを許可

しないとCI実行時にエラー。

  • Error response from daemon: Get "https://192.0.2.101:1086/v2/": http: server gave HTTP response to HTTPS client

Test an insecure registry | Docker Documentation

LXC Docker
root@docker1:~# echo '{ "insecure-registries" : ["192.0.2.101:1086"] }' >> /etc/docker/daemon.json
root@docker1:~# systemctl restart docker

GitLabの新規ユーザ登録

移行した場合は(たぶん)不要。

インストール直後はユーザがrootしかいないので、rootでWebUIにログインして他のユーザを作る。
rootのパスワードはGitLabをインストールした際に生成されている。

LXC GitLab /etc/gitlab/initial_root_password
# WARNING: This value is valid only in the following conditions
#          1. If provided manually (either via `GITLAB_ROOT_PASSWORD` environment variable or via `gitlab_rails['initial_root_password']` setting in `gitlab.rb`, it was provided before database was seeded for the first time (usually, the first reconfigure run).
#          2. Password hasn't been changed manually, either via UI or via command line.
#
#          If the password shown here doesn't work, you must reset the admin password following https://docs.gitlab.com/ee/security/reset_user_password.html#reset-your-root-password.

Password: *******************************

# NOTE: This file will be automatically deleted in the first reconfigure run after 24 hours.

メールサーバを立てていない場合、新規ユーザの初期パスワードはどこからも参照できないが、ユーザの編集画面で任意のパスワードにリセットできる。
gitlab signup users without email confirmation - Stack Overflow


これでできあがり。

GitLab - Rustで使う用の.gitlab-ci.yml (Runnerを専有可能な環境向け)

RustなプロジェクトのCIを回す際、ビルドやカバレッジ取得のたびに依存関係がリコンパイルされたり、キャッシュが大きくなったりと色々つらかったので何とかしようと試行錯誤したメモ。

やった事は主に以下二点で、今のところは満足。
一部GitLab Runnerの専有が必要という環境制約あり。

  • CIジョブ実行時のgit cleanの対象から./targetを除外する
  • CIジョブで使用するtarpaulin入りのDockerイメージをあらかじめ作っておく

目次

環境

  • GitLab: 14.8.2-ee *1

サーバ構成

制約

  • GitLab Runnerを専有する必要がある
    今回のやり方では、Docker executorのvolume上に./targetを保持したままにすることで、volumeそのものをキャッシュ代わりにする。
    そのため、共有RunnerのようにCIジョブ実行都度volumeが片付けられてしまう環境では効果がない。

    参考: GitLab CI ジョブの高速化 (キャッシュを使わない)

定義ファイル類

配置

イメージをつかむのに必要そうなものだけ書いた。

.
|-- .gitlab-ci.yml
|-- Cargo.toml
|-- Docker
|   |-- CI
|   |   `-- Dockerfile.rust.ci    … CIジョブで使用するimageのビルド用
|   `-- Dockerfile.rust           … 開発環境用
|-- docker-compose.yml
|-- src
|   `-- main.rs
`-- target

.gitlab-ci.yml

パイプラインイメージ

パイプライン構成

  1. git push時にビルドやテストなどを実行する
  2. MergeRequest作成時にカバレッジを計測する
  3. 任意のタイミングでビルドとカバレッジ計測に使用するDockerイメージを再作成する
  • 各ジョブとも任意のタイミングでGitLabのWebUIから個別に実行できる
  • 個別に実行する場合、ビルドとカバレッジ計測は実行時のVariablesとしてCLEANを付与することでクリーンビルドできる

.gitlab-ci.yml

variables:
  CARGO_INCREMENTAL: 0
  FF_USE_FASTZIP: "true"
  # ARTIFACT_COMPRESSION_LEVEL: fast

  # https://zenn.dev/masakura/articles/1ba1d9ec95cfad
  # GitLab CI ジョブの高速化 (キャッシュを使わない)
  GIT_CLEAN_FLAGS: -ffdx -e target/

  # CI時に使用するrustのイメージ名
  CI_RUST_IMAGE: "$CI_REGISTRY_IMAGE/ci-rust"

stages:
  - build
  - coverage
  - rebuild_ci_image

image: rust:slim


# 通常のフローでは`./target`を掃除できなくなっているので、
# フォールバック手段を用意しておく。
# GitLabのPipeline実行画面で変数`CLEAN`(値は任意)を指定して実行すると、
# ビルド実行前に`./target`を削除できる。
# あと、本体へのマージ時にも一応。
.clean: &clean >
  [ "${CLEAN:-}" != "" ] || [ "${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-}" = $CI_DEFAULT_BRANCH ]
  && echo ----- CLEAN ------------------------------
  && rm -rf ./target
  && ls -la

build:
  stage: build
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: never
    - if: $REBUILD_RUST_IMAGE
      when: never
    - when: always
  image: $CI_RUST_IMAGE
  script:
    # 始めはビルドやテストなどを個別のジョブに細分化していたが、ジョブごとに
    # キャッシュの圧縮/展開を繰り返してストレージに優しくない感じだったので
    # 1ジョブにまとめた。
    - ls -la
    - cargo --version
    - *clean
    - >
      : ----- FORMAT ----------------------------
    - cargo fmt --version
    - time cargo fmt -- --check
    - >
      : ----- BUILD -----------------------------
    - rustc --version
    - time cargo build
    - >
      : ----- LINT ------------------------------
    - cargo clippy --version
    - time cargo clippy
    - >
      : ----- BUILD - TEST ----------------------
    - time cargo test --no-run
    - >
      : ----- TEST ------------------------------
    - time cargo test
    - ls -la

coverage:
  stage: coverage
  tags: 
    # coverage用のrunnerに振り分ける。
    # buildと一緒のrunnerで処理すると毎回リコンパイルされてしまうので。
    - coverage
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  image: $CI_RUST_IMAGE
  script:
    - >
      : ----- TEMP MERGE ----------------------------
    # たまに`*** Please tell me who you are.`されるのでダミーを入れておく。
    - git config --local user.name "gitlab-runner"
    - git config --local user.email "gitlab-runner@example.com"
    - git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
    # https://ngyuki.hatenablog.com/entry/2020/02/15/205425
    # Gitlab CI でマージリクエストのマージ結果でパイプラインを実行する
    - git checkout "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - git merge --squash -v -
    - git diff --stat --staged
    - ls -la
    - *clean
    - >
      : ----- COVERAGE ------------------------------
    - cargo tarpaulin --version
    - cargo tarpaulin --skip-clean --out Html Xml --output-dir ./coverage
    - ls -la
  coverage: '/^\d+.\d+% coverage/'
  artifacts:
    paths:
      # カバレッジレポートをGitLab Pagesで参照できるようにする。
      - coverage/tarpaulin-report.html
    reports:
      cobertura: 
        # Merge RequestのChangesタブで変更箇所のカバレッジ状況を参照できるようにする。
        - coverage/cobertura.xml
    expose_as: 'coverage report'


rebuild_ci_image:
  stage: rebuild_ci_image
  rules:
    - if: $REBUILD_RUST_IMAGE
  tags: 
    - rebuild_image
  script:
    - ls -la
    - docker login -u "gitlab-ci-token" -p "$CI_BUILD_TOKEN" $CI_REGISTRY
    - docker build --pull -t $CI_RUST_IMAGE -f Docker/CI/Dockerfile.rust.ci Docker/CI/
    - docker push $CI_RUST_IMAGE

Dockerfile.rust.ci

ビルドやカバレッジ取得で使用するDockerイメージのビルドファイル。

カバレッジ取得はtarpaulinの公式イメージを使うという手もあるのだが、パイプライン内でgit操作したかったり、プロジェクトで使用しているクレートが外部のパッケージに依存していたりするため、個別にDockerイメージを作ることにした。

FROM rust:slim

# pkg-config, libssl-dev:
#   - cargo-tarpaulinのコンパイルに必要
#   - プロジェクトで利用しているrequestクレートのコンパイルに必要
# git:
#   - Merge Requestのパイプライン内で一時的にマージ状態を作るためにgit操作が必要
RUN apt-get update -qq && \
    apt-get install -qqy pkg-config libssl-dev git && \
    rustup component add rustfmt clippy && \
    cargo install cargo-tarpaulin

/etc/gitlab-runner/config.toml

GitLab Runnerをインストールした際に自動生成される定義ファイル。
Docker executorがgit cloneする際にホスト名を名前解決できるように追記しておく。
また、tarpaulinを使用するexecutorには特権が必要になる。

[[runners]]
  name = "Docker executor for build"
  executor = "docker"
    :
  [runners.docker]
    volumes = ["/cache"]
        :
+   extra_hosts = ["MY_HOST_NAME:192.0.2.1"]
+   privileged = true                        # tarpaulinを使用するexecutorのみ

参考リンク

*1:EEでもライセンスなしの場合はCEと同じ状態になる。
Community EditionとEnterprise Editionの違い | GitLab.JP

Unity - 複数のTerrainに対してプレイヤーに近い順に何か処理する

Unityで、MapMagicが自動生成したTerrainに対して、プレイヤーに近い順に特定の処理をしたいことがあったのでメモ。

なお、今回の記事ではサンプルの題材として「Terrainにオブジェクトを配置する」という内容にしているが、オブジェクトの配置が目的であればMapMagicの公式追加アセット「MapMagic 2 Objects」を使った方がもっと高機能だし楽なはず。

環境

  • Unity: 2020.3.33f1 Personal
  • MapMagic 2: 2.1.10

できあがりイメージ

画像のリンク先はGoogleDriveに置いたGIF。75MBくらい。

カメラに近いTerrainから順にSphereを生成している…のだが、静止画だと分からないな…

考え方

  • 生成されたTerrainから早い者勝ちで処理しようとすると割とばらつきが出る。
    遠くのTerrainが先に処理されて、近くのTerrainがしばらく待たされたり。
  • なので、プレイヤーの目に付きやすい近くのTerrainから先に処理したい。
    遠くのTerrainはあまり見えないので後からゆっくりやってくれればいい。

赤→オレンジ→緑の順番で処理したい

  • Terrainを1枚生成し終わったら、そのTerrainに対して処理したいタスクを生成してキューに入れる。
  • キューから取り出す際にプレイヤーと各Terrainの距離を測定し、最も近くにあるTerrainのタスクを取り出して実行する。

各Terrainのタスクをいったんキューに入れ、プレイヤーに近い順に取り出す

  • 優先順位は取り出し時に決める。
    キューに入れる時に決めると、プレイヤーが高速で移動した場合やセーブデータのロードなどで一気に遠くのTerrainへ移動した場合に、元いたTerrainのタスクが高優先扱いのまま残ってしまう。
  • 古いエントリを削除してしまうと、元のTerrain方面へ引き返した際にそのタスクを実行できないので、優先順位が低い状態で残ってほしい。

サンプルコード

各クラスの関連と流れ

だいたいこんな感じ

  1. MapMagicが発行するOnAllCompleteイベント(Terrain生成完了)を検知し、タスクを生成してタスクキューに登録する
  2. タスクキューは内部的に、距離測定用のクラスでタスクをラップしてからキューに入れる
  3. 取り出し時はキュー内の全エントリを走査して各Terrainとプレイヤーとの距離を測定し、最も近いエントリを取り出す
  4. 取り出したタスクを実行する

TerrainEventHandler

using UnityEngine;
using MapMagic.Core;
using MapMagic.Terrains;

public class TerrainEventHandler : MonoBehaviour
{
    private Transform _objects;

    private Transform tile => transform.parent;

    void OnEnable()
    {
        // 遠距離用の`/MapMagic/Tile *,*/Draft Terrain`や、コピー元の`/MapMagic`は対象外。
        // 近距離用の`/MapMagic/Tile *,*/Main Terrain`だけ処理したい。

        // e.g. `Main Terrain@Tile 0,0`
        var nameWithParent = $"{name}@{tile?.name}";
        if (!nameWithParent.StartsWith("Main Terrain@Tile "))
        {
            return;
        }

        _objects = tile.Find("Objects");

        TerrainTile.OnAllComplete -= CreateMapObjects;
        TerrainTile.OnAllComplete += CreateMapObjects;
    }

    void OnDisable()
    {
        TerrainTile.OnAllComplete -= CreateMapObjects;
    }

    public void CreateMapObjects(MapMagicObject terrain)
    {
        // オブジェクトを生成する時は、`Tile *.*/Objects`配下に生成する。
        // ここに既にオブジェクトが生成されていた場合は、処理済みのTerrainとみなしてスキップ。
        var alreadyGenerated = (_objects == null || _objects.transform.childCount > 0);
        if (alreadyGenerated)
        {
            return;
        }

        var task = new TerrainTask(tile);
        TerrainTaskManager.Enqueue(task);

        TerrainTile.OnAllComplete -= CreateMapObjects;
    }

}

TerrainTaskManager

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class TerrainTaskManager : MonoBehaviour
{
    [SerializeField]
    private float _pollingInterval = 0.1f;

    private static LinkedList<TerrainTaskQueueEntry> _queue =
        new LinkedList<TerrainTaskQueueEntry>();

    void Start()
    {
        StartCoroutine(PollQueue());
    }

    private IEnumerator PollQueue()
    {
        while (true)
        {
            var entry = Dequeue();
            if (entry != null)
            {
                yield return entry.Run();
            }

            yield return new WaitForSecondsRealtime(_pollingInterval);
        }
    }

    public static void Enqueue(TerrainTask task)
    {
        // Destroyと生成のギリギリの位置で行ったり来たりすると、そのTerrainに対するタスクが
        // 実行されない内に(距離が遠いので優先度が低い)どんどん新しくキューイングされてしまうので、
        // キューイング済みならスキップする。
        var alreadyQueued = _queue.Any(e => e.Name == task._name);
        if (alreadyQueued)
        {
            return;
        }

        var entry = new TerrainTaskQueueEntry(task);
        _queue.AddLast(entry);
    }

    private static TerrainTaskQueueEntry Dequeue()
    {
        if (_queue.Count < 1)
        {
            return null;
        }

        // 最短距離にあるTerrainに対するタスクを探す
        var closestTask = _queue.Aggregate((cur, nxt) => cur.Closer(nxt));
        _queue.Remove(closestTask);

        return closestTask;
    }

}

TerrainTaskQueueEntry

タスク側に含めてしまってもよかったが、散らかった感じになるかなと思って分けた。

using System.Collections;
using UnityEngine;

public class TerrainTaskQueueEntry
{
    public TerrainTask _task;

    public string Name => _task._name;

    public IEnumerator Run() => _task.Run();

    public TerrainTaskQueueEntry(TerrainTask task)
    {
        this._task = task;
    }

    public TerrainTaskQueueEntry Closer(TerrainTaskQueueEntry another)
    {
        var fromThis = this.DistanceToPlayer();
        var fromAnother = another.DistanceToPlayer();
        var closer = (fromThis < fromAnother) ? this : another;
        return closer;
    }

    private float DistanceToPlayer()
    {
        // プレイヤーの移動によってTerrainがDestroyされている場合がある
        if (!_task._tile)
        {
            return float.MaxValue;
        }

        var playerPos = GlobalRefs.Player.transform.position;
        var terrainPos = _task._tile.transform.position + GlobalRefs.TileCenterGap;
        return Vector3.Distance(terrainPos, playerPos);
    }
}

TerrainTask

今回はSphereをいくつか生成するだけ。

using System.Collections;
using System.Linq;
using UnityEngine;

public class TerrainTask
{
    public Transform _tile { get; private set; }

    public string _name { get; private set; }

    private Transform _objectsParent;

    public TerrainTask(Transform parentTile)
    {
        _tile = parentTile;
        _name = $"{_tile.name}/{_tile.GetComponentInChildren<Terrain>(false).name}";
        _objectsParent = _tile.Find("Objects");
    }

    public IEnumerator Run()
    {
        Debug.Log($"ここでTerrainに対する処理。アイテム置いたりとか。 @{_name}");
        var something = Enumerable.Range(0, 5).ToArray();
        foreach (var i in something)
        {
            // プレイヤーの移動によってTerrainがDestroyされている場合がある
            if (!_tile) break;

            CreateDummySphere();
            yield return null;
        }
    }

    private void CreateDummySphere()
    {
        var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
        sphere.transform.parent = _objectsParent;
 
        var rndX = Random.Range(250, 750);
        var rndZ = Random.Range(250, 750);
        // 簡単のためY座標は固定値
        sphere.transform.localPosition = new Vector3(rndX, 100, rndZ);
        sphere.transform.localScale = Vector3.one * 200;
    }

}

GlobalRefs

距離計算する際に毎回GameObject.Findするのはつらいので、取得結果を持っててもらう。
今回の記事の内容とはあまり関係ないので上の図には描いていないが、ソースコピペで動かすレベルでも負荷上がってしまうかなと思ったので一応。

using UnityEngine;
using MapMagic.Core;

public class GlobalRefs
{
    private static GameObject _player;
    public static GameObject Player
    {
        get
        {
            if (!_player)
            {
                _player = GameObject.FindWithTag("Player");
            }
            return _player;
        }
    }

    // MapMagicのTerrain1枚の大きさ。
    // Playerとの距離を測る際にTerrainの中心を求める用。
    private static Vector3 _tileCenterGap = Vector3.up; // Y is never used
    public static Vector3 TileCenterGap
    {
        get
        {
            if (_tileCenterGap == Vector3.up)
            {
                // デフォルトだと1000*1000のはず。
                // MapMagicの内部の値なのであまり深入りしない方がいいかも。
                var tileSize = GameObject.Find("MapMagic")
                        .GetComponent<MapMagicObject>().tileSize;

                _tileCenterGap = new Vector3(tileSize.x / 2, 0, tileSize.z / 2);
            }
            return _tileCenterGap;
        }
    }
}

スクリプトのアタッチ

TerrainEventHandler

MapMagicObjectコンポーネントと同じオブジェクトにアタッチする。
併せて、MapMagicObjectコンポーネントCopy Components to TerrainsをONにする。
これで自動生成されたTerrainにもこのスクリプトをコピーしてくれる。

スクリプトのアタッチとCopy Component to TerrainsのチェックON

TerrainTaskManager

ルート辺りにEmptyを作ってアタッチする。
キューを監視する部分が動き出してくれればいいだけなので、どこでもよい。
今回はルートにScriptsという名前のEmptyを作ってそこに付けた。

くっつける

使用したアセット

  • MapMagic 2 | Terrain | Unity Asset Store
    あらかじめパラメータを設定しておくことで動的にTerrainを生成してくれるアセット。
    実行中にどんどん生成してくれるのでどこまでも歩いて行ける。
    ノイズで地形をデコボコさせたり、草を生やしたり地表テクスチャをブレンドしたりしていい感じのTerrainを作ってくれる。
    動作も軽い。
    基本的な部分は無料で、追加機能としていくつかの有料アセットがある。

Unity - スクリプトでTerrainに草を生やす

前回Terrainに自動で木を植えたので下草も生やしたいなと思って調べたが、やっぱり情報があんまりない感じだったので書く。

環境

  • Unity: 2020.3.26f1 Personal
  • Universal RP: 10.8.1

できあがりイメージ

f:id:bifutek:20220224194518p:plain
草です

考え方

木の時とだいたい同じ。
こちらはTerrainの座標に対応する配列にフラグ値を詰めていく形。

f:id:bifutek:20220224185643p:plain
だいたいこんな感じ

  1. 生成する草のPrefabをDetailPrototypeにセットする
  2. Terrain上の座標に対応する二次元配列(int[,])に、草を生やすかどうかのフラグ値を詰めていく
    • 0 … 生やさない
    • 1以上 … 生やす
  3. 詰め終わった二次元配列をTerrainDataにセットすると草が生成される

サンプルスクリプト

以下のスクリプトをTerrainオブジェクトにくっつけ、Inspectorで草のTexture2Dを設定すればPlayした時に自動的に草を生成する。

TerrainGrassGenerator .cs

using System.Linq;
using UnityEngine;

public class TerrainGrassGenerator : MonoBehaviour
{
    public Texture2D _grassTexture;

    [Range(0, 100)]
    public int _density = 50;

    void Start()
    {
        Terrain terrain = GetComponent<Terrain>();
        GenerateTerrainGrass(terrain);
    }

    private void GenerateTerrainGrass(Terrain terrain)
    {
        var terrainData = terrain.terrainData;

        // prototypeTextureはnullでもエラーにならなくて分かりづらいので例外投げとく
        if (_grassTexture == null) {
            throw new MissingReferenceException("コンポーネントに草が付いてないよ!");
        }

        // すべての草の元ネタ
        terrainData.detailPrototypes = new DetailPrototype[]
        {
            new DetailPrototype()
            {
                prototypeTexture = _grassTexture,
                renderMode = DetailRenderMode.GrassBillboard
            }
        };

        // デフォルトは1024のはず
        var detailResolution = terrainData.detailResolution;

        var noise = new CreationNoiseGener(detailResolution, _density);

        // 草の生成状態を数値で表す四角形配列 / 1以上の値が入っている座標に草が生える
        var detailMap = new int[detailResolution, detailResolution];
        var detailPoints = Enumerable.Range(0, detailResolution).ToList();
        detailPoints.AsParallel().ForAll(x =>
        {
            detailPoints.Where(z => noise.ShouldCreate(x, z))
            .ToList().ForEach(z =>
            {
                detailMap[x, z] = 1;
            });
        });

        terrainData.SetDetailLayer(0, 0, 0, detailMap);
    }

    /// <summary>
    /// 各座標にオブジェクトを生成するかランダム判定する用ユーティリティ
    /// </summary>
    private class CreationNoiseGener
    {
        private int[] _randAtPoint;
        private int _threshold;
        private int _resolution;

        public CreationNoiseGener(int resolution, int density)
        {
            _threshold = 100 - density;
            _resolution = resolution;

            var ints = GenerateRandomInts(resolution * resolution);
            _randAtPoint = ints.Select(e => e * 100 / byte.MaxValue).ToArray();
        }

        public bool ShouldCreate(int x, int z)
        {
            return _threshold < _randAtPoint[x * _resolution + z];
        }

        private int[] GenerateRandomInts(int size)
        {
            var bytes = new byte[size];
            new System.Random().NextBytes(bytes);

            var ints = new int[size];
            bytes.CopyTo(ints, 0);

            return ints;
        }
    }

}

参考リンク

サンプルで使用したアセット