유니티 빌드 파이프라인 2 - 빌드 에이전트와 빌드 Job

이번 글에서는 아래 내용을 정리한다.

  • 빌드 에이전트를 기존 서버(2016, 2019, 2022)에 얹을 수 있는지 따져 본 결과
  • 새 VM을 못 만드는 이유(RAM, 라이선스)와 작업 VM을 임시로 쓰기로 한 판단
  • 에이전트를 별도 서비스 계정으로 설치하고 유니티 라이선스가 서비스 계정에서도 먹는지 확인한 것
  • 빌드 Job이 하는 일과 산출물 폴더(_exeOut, _exeOutDEV)
  • 증분 빌드와 클린 빌드, 실패하면 클린으로 자동 재시도
  • Groovy 주석, PowerShell의 빈 종료 코드, robocopy 종료 코드 같은 함정

어디에 둘 것인가

새 VM이 정석이지만 두 가지가 막았다. 호스트 여유 RAM이 1.25GB라 16GB짜리 Windows VM을 만들려면 다른 VM을 줄여야 했고, Windows 라이선스도 하나 더 필요했다. 그래서 기존 VM에 역할을 얹는 안을 먼저 따졌다.

VM Windows 커널 빌드 Unity 6.6 최소(19043) 외부 노출 판단
Server 2016 14393 미달 블로그 프록시를 통해 일부 열림 안 됨. 2027년 1월에 지원도 끝난다
Server 2019 17763 미달 블로그와 FTPS로 항상 열림 안 됨. 소스와 토큰을 공개 서버에 두는 셈
Server 2022 20348 충족 게임 서버 호스팅 중에만 가능. 그러나 게임 서버와 자원을 나눠 쓴다

Unity 6.6 에디터의 공식 최소 OS는 Windows 10 21H1(빌드 19043)이다. Server 2019는 Windows 10 1809와 같은 커널이라 미달이다. 억지로 깔면 돌아갈 수도 있지만 미지원 상태의 빌드 머신은 어느 날 안 되는 이유를 찾느라 시간을 쓰게 된다.

결국 작업용 Windows 11 VM을 임시 에이전트로 쓰기로 했다. 유니티, Visual Studio의 C++ 도구, Git LFS가 이미 있어 추가할 것이 Java 21 하나였다. 하드웨어를 올리면 다른 VM으로 옮기는 전제고, 옮기기 쉽게 네 가지를 지켰다.

  1. 빌드 정의는 전부 저장소 안에 둔다. 에이전트에는 설치된 도구만 남는다.
  2. 파이프라인은 머신 이름이 아니라 라벨(unity-win)로 에이전트를 고른다.
  3. 작업 공간은 별도 경로(C:\BuildSource 아래)에 별도 클론으로 둔다. 작업 중인 프로젝트 폴더는 건드리지 않는다. 유니티는 같은 프로젝트를 두 인스턴스가 열지 못한다.
  4. 에이전트는 별도 저권한 계정의 Windows 서비스로 돌린다. 컨트롤러로 나가는 WebSocket 연결만 쓰니 인바운드 포트를 열 필요가 없다.

에이전트 설치

  • Java는 Temurin 21 JRE. Adoptium API에서 설치 파일과 체크섬을 받아 검증 후 설치했다.
  • 자바가 컨트롤러의 HTTPS를 믿게 사설 CA를 JRE의 cacerts에 넣었다. Windows 인증서 저장소에 있어도 자바는 자기 keystore만 본다.
  • 서비스 래퍼는 WinSW. agent.jar를 WebSocket으로 붙이고, 비밀값은 명령줄이 아니라 파일로 넘긴다.
  • 서비스 계정 jenkins-agent를 만들어 Users 그룹에만 넣고 로그인 화면에서 숨겼다. 우선순위는 belownormal로 두어 빌드가 도는 동안 작업 중인 에디터가 CPU를 먼저 받게 했다.

가장 큰 미지수는 유니티 라이선스가 서비스 계정에서도 먹는가였다. 임시 Job에서 Unity.exe를 배치모드로 띄워 빈 프로젝트를 만들어 보니 로그에 Licensing is initialized가 찍히고 종료 코드 0이었다. Personal 라이선스는 머신 단위(ProgramData)라 계정이 달라도 된다.

첫 실행에서 서비스 계정의 유니티 캐시 폴더가 없다는 경고가 났다. 빌드는 됐지만 미리 만들어 두면 깨끗하다. 서비스 계정의 프로필은 서비스가 처음 뜰 때 생기므로 그 뒤에 폴더를 만든다.

빌드 Job이 하는 일

1. Forgejo에서 <PROJECT>의 <BRANCH> 최신 커밋을 클론 (LFS 포함, 매번 fetch)
   유니티 버전은 프로젝트의 ProjectSettings/ProjectVersion.txt에서 읽는다
2. ci 저장소의 CiBuild.cs를 Assets/Editor/CI/에 주입
   암호화 키를 Assets/Scripts/Data/DataCryptoKey.cs로 생성 (4편)
3. Unity 배치모드 빌드
   dev     = Mono + Development Build
   release = IL2CPP + Release 구성 + 스플래시 화면 끔 + 기획 데이터 암호화
