CI 러너를 옮기는 일은 "runs-on 한 줄만 바꾸면 끝"처럼 보입니다. 실제로 저도 그렇게 생각하고 시작했습니다. 그런데 여러 저장소를 한꺼번에 옮겨 보니, 러너 이전은 단순한 인프라 교체가 아니었습니다. 그동안 낡은 환경 덕분에 우연히 통과하던 것들이 한꺼번에 드러나는 사건이었습니다.

이 글은 컨테이너 러너 자체의 특성을 설명하는 글이 아닙니다. 어떤 순서로 옮겼고, 단계마다 무엇이 깨졌고, 어떻게 원인을 찾았는지 이전 과정을 시간순으로 정리한 기록입니다.

0. 출발점: 개인 계정의 Mac 러너

처음 구조는 단순했습니다. 개인 GitHub 계정(pushwing) 아래 PHP/CodeIgniter 4 저장소 여러 개가 있었고, CI는 GitHub 호스팅 러너(ubuntu-latest)에서 돌았습니다. 그러다 호스팅 러너 결제 문제로 CI가 막혔고, 7월 말에 개발에 쓰는 Mac을 self-hosted 러너로 등록했습니다.

등록은 스크립트 하나로 끝냈습니다. 최신 러너 패키지를 받고, 등록 토큰은 gh api로 발급하고, launchd 서비스로 올려 두는 방식입니다.

#!/usr/bin/env bash
set -euo pipefail

RUNNER_DIR="$HOME/actions-runners/MyRepo"
LABELS="self-hosted,macOS,ARM64"

# 등록 토큰 자동 발급 (gh CLI가 없으면 수동 입력으로 폴백)
REG_TOKEN="$(gh api -X POST "repos/OWNER/REPO/actions/runners/registration-token" --jq .token)"

./config.sh --url "https://github.com/OWNER/REPO" --token "$REG_TOKEN" \
    --labels "$LABELS" --name "mac-local-runner" --unattended

./svc.sh install   # launchd 상시 서비스로 등록
./svc.sh start

동작은 했지만, 로컬 Mac을 러너로 쓰면 곧 한계가 보였습니다.

  • macOS 러너는 services: 컨테이너를 지원하지 않습니다. 서비스 컨테이너는 Linux 러너 전용 기능이라, MySQL을 docker run으로 직접 띄우고 if: always() 스텝에서 직접 정리해야 했습니다.
  • 포트 충돌을 사람이 관리해야 했습니다. 시스템 mysqld(3306), 다른 저장소 CI(13306), 또 다른 저장소(33306)... 저장소마다 호스트 포트를 따로 배정했습니다.
  • 러너끼리 로컬 환경을 공유했습니다. 한 번은 다른 저장소의 CI가 Homebrew의 범용 php 심링크를 php@8.4로 바꿔 놓는 바람에, PHP 8.5가 필요한 저장소의 composer 스크립트가 "PHP 8.4" 플랫폼 오류로 실패했습니다. 버전이 고정된 keg-only 경로를 PATH 맨 앞에 강제해서 임시로 막았습니다.

돌이켜 보면 이때부터 "러너 환경이 곧 CI 결과의 일부"라는 신호가 나오고 있었습니다.

1. 1차 이전 (9월 초): 조직 + Linux X64

9월 초에 두 가지가 동시에 바뀌었습니다. GitHub 계정이 개인에서 조직으로 이관됐고, 러너가 macOS ARM64에서 조직 공용 self-hosted Linux X64로 바뀌었습니다. 저장소 전체의 runs-on을 바꾸는 PR은 저장소마다 한 줄짜리였습니다.

# before
runs-on: [self-hosted, macOS, ARM64]

# after
runs-on: [self-hosted, Linux, X64]

Linux에서는 services:가 된다고 알고 있었기 때문에, 수동 docker run/정리 스텝도 services: mysql 블록으로 되돌렸습니다. 여기까지는 계획대로였습니다. 문제는 그다음이었습니다.

1-1. CI가 30분 넘게 멈춘다: setup-php

PR을 올리자 PHP 설치 스텝에서 CI가 30분 넘게 끝나지 않았습니다. 러너 박스에서 직접 재현해 보니 원인은 두 겹이었습니다.

  1. shivammathur/setup-php는 ondrej/php PPA로 PHP를 설치하는데, 이 러너의 Ubuntu는 최신 LTS라 PPA가 아직 그 코드네임 패키지를 내지 않았습니다. apt-get update가 "Release file 없음"으로 실패하고, 액션은 그걸 무한 재시도했습니다.
  2. apt 설치 중 needrestart가 "재시작할 서비스를 고르라"는 대화상자를 띄웁니다. 비대화형 환경에서는 입력을 영원히 기다립니다.

해결은 setup-php를 걷어내고 러너의 네이티브 apt 저장소에서 직접 설치하는 것이었습니다. 이때 비대화형 환경변수를 반드시 같이 줘야 했습니다.

- name: Install PHP (apt native)
  env:
    DEBIAN_FRONTEND: noninteractive
    NEEDRESTART_MODE: a   # 서비스 재시작 대화상자 자체를 없앤다
  run: |
    sudo -E apt-get update
    sudo -E apt-get install -y php8.5-cli php8.5-mbstring php8.5-intl php8.5-mysql composer

커버리지 잡에서는 pcov를 시도하고 없으면 xdebug로 폴백한 뒤, 드라이버가 실제로 로드됐는지 확인하고 없으면 명시적으로 실패하게 했습니다. 조용히 커버리지 없이 통과하는 CI는 없는 것보다 나쁘기 때문입니다.

1-2. 테스트가 테스트 실패가 아니라 타임아웃으로 죽는다

전체 스위트(1111건)가 5분을 넘기자 composer test가 테스트 실패가 아니라 타임아웃으로 죽었습니다. Composer는 스크립트가 띄운 자식 프로세스를 기본 300초에 끊습니다. COMPOSER_PROCESS_TIMEOUT=0으로 해제했습니다. 맥에서는 디스크가 빨라서 한 번도 걸리지 않던 제한이었습니다.

2. 이전이 드러낸 진짜 버그: "몇 달간 초록불이던 CI"

이번 이전에서 가장 충격적이었던 건 AIFid 저장소였습니다. Linux로 옮기자 PHPUnit 765개가 전부 Connection refused로 실패했습니다.

2-1. MySQL 임시 서버가 healthcheck를 속였다

첫 번째 원인은 healthcheck였습니다. MySQL 8 공식 이미지는 초기화 중에 유닉스 소켓만 여는 "임시 서버"를 먼저 띄웠다가 내리고, 진짜 서버를 다시 시작합니다. 소켓 기반 mysqladmin ping은 이 임시 서버 단계에서 이미 healthy라고 응답했고, 곧바로 PHPUnit이 접속을 시도하다가 서버가 내려가는 구간에 걸려 죽었습니다. -h 127.0.0.1로 TCP 접속을 강제하자 임시 서버 단계에서는 healthcheck가 실패하게 되어 해결됐습니다.

options: >-
  --health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
  --health-interval=2s
  --health-timeout=5s
  --health-retries=15

2-2. 그런데 healthcheck를 고쳐도 계속 실패했다

임시 진단 스텝까지 넣어 가며 추적한 끝에 진짜 원인을 확정했습니다. phpunit.dist.xml의 <env> 값이 CI가 export한 값을 항상 덮어쓰고 있었습니다. PHPUnit의 "이미 설정된 값은 덮어쓰지 않는다"는 putenv()/getenv() 수준의 이야기이고, $_ENV/$_SERVER에는 XML 값이 항상 심깁니다. 그런데 CI4의 env() 헬퍼는 $_SERVER → $_ENV → getenv() 순으로 읽습니다. 그래서 CI가 아무리 port=13306을 export해도 XML의 로컬 기본값 3307이 이겼습니다.

macOS 러너 시절에는 이게 왜 통과했을까요? 개발자 Mac에 로컬 개발용 MySQL이 3307에 상시 떠 있었기 때문입니다. 즉 CI는 자기 테스트 컨테이너가 아니라 내 로컬 개발 DB에 접속해서 몇 달간 초록불을 내고 있었습니다. 게다가 테스트가 클래스마다 regress → migrate를 돌렸으니, CI가 로컬 개발 DB를 지웠다 만들었다 하고 있었던 셈입니다.

수정은 XML의 접속 정보 <env>를 제거하는 것이었고, 같은 사고가 재발하지 않도록 "테스트가 요청한 DB에 실제로 붙었는지" 검증하는 가드 테스트를 추가했습니다.

