유니티 빌드 파이프라인 1 - 설계와 젠킨스 설치
이번 글에서는 아래 내용을 정리한다.
- 처음 생각한 구조(젠킨스 두 대, Git 두 개)가 왜 틀렸나
- 예전 회사의 빌드 젠킨스와 패치 젠킨스 구조를 유니티에 옮길 때 달라지는 점
- 젠킨스 컨트롤러를 어디에, 어떤 사양으로 두었나
- 사설 CA로 HTTPS를 붙이면서 생긴 일들
- 푸시하면 빌드하는 방식을 버리고 인자를 받아 수동 실행하는 방식으로 간 이유
- 프로젝트 목록을 손으로 타이핑하지 않고 Forgejo에서 자동으로 채우는 방법
배경
2인 팀으로 유니티 게임을 만들고 있다. 코드는 집 서버의 Debian VM에 올린 Forgejo에 있다. 빌드를 자동으로 돌리고 배포까지 이어지는 체계가 언젠가 필요할 텐데, 이걸 어떻게 짜야 할지 처음부터 정리해 보기로 했다.
내 머릿속 그림은 예전에 다니던 MMORPG 회사의 구조였다. 그 회사에는 젠킨스가 역할별로 있었다.
| 예전 회사 | 하는 일 |
|---|---|
| 빌드 젠킨스 | C++ 코드를 빌드해 exe, pdb, map 같은 실행 파일만 낸다 |
| 패치 젠킨스 | 그 exe를 패치 버전 폴더(예: 200)에 넣고, 기획 데이터(엑셀)와 모델 파일을 암호화하고 패키징해 유저에게 나갈 파일을 만든다 |
그래서 처음에는 개발용 Git과 젠킨스, 그리고 exe만 들어 있는 배포용 Git과 젠킨스를 각각 두는 안을 떠올렸다. 결론부터 말하면 방향은 맞았고 구현이 두 군데 틀렸다.
처음 안에서 틀린 두 가지
젠킨스는 한 대면 된다. 젠킨스는 컨트롤러 하나가 Job을 여러 개 돌리는 구조다. 빌드와 배포를 나누는 것은 인스턴스가 아니라 Job, 권한, 에이전트 단위로 한다. 예전 회사도 실제로는 빌드 머신 하나에 dev, QA, live 배포 스크립트가 따로 있었던 것과 같다.
exe를 Git에 넣지 않는다. Git은 바이너리 이력에 가장 나쁜 도구다. LFS를 써도 빌드마다 용량이 그대로 쌓이고 지우기가 어렵다. 산출물은 아티팩트 저장소에 둔다. 다만 이 원칙은 뒤에서 한 번 뒤집힌다. 유저 배포물만은 결국 Git에 올리기로 했는데, 그 이유는 3편에서 다룬다.
유니티는 exe만 나오지 않는다
예전 구조를 유니티에 그대로 옮기려니 한 가지가 어긋났다. 유니티 빌드는 exe 하나가 아니라 실행 가능한 폴더 전체가 나온다.
MyGame.exe
UnityPlayer.dll
GameAssembly.dll (IL2CPP)
MyGame_Data\ 씬, 프리팹, 텍스처, 테이블이 직렬화된 파일들
MyGame_BackUpThisFolder_ButDontShipItWithYourGame\ pdb와 IL2CPP 중간 C++ (심볼)예전 클라이언트는 exe가 실행 중에 외부 팩 파일에서 리소스와 테이블을 읽었기 때문에, 코드 빌드와 콘텐츠 패키징을 다른 시점에 할 수 있었다. 유니티는 Player 빌드 단계에서 콘텐츠까지 함께 묶는다. 그래서 대응은 이렇게 된다.
| 예전 회사 | 유니티에서 |
|---|---|
| 빌드 젠킨스: 코드에서 exe | Player 빌드에서 실행 가능한 폴더. 심볼은 BackUpThisFolder 폴더 |
| exe를 패치 버전 폴더에 넣음 | 빌드 결과 폴더를 통째로 넣음. exe와 Data 폴더는 한 덩어리라 따로 못 끼운다 |
| 패치 젠킨스: 기획 데이터, 모델, exe 암호화 후 배포물 | 모델과 씬은 이미 Data 폴더에 들어 있다. 남는 일은 기획 데이터 암호화, 디버그 파일 제거, 버전 기록, 배포 |
기획 데이터를 빌드 뒤에 교체하거나 암호화하려면 StreamingAssets 폴더를 써야 한다. 이 폴더의 파일은 빌드에 그대로 복사되고 실행 중에 파일로 읽히기 때문이다. 이것이 4편의 전제가 된다.
여기서 한 가지는 확실히 해 두어야 한다. exe와 Data 폴더는 한 빌드에서 나온 짝이다. 빌드 N의 exe에 빌드 M의 Data 폴더를 끼워 넣을 수 없다. 에셋 GUID와 직렬화가 그 빌드에 묶여 있다. 예전처럼 exe만 바꿔 끼우는 운용은 유니티에서는 안 된다. 산출물의 단위를 exe가 아니라 폴더로 잡으면 나머지는 예전 구조와 같다.
컨트롤러는 어디에
젠킨스 운용에 필요한 머신은 두 종류다.
| 역할 | 사양 | 이유 |
|---|---|---|
| 컨트롤러 | Linux, 2 vCPU, 2GB, 디스크 20GB | Job 스케줄과 웹 UI만. 빌드는 직접 안 한다 |
| 빌드 에이전트 | Windows, 4 vCPU, 16GB, SSD 150GB | 유니티 에디터와 Visual Studio 빌드 도구. Windows용 IL2CPP 빌드는 Windows에서만 된다 |
컨트롤러는 Forgejo와 Xen Orchestra가 이미 돌고 있는 Debian VM에 얹었다. 실측으로 4GB 중 3.1GB가 가용이었고 CPU 부하는 0이었다. 젠킨스 JVM은 힙 1GB로 묶었고, 시작 후 상주 메모리는 700에서 800MB, VM 가용 메모리는 2.4GB 남았다. 아티팩트를 이 30GB 디스크에 쌓으면 금방 차니, 산출물은 다른 서버의 큰 디스크로 보내기로 했다.
빌드 에이전트는 별도 VM이 정답이지만 호스트 여유 RAM이 1.25GB라 새 VM을 만들 수 없었다. 이 문제는 2편에서 다룬다.
설치에서 걸린 것들
저장소 서명 키가 바뀌어 있었다. 젠킨스 apt 저장소의 2023년 키가 2026년 3월에 만료되고 jenkins.io-2026.key로 바뀌어 있었다. 문서에 흔히 남아 있는 옛 키 URL을 그대로 쓰면 Missing key 오류로 멈춘다. 새 키를 받아 지문을 확인하고 진행했다.
HTTPS는 Forgejo와 같은 사설 CA로 붙였다. 집 CA로 젠킨스용 인증서를 발급해 Winstone(젠킨스 내장 서버)의 keystore로 넣고, HTTP 포트는 닫았다. Forgejo 인증서와 만료일을 같은 날로 맞춰 갱신 날짜를 하나로 두었고, 갱신은 스크립트 한 줄로 끝나게 해 두었다.
[Service]
Environment="JAVA_OPTS=-Djava.awt.headless=true -Xms256m -Xmx1024m -Duser.timezone=Asia/Seoul"
Environment="JENKINS_PORT=-1"
Environment="JENKINS_HTTPS_PORT=8443"
Environment="JENKINS_HTTPS_KEYSTORE=/etc/ssl/jenkins/jenkins.p12"
EnvironmentFile=/etc/jenkins/tls.env컨트롤러의 executor는 0으로 두었다. 컨트롤러에서 빌드가 돌면 Forgejo, XO와 4GB를 다투고 유니티도 없다. 빌드는 에이전트에서만 돌게 막았다.
Forgejo 연동은 Gitea 플러그인으로 한다. 업데이트 센터에 Forgejo 전용 플러그인은 없고, API 호환인 Gitea 플러그인을 쓴다. 자바가 사설 CA를 신뢰하는지 먼저 확인해야 하는데, Debian의 ca-certificates-java가 시스템 CA를 자바 keystore에 넣어 주므로 별도 작업이 없었다.
403이 하나 떴는데 무시해도 되는 것이었다. Gitea 서버 항목을 저장하려는데 Could not communicate with server: HTTP 403이 나왔다. Forgejo 로그를 보니 플러그인이 URL 칸을 검사할 때 토큰 없이 /api/v1/version을 호출했고, 우리 Forgejo는 로그인 없는 접근을 전부 막아 두었기 때문이었다(REQUIRE_SIGNIN_VIEW). 바로 이어 토큰으로 한 /api/v1/user는 200이었다. 실제 동작은 항상 토큰을 붙이므로 그대로 저장했다.
푸시하면 빌드하지 않는다
처음에는 조직 폴더(Gitea Organization) Job으로 저장소를 자동 탐색하고 푸시마다 빌드가 돌게 했다. 웹훅까지 붙여 잘 돌았다. 그런데 운영 방식을 다시 생각하니 예전 회사의 흐름과 맞지 않았다. 빌드는 사람이 이 프로젝트를 이 타입으로 빌드한다고 정해서 돌리는 것이었다.
그래서 구조를 바꿨다.
- 조직 폴더와 웹훅을 지웠다. 푸시는 아무것도 실행하지 않는다.
- Job은 unity-build 하나. 파라미터로 PROJECT, BUILD_TYPE(dev/release), BRANCH를 받아 그 저장소를 클론해 빌드한다.
- 파이프라인 정의(Jenkinsfile)와 유니티 빌드 스크립트는 Forgejo의 ci 저장소 하나에 둔다. 빌드 시점에 빌드 스크립트를 프로젝트에 주입하므로, 프로젝트마다 CI 파일을 넣을 필요가 없다.
프로젝트 목록은 자동으로
PROJECT를 타이핑하는 대신 목록에서 고르게 하고, 저장소가 추가되면 목록도 따라오게 했다. 컨트롤러 VM의 systemd 타이머가 10분마다 Forgejo 조직을 훑는다.
- 조직의 저장소 중 기본 브랜치에 ProjectSettings/ProjectVersion.txt가 있는 것을 유니티 프로젝트로 본다. ci와 배포 저장소는 제외한다.
- 두 Job의 PROJECT 선택 목록이 다르면 Job 정의 템플릿에 목록을 채워 update-job으로 밀어 넣는다.
- 프로젝트마다 배포 저장소가 없으면 만든다(3편).
여기서 하나 걸린 것이 있다. 선언형 파이프라인의 parameters 블록은 실행마다 Job 설정을 덮어쓴다. 그래서 파라미터는 Job 설정에만 두고 Jenkinsfile에서는 선언하지 않는다. 선언하면 타이머가 채운 목록이 다음 실행에 사라진다. 파라미터를 외부에서 관리하려면 Jenkinsfile 쪽을 비워야 한다는 뜻이다.
마치며
구조를 정하는 데 든 시간이 설치보다 길었다. 결론은 단순하다. 젠킨스 한 대, Job 둘(빌드, 배포), 파이프라인 코드는 저장소에, 산출물은 Git 밖에, 실행은 사람이 인자를 정해서. 다음 글은 빌드 에이전트를 어디에 둘지 고민한 이야기와 빌드 Job 자체다.