4. 결과를 파일 서버에 기록
   release = \\서버\exeOut\<PROJECT>\_exeOut
   dev     = \\서버\exeOut\<PROJECT>\_exeOutDEV
   성공/실패와 관계없이 통째로 교체. _ci 폴더에 build-info.txt, 개발 커밋 메시지, Unity 로그
5. 작업 공간의 키 파일과 ProjectSettings를 원래대로 되돌린다

빌드 진입점은 에디터 스크립트 하나다. Build Settings에서 체크된 씬을 모아 BuildPipeline.BuildPlayer를 부르고, 종료 코드로 성공과 실패를 알린다.

string[] scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
PlayerSettings.SetScriptingBackend(NamedBuildTarget.Standalone,
    release ? ScriptingImplementation.IL2CPP : ScriptingImplementation.Mono2x);
if (release)
{
    PlayerSettings.SetIl2CppCompilerConfiguration(NamedBuildTarget.Standalone, Il2CppCompilerConfiguration.Release);
    PlayerSettings.SplashScreen.show = false;        // Unity 6부터 Personal도 허용
    PlayerSettings.SplashScreen.showUnityLogo = false;
}
var report = BuildPipeline.BuildPlayer(options);
EditorApplication.Exit(report.summary.result == BuildResult.Succeeded ? 0 : 1);

dev와 release를 다른 폴더에 두는 이유는 3편에서 나온다. 배포는 _exeOut만 읽고, dev 빌드는 절대 배포되지 않는다.

실패한 빌드도 _exeOut을 교체하고 _ci에 로그를 남긴다. 결과 폴더를 성공했을 때만 만드는 게 아니다. 배포는 build-info의 result가 SUCCESS인지 보고 거부한다. 실패 흔적을 남기지 않으면 원인을 찾을 곳이 없다.

클린 빌드

처음에는 증분 빌드였다. 작업 공간의 Library 캐시와 IL2CPP 캐시가 재사용되어 release가 1분, 코드 변경이 없으면 9초에 끝났다. 캐시가 어긋날 위험을 두고 고민하다가 클린 빌드 옵션을 넣었다.

모드 release dev
증분 약 1분 약 1분
클린 5분 41초 3분 12초

CLEAN 파라미터는 auto, yes, no다. 처음 정책은 release만 클린이었는데, dev도 그냥 클린으로 가기로 했다. 매번 처음부터 임포트하니 캐시 문제가 원천적으로 없다. 클린은 Git 플러그인의 cleanBeforeCheckout(git clean -ffdx)으로 한다.

no로 돌린 증분 빌드가 실패하면 작업 공간을 클린하고 한 번 자동 재시도한다. 첫 시도의 로그는 _ci 폴더에 unity-build.attempt1.log로 남고 build-info에 clean_build=retry-after-failure가 기록된다. 일부러 문법이 깨진 C# 파일을 작업 공간에 심어 테스트했다. 1차 실패(컴파일 오류 15건), 자동 클린, 2차 성공까지 3분 31초였다.

밟은 함정들

PowerShell이 종료 코드를 잃는다. Start-Process -PassThru로 유니티를 띄우고 WaitForExit() 뒤에 ExitCode를 읽었는데 빈 값이 나왔다. 프로세스 핸들을 미리 건드리지 않으면 종료 뒤 ExitCode가 비는 경우가 있다. $null = $p.Handle로 핸들을 캐시하고, 그래도 비면 로그의 결과 줄로 판정하게 했다. 이대로 두면 빌드가 실패해도 단계가 성공으로 넘어갈 수 있는 문제라 발견 즉시 고쳤다.

Groovy는 주석 안이라도 백슬래시 뒤에 u가 오면 유니코드 이스케이프로 읽는다. 주석에 Windows 경로를 백슬래시로 적었더니 Did not find four digit hex character code 오류로 파이프라인 전체가 파싱 실패했다. 경로는 슬래시로 적는다. 이 사실을 주석에 적으면서 또 같은 문자를 써서 두 번 걸렸다.

robocopy의 종료 코드 1은 성공이다. Jenkins의 powershell 단계는 스크립트의 마지막 종료 코드를 결과로 본다. robocopy가 복사한 파일이 있음을 1로 돌려주면 단계가 실패로 끝난다. 검사가 끝난 뒤 exit 0으로 명시하고, robocopy는 8 이상만 실패로 다룬다. robocopy, git, curl 뒤에는 코드를 직접 판정하는 습관이 필요하다.

Groovy 문자열 안의 정규식 이스케이프도 한 번 걸렸다. 큰따옴표 Groovy 문자열에 백슬래시를 넷 쓰면 PowerShell에는 둘이 넘어가 매치가 안 된다. 둘을 써야 PowerShell이 하나를 받는다.

마치며

에이전트를 작업 VM에 두는 것은 임시다. 하지만 라벨과 저장소 안의 정의만 지키면 옮기는 비용은 도구 설치 한두 시간과 노드 등록 10분이다. 다음 글은 _exeOut을 받아 유저 배포 Git과 디버그 백업을 만드는 배포 Job이다.