<?php

declare(strict_types=1);

use CodeIgniter\Test\CIUnitTestCase;
use Config\Database;

/**
 * 환경이 database.tests.* 를 지정했는데 프레임워크가 다른 값을 쓰면 즉시 실패시킨다.
 * 틀린 이유로 통과하는 CI가 실패하는 CI보다 위험하다.
 */
final class TestDatabaseTargetTest extends CIUnitTestCase
{
    public function testConnectsToRequestedDatabase(): void
    {
        $requested = getenv('database.tests.hostname');

        if ($requested === false || $requested === '') {
            $this->markTestSkipped('database.tests.* 가 지정되지 않은 로컬 실행');
        }

        $config = config(Database::class)->tests;

        $this->assertSame($requested, $config['hostname']);
        $this->assertSame((int) getenv('database.tests.port'), (int) $config['port']);
    }
}

이 사건에서 얻은 교훈은 하나입니다. 실패하는 CI보다 틀린 이유로 통과하는 CI가 더 위험합니다. 러너를 옮기지 않았다면 아마 계속 몰랐을 겁니다.

2-3. 테스트마다 스키마를 다시 만들고 있었다

같은 저장소에서 백오피스 잡이 한 시간을 넘겼습니다. 러너에서 MySQL을 관찰해 보니 567초 동안 테이블을 4,707번 열고 있었습니다. CI4의 $refresh = true는 테스트 클래스가 아니라 메서드마다 전체 regress → migrate를 돌립니다. 마이그레이션 38개에 DB를 쓰는 테스트가 수백 개면 DDL이 수만 건이 됩니다. 스키마는 프로세스당 한 번만 만들고($migrateOnce = true) 테스트마다 데이터만 비우는 트레이트로 바꿨습니다. 맥에서도 같은 비용이었지만 디스크가 빨라서 덜 아팠을 뿐이었습니다.

3. 느려진 테스트를 되돌리기: DB 옵션 다음 병렬화

Linux 러너에서 느려진 구간은 "전체가 느린" 게 아니라 특정 구간(427~915번째 테스트)만 50배 느려졌다가 회복하는 패턴이었습니다. 로컬에서는 재현되지 않았습니다. InnoDB redo log가 소진되면 걸리는 동기 체크포인트 플러시 스톨과 일치하는 패턴이라, 휘발성 테스트 DB에 불필요한 durability를 껐습니다.

docker run -d --name ci-mysql \
  --tmpfs /var/lib/mysql \
  mysql:8.0 \
  --innodb-flush-log-at-trx-commit=0 \
  --innodb-flush-method=nosync \
  --sync-binlog=0 \
  --innodb-redo-log-capacity=536870912

312초가 113초로 줄었습니다. doublewrite, flush_neighbors, buffer pool을 더 만져 봤지만 무반응이어서 되돌렸습니다. 효과가 측정되지 않는 옵션을 남겨 두면 "이유를 아무도 모르는 설정"이 되기 때문입니다. 남은 병목은 DB I/O가 아니라 프레임워크 부팅과 쿼리량이라는 CPU 비용이었고, 다음 단계는 paratest 병렬화였습니다.

병렬 워커는 워커마다 쓰기 경로와 테스트 DB 스키마를 따로 가져야 합니다. CI4 테스트 부트스트랩이 defined('WRITEPATH') || define(...) 가드를 쓰는 점을 이용했습니다. 우리 부트스트랩이 먼저 상수를 선점하면, WRITEPATH를 참조하는 코드 8곳을 한 줄도 고치지 않고 워커별로 격리됩니다.

<?php

declare(strict_types=1);

$testToken = getenv('TEST_TOKEN');   // paratest가 워커마다 1, 2, 3... 주입

if ($testToken === false || $testToken === '') {
    // 단일 프로세스 실행이면 원본 CI4 부트스트랩만 그대로 태운다
    require __DIR__ . '/../../vendor/codeigniter4/framework/system/Test/bootstrap.php';

    return;
}

// 워커 전용 쓰기 경로를 프레임워크보다 먼저 정의한다
$workerRoot = __DIR__ . '/../../writable/paratest/worker_' . $testToken;
define('WRITEPATH', realpath($workerRoot) . DIRECTORY_SEPARATOR);

// 워커 전용 테스트 DB 스키마 ({base}_{TOKEN})
$workerDatabase = ($_ENV['database.tests.database'] ?? 'app_test') . '_' . $testToken;
$_ENV['database.tests.database'] = $workerDatabase;

require __DIR__ . '/../../vendor/codeigniter4/framework/system/Test/bootstrap.php';

(실제 파일은 워커 DB를 CREATE DATABASE IF NOT EXISTS로 만드는 부분이 더 있습니다.) paratest 7.x는 PHP 8.5를 아직 지원 선언하지 않아 --ignore-platform-req=php가 필요했고, 마지막으로 호환되는 7.4.9 버전으로 고정했습니다. 한 저장소는 직렬 5분 이상이 46초가 됐습니다.

4. 2차 이전 (10월): "러너가 컨테이너였다"

9월 이전을 마치고 한동안 안정적이었는데, 10월에 조직 러너가 runner-01~03 여러 대로 재편되면서 또 한 번 깨졌습니다. 이번에는 러너 자체가 Docker 컨테이너였고, 같은 라벨을 달고 있어도 러너마다 환경이 달랐습니다.

4-1. services:의 포트는 러너에서 닿지 않는다

1차 이전에서 "Linux니까 services:가 된다"고 했던 판단이 반쯤만 맞았다는 걸 알게 됐습니다. 러너가 컨테이너이면 services:/docker run -p로 게시한 포트는 Docker 호스트의 127.0.0.1에 열리고, 러너 컨테이너에서는 그 주소로 닿지 않아 Connection refused가 납니다. 해법은 실행별 네트워크를 만들어 러너와 DB 컨테이너를 같이 붙이고 컨테이너 이름으로 접속하는 것이었습니다.

- name: Start MySQL on a per-run network
  run: |
    docker network create ci-net-${{ github.run_id }}
    docker network connect ci-net-${{ github.run_id }} "$(hostname)"
    docker run -d --name ci-mysql-${{ github.run_id }} \
      --network ci-net-${{ github.run_id }} \
      -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=app_test \
      --tmpfs /var/lib/mysql \
      mysql:8.0
    # 앱 설정: hostname = ci-mysql-<run_id>, port = 3306 (컨테이너 자체 포트)

- name: Cleanup
  if: always()
  run: |
    docker rm -f -v ci-mysql-${{ github.run_id }} || true
    docker network disconnect -f ci-net-${{ github.run_id }} "$(hostname)" || true
    docker network rm ci-net-${{ github.run_id }} || true

부수 효과로 호스트 포트를 고정하지 않으니, 같은 호스트에서 잡이 동시에 돌아도 포트가 충돌하지 않습니다. 맥 시절의 "저장소마다 포트 배정" 문제가 구조적으로 사라졌습니다. 준비 확인도 mysqladmin ping이 아니라 실제 인증된 쿼리(SELECT 1)로 바꿨습니다. 앞서 본 임시 서버 문제 때문입니다.

4-2. 같은 라벨인데 이미지가 다르다

같은 [self-hosted, Linux, X64] 라벨인데 runner-01은 ondrej PPA로 PHP 8.5가 들어 있었고, runner-03은 네이티브 Ubuntu 24.04라 PHP 8.3.6이었습니다. composer.json이 ^8.5를 요구하니 러너에 따라 통과 여부가 달라졌습니다. 처음에는 "PHP는 러너 이미지에 굽고 CI는 검증만 한다"로 갔다가, 그 가정이 깨지자(PHP 미설치, exit 127) 다시 apt 설치로 되돌렸고, 최종적으로 "PHP 보장 단계"로 정리했습니다. 요구 버전보다 낮으면 PPA에서 8.5를 설치해 기본값으로 지정하고, 확장은 현재 활성 버전에 맞춰 없는 것만 설치합니다. 버전 없는 php-intl은 다른 PHP 버전을 끌고 오기 때문입니다.

같은 이유로 동시 잡끼리 dpkg 락을 두고 경합했고, 커버리지 드라이버가 xdebug로 폴백될 때는 XDEBUG_MODE=coverage가 없으면 paratest가 죽었습니다. 이전 전에는 한 번도 못 본 종류의 실패들이었습니다.

4-3. 나머지 함정들

  • Docker 컨테이너 액션: runs.using: docker인 액션(예: 커버리지 요약 액션)은 호스트 경로를 /github/workspace로 마운트해서 러너 컨테이너의 작업 디렉터리가 보이지 않습니다. No files found matching glob pattern이 나면 이 경우입니다. 셸 스텝으로 대체했습니다. JavaScript/composite 액션은 문제없습니다.
  • Flutter: 러너는 root로 도는데 SDK 소유자는 다른 사용자라 detected dubious ownership(exit 128)이 납니다. flutter-action 직후 git config --global --add safe.directory "$FLUTTER_ROOT"를 넣었습니다.
  • 배포용 SSH 호스트: DEPLOY_HOST가 Cloudflare 프록시(주황 구름) 도메인이면 SSH 포트가 프록시를 통과하지 못해 dial tcp ... i/o timeout이 납니다. 프록시 없는 직접 접속 호스트를 써야 합니다.

5. 운영 방식의 변화: CI는 배포 PR에서만

러너를 옮기면서 워크플로 트리거도 같이 정리했습니다. feature → dev PR에는 CI를 걸지 않고, dev → main 배포 PR에서만 CI가 돕니다. 대신 로컬 pre-push 훅이 실질 게이트입니다. main 직접 푸시는 막고, dev 푸시는 composer check를 통과해야 하고, feature/*는 빠른 반복을 위해 검증을 생략합니다.

이 방식의 약점은 분명합니다. CI 변경이 배포 PR에서야 처음 검증됩니다. 그래서 이전 작업에서는 이렇게 보완했습니다.

  • DB 네트워크, 테스트 명령, paratest를 로컬 Docker로 먼저 재현하고, YAML 문법은 ruby -ryaml -e 'YAML.load_file(...)'로 확인합니다.
  • main에 CI 변경이 직접 들어가면 다음 dev → main PR이 ci.yml에서 충돌합니다. dev 기반 브랜치에 main을 병합해 해결하고, 그 동기화 PR은 merge commit으로 머지합니다. squash를 하면 main이 dev의 조상이 아니게 되어 다음 배포마다 같은 충돌이 되풀이됩니다.
  • PR이 이미 머지된 뒤 같은 브랜치에 푸시한 커밋은 dev에 들어가지 않습니다. 추가 수정은 머지 여부를 확인하고 새 브랜치·새 PR로 올립니다.

정리: 러너 이전 체크리스트

  1. 러너가 VM인지 컨테이너인지 먼저 확인한다. 컨테이너면 호스트 게시 포트는 닿지 않는다. 실행별 네트워크 + 컨테이너 이름 접속으로 간다.
  2. 같은 라벨이어도 이미지가 같다고 가정하지 않는다. PHP 버전과 확장은 CI가 스스로 보장한다(현재 활성 버전에 맞춰, 없는 것만).
  3. 비대화형 환경에서 멈출 수 있는 곳을 막는다. DEBIAN_FRONTEND=noninteractive, NEEDRESTART_MODE=a. 막힌 CI는 실패하지 않고 그냥 멈춰서 더 오래 걸려 발견된다.
  4. "통과"를 믿지 말고 무엇에 붙어서 통과했는지 확인한다. 테스트가 요청한 DB에 붙었는지 가드 테스트를 둔다. 옛 환경에서 우연히 통과하던 것은 이전할 때 터진다.
  5. 준비 확인은 실제 쿼리로 한다. mysqladmin ping은 MySQL 초기화 임시 서버에도 성공한다.
  6. 튜닝은 측정하고, 무반응이면 되돌린다. DB 옵션 → 그래도 느리면 병렬화 순서로 간다.
  7. 동시 잡 충돌을 구조로 없앤다. 고정 포트·고정 심링크·apt 락처럼 공유 자원은 실행별 격리나 락 대기로 해결한다.
  8. CI가 늦게 도는 구조라면 로컬 재현이 필수다. 배포 PR에서 처음 터지면 이미 늦다.

러너 이전은 인프라 작업이지만, 결과적으로는 CI가 실제로 무엇을 검증하고 있었는지 점검하는 가장 좋은 기회였습니다. 이전 후 CI가 한 번 빨갛게 변하는 걸 두려워하지 않아도 됩니다. 그 빨간불의 상당수는 원래부터 있던 문제가 이제야 보이는 것이기 때문입니다.