러너를 컨테이너로 띄우면 GitHub 호스티드 러너에서 당연하던 것들이 하나씩 깨집니다. 이 글은 제가 실제로 밟은 여섯 가지 함정과 그 해법입니다.
지난 글 《AI가 쓴 코드도 같은 기준으로: GitHub Actions 검증 파이프라인》에서, 무료 플랜의 월 2,000분을 넘겨 CI가 멈춘 뒤 셀프호스티드 러너로 옮긴 이야기를 했습니다. 지금은 Ubuntu 26.04 서버에 Docker로 러너 3대를 띄우고, GitHub 조직 단위로 등록해 모든 저장소가 함께 씁니다.
옮기는 데는 반나절이면 충분했습니다. 문제는 그다음이었습니다. ubuntu-latest에서 초록색이던 워크플로가 컨테이너 러너에서는 이유도 없이 빨간색이 됐습니다. 원인을 찾고 보니 결국 한 문장으로 요약됩니다.
GitHub 호스티드 러너는 매번 새로 만들어지는 가상머신이고, 내 러너는 계속 살아 있는 컨테이너다.
여섯 가지 문제는 모두 이 차이에서 나옵니다.
1. 포트를 게시했는데 localhost에 아무도 없다
통합 테스트가 SQLSTATE[HY000] [2002] Connection refused로 죽었습니다. 테스트용 MySQL은 러너 호스트에 따로 띄워 두었고, .env의 호스트는 localhost였습니다.
원인: 컨테이너 안의 localhost는 러너 자신이다
가상머신 러너에서는 localhost:3306이 그 머신의 MySQL입니다. 컨테이너 러너에서 localhost는 러너 컨테이너의 루프백입니다. 호스트의 MySQL도, 옆 컨테이너의 MySQL도 아닙니다.
포트 게시(-p 3306:3306)는 헷갈리기 더 쉽습니다. 게시는 컨테이너 포트를 호스트 쪽으로 여는 것이지, 다른 컨테이너의 localhost로 연결해 주는 것이 아닙니다. 반대로 MySQL을 게시하지 않았다면 호스트 IP로도 닿지 않습니다.
해법: 같은 네트워크에 넣고 서비스 이름으로 부른다
러너와 테스트용 DB를 하나의 Compose 네트워크에 두고, 호스트 이름을 서비스 이름으로 바꿈습니다. 포트 게시는 아예 필요 없어집니다.
# docker-compose.yml (러너 호스트, 예시)
services:
runner-1:
image: my-gh-runner:php8.5
networks: [ci]
mysql-test:
image: mysql:8.0
environment:
MYSQL_DATABASE: ci_test
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
networks: [ci]
# ports: 따로 게시하지 않는다
networks:
ci: {}
# .github/workflows/verify.yml
- name: Tests
env:
database.tests.hostname: mysql-test
database.tests.port: 3306
run: vendor/bin/phpunit
CodeIgniter 4는 database.tests.* 환경 변수로 테스트 DB 그룹을 덮어쓸 수 있습니다. 그래서 .env를 건드리지 않고 워크플로에서만 바꿉니다. 호스트에 떠 있는 서비스에 붙어야 한다면 localhost 대신 host.docker.internal을 쓰고, 러너 컨테이너에 extra_hosts: ["host.docker.internal:host-gateway"]를 추가합니다.
2. 같은 PR이 러너에 따라 통과하기도, 실패하기도 한다
가장 괴로운 유형이었습니다. 재실행만 눌러도 통과하는 날이 있었습니다. 로그를 비교하니 실패한 잡은 항상 같은 러너에서 돌고 있었습니다.
원인: 러너 3대가 서로 다른 환경이었다
러너를 한 대씩 늘리면서 이미지를 그때그때 다시 빌드했습니다. 그 사이 Ondrej PPA의 PHP 8.5 패치 버전이 바뀌었고, 한 대는 php8.5-intl이 빠진 채로 만들어졌습니다. 호스티드 러너는 모두 같은 이미지에서 출발하지만, 내 러너는 내가 만든 시점의 상태를 그대로 간직합니다.
매트릭스로 여러 PHP 버전을 돌리려 할 때도 같은 문제가 있습니다. shivammathur/setup-php는 호스티드 러너에서는 제일 편하지만, 셀프호스티드에서는 러너 안에 PHP를 설치하고 그 상태를 다음 잡에 남깁니다.
해법: 러너 이미지를 하나로 고정하고, 버전을 라벨로 드러낸다
- 이미지는 Dockerfile 하나에서만 만듭니다. PHP 확장 목록을 명시하고, 태그에 PHP 버전을 넣습니다(
my-gh-runner:php8.5). 3대는 항상 같은 태그로 같이 교체합니다. - 러너에 버전 라벨을 붙입니다. 등록 시
--labels php8.5를 주고, 워크플로는runs-on: [self-hosted, linux, php8.5]로 요청합니다. 버전이 다른 러너가 생기면 잡이 그쪽으로 가지 않습니다. - 잡 처음에 환경을 찍습니다. 실패 로그만 봐도 어느 러너, 어느 버전이었는지 바로 보입니다.
- name: Runner environment
run: |
echo "runner: $RUNNER_NAME"
php -v | head -1
php -m | sort | tr '\n' ' '
composer --version
여기에 composer.json의 config.platform.php를 운영 버전에 맞춰 두면, 러너의 PHP가 조금 달라도 의존성 해석 결과는 흔들리지 않습니다.
3. Docker 액션과 services:가 돌지 않는다
워크플로에 services: mysql:을 선언하자 잡이 시작도 하기 전에 멈추었습니다. 로그에는 Docker를 찾을 수 없다는 오류만 남았습니다. 마켓플레이스의 몇몇 액션도 같은 이유로 실패했습니다.
원인: 러너 컨테이너 안에는 Docker가 없다
GitHub Actions에서 다음 세 가지는 러너가 직접 docker 명령을 실행해야 돌아갑니다.
|
문법 |
러너가 하는 일 |
|---|---|
|
|
서비스 컨테이너를 띄운다 |
|
|
잡 전체를 컨테이너 안에서 실행한다 |
|
|
액션 이미지를 빌드하거나 받아 실행한다 |
호스티드 러너에는 Docker가 기본으로 있습니다. 내 러너는 그 자신이 컨테이너라, 안에 Docker 데몬이 없습니다. 액션 사용자 입장에서는 어떤 액션이 Docker 액션인지 이름만 보고는 알 수 없다는 것도 함정입니다. 저장소의 action.yml을 열어 봐야 합니다.
해법: 세 가지 길 중 하나를 고른다
- Docker 의존을 걷어낸다. 제가 고른 길입니다.
services:대신 1번에서 만든 Compose 네트워크의mysql-test를 씁니다. Docker 액션은 JavaScript 액션이나 직접 쓴run:스크립트로 바꿉니다. - 호스트 Docker 소켓을 마운트한다.
/var/run/docker.sock을 러너에 물리고 Docker CLI를 설치하면 돌아갑니다. 다만 잡 안의 코드가 호스트의 Docker를, 사실상 호스트 root를 얻습니다. 비공개 저장소만 연결된 러너에서만 고려할 만합니다. - Docker-in-Docker(DinD)를 씁니다. 격리는 가장 좋지만
privileged모드가 필요하고, 이미지 캐시가 따로 놓여 느려집니다.
PHP 테스트에 Docker가 필요한 경우는 대부분 DB·Redis 같은 의존 서비스입니다. 이것만 러너 밖에 미리 띄워 두면, 잡 안에서 Docker를 부를 일은 거의 사라집니다.
4. 배포 호스트가 Cloudflare 뒤에 있으면 SSH가 안 붙는다
검증은 통과했는데 배포 잡이 ssh: connect to host ... port 22: Connection timed out으로 끝났습니다. 지난 글의 appleboy/ssh-action 설정은 그대로였습니다.
원인: 도메인은 Cloudflare를 가리키고, Cloudflare는 22번을 넘겨주지 않는다
SSH_HOST 시크릿에 서버 IP 대신 도메인을 넣어 두었습니다. 그 도메인의 DNS 레코드가 프록시(주황색 구름)로 켜져 있었습니다. 프록시된 도메인은 오리진이 아니라 Cloudflare 엣지 IP로 풀리고, 엣지는 HTTP(S) 포트만 중계합니다. 22번으로 두드리면 아무도 받지 않습니다.
로컬에서는 ~/.ssh/config에 IP를 적어 두었기 때문에 이 차이를 못 느꼈습니다. 거기에 오리진 방화벽을 잠가 두면, IP로 바꿔도 러너의 공인 IP가 허용 목록에 없어 다시 막힙니다.
해법: SSH는 프록시를 거치지 않는 길로 넣는다
선택지는 세 가지입니다.
|
방법 |
장점 |
주의할 점 |
|---|---|---|
|
오리진 IP를 시크릿에 직접 넣기 |
설정이 가장 간단하다 |
방화벽에 러너 공인 IP를 열어야 한다 |
|
DNS only 하위 도메인( |
서버 IP가 바뀌어도 시크릿을 안 고친다 |
오리진 IP가 DNS로 노출된다 |
|
Cloudflare Tunnel + |
22번을 외부에 열지 않는다 |
러너 이미지에 |
제 경우 러너가 집에 있는 서버에서 돌아 공인 IP가 거의 고정입니다. 그래서 오리진 IP + 러너 IP만 허용으로 정리했습니다. 러너가 클라우드에 있거나 IP가 자주 바뀐다면 Tunnel 쪽이 더 안전합니다.
# 운영 서버 (ufw)
sudo ufw allow from <러너_공인_IP> to any port 22 proto tcp
하나 더, 컨테이너 러너는 재생성될 때마다 known_hosts가 비어 있습니다. 호스트 키 검증을 끄지 말고, 서버의 호스트 키 지문을 시크릿(SSH_FINGERPRINT)으로 넣어 fingerprint: 입력에 넘깁니다.
참고로 jblove.net은 한국 트래픽이 해외 PoP로 빠지는 문제 때문에 이후 DNS only로 돌렸습니다. 프록시 여부는 도메인마다 다를 수 있으니, 배포 대상은 항상 "이 이름이 지금 어디로 풀리는가"부터 확인합니다.
5. 어제는 됐던 checkout이 권한 오류로 실패한다
어느 날부터 actions/checkout이 EACCES: permission denied, unlink '.../writable/cache/...'를 내며 실패했습니다. 코드는 바뀐 것이 없었습니다.
원인: 작업 공간에 다른 사용자 소유의 파일이 남아 있었다
러너 프로세스는 runner 사용자(UID 1001)로 돌고 있었습니다. 그런데 테스트 스텝 하나가 sudo로 실행되면서 CodeIgniter 4의 writable/ 아래에 root 소유 캐시·로그 파일을 남겼습니다. 다음 잡의 checkout은 작업 공간을 정리하다가 그 파일을 지우지 못하고 멈춘 것입니다.
호스트 디렉터리를 _work로 마운트해 두면 문제가 더 커집니다. 컨테이너 안의 UID와 호스트의 UID가 다르면, 호스트에서도 내 계정으로 지울 수 없는 파일이 쌓입니다.
해법: root로 쓰지 않고, UID를 맞추고, 끝날 때 치운다
- 잡 안에서
sudo를 없앤다. 필요한 패키지는 러너 이미지에 미리 넣습니다.runner계정에는 sudo 권한을 주지 않았습니다. - 러너 UID를 호스트와 맞춘다. 이미지 빌드 때
--build-arg RUNNER_UID=$(id -u)로 호스트 계정과 같은 UID를 줍니다. - 잡이 끝날 때 작업 공간을 치운다. 실패해도 돌도록
if: always()를 붙입니다.
- name: Clean workspace
if: always()
run: git clean -ffdx && rm -rf writable/cache/* writable/logs/*
이미 쌓인 root 소유 파일은 호스트에서 한 번 sudo chown -R으로 정리했습니다. 그 뒤로는 재발하지 않았습니다.
6. 이전 잡의 흔적이 다음 잡을 초록색으로 만든다
여섯 가지 중 가장 위험한 것은 빨간색이 아니라 가짜 초록색입니다. 로컬과 CI에서는 통과한 코드가 깨끗한 환경에서는 깨졌습니다. 새 마이그레이션 파일을 커밋에서 빠뜨렸는데도 테스트가 통과한 것입니다.
원인: 셀프호스티드 러너는 기본적으로 잡을 끝내도 사라지지 않는다
호스티드 러너는 잡마다 가상머신을 버립니다. 내 러너는 같은 컨테이너가 잡을 계속 받습니다. 그래서 다음이 남습니다.
.gitignore된 파일: 다른 브랜치 잡이 만든.env,vendor/, 마이그레이션 결과- 테스트 DB의 테이블과 데이터: 서로 다른 저장소가 같은
mysql-test를 보면 서로의 스키마가 섞입니다 - Composer 캐시와
~/.config같은 홈 디렉터리 상태
제 경우 테스트 DB에 이전 잡이 만든 테이블이 있어서, 마이그레이션이 빠져도 테스트가 통과했습니다.
해법: 러너를 일회용으로 만들고, 남겨도 되는 것만 남긴다
--ephemeral로 등록한다. 러너가 잡 하나만 받고 종료되면, Compose의restart: always가 새 컨테이너를 띄웁니다. 러너 등록 토큰은 짧게 만료되므로, 시작 스크립트가 GitHub API로 매번 새 토큰을 받게 했습니다.- 테스트 DB는 러너별·잡별로 나눈다. 러너 이름으로 DB 이름을 만들고(
ci_test_runner_1), 잡 시작 때 비운 뒤 마이그레이션부터 다시 돌립니다. 그래야 마이그레이션 누락이 바로 빨간색으로 나옵니다. - 남겨도 되는 것은 명시적으로 남긴다. Composer 캐시 디렉터리만 볼륨으로 공유해 설치 속도를 지켰습니다.
vendor/와.env는 매번 새로 만듭니다.
- name: Reset test database
run: |
DB="ci_test_${RUNNER_NAME//-/_}"
mysql -h mysql-test -uroot -p"$MYSQL_ROOT_PASSWORD" \
-e "DROP DATABASE IF EXISTS $DB; CREATE DATABASE $DB;"
echo "database.tests.database=$DB" >> "$GITHUB_ENV"
에피메럴로 바꾼 뒤 5번의 권한 문제도 구조적으로 사라졌습니다. 남을 작업 공간 자체가 없기 때문입니다.
한눈에 보기
|
# |
증상 |
근본 원인 |
해법 |
|---|---|---|---|
|
1 |
DB |
|
같은 네트워크 + 서비스 이름 |
|
2 |
러너에 따라 결과가 다름 |
러너별 PHP·확장 차이 |
단일 이미지 + 버전 라벨 |
|
3 |
|
러너 안에 Docker 없음 |
의존 서비스를 러너 밖으로 |
|
4 |
배포 SSH 타임아웃 |
Cloudflare 프록시는 22번 미중계 |
오리진 IP 또는 Tunnel |
|
5 |
checkout |
root 소유 잔여 파일 |
sudo 제거, UID 맞춤, 정리 스텝 |
|
6 |
가짜 초록색 |
잡 사이 상태 잔여 |
|
컨테이너 러너 점검 체크리스트
새 러너를 띄우거나 이미지를 바꿀 때마다 확인합니다.
- 워크플로에
localhost로 점속하는 외부 서비스가 없는가 - 모든 러너가 같은 이미지 태그이고, PHP 버전이 라벨로 드러나는가
services:,container:, Docker 액션을 쓰지 않는가 (쓴다면 소켓·DinD 결정을 했는가)- 배포 호스트 이름이 프록시되지 않은 주소로 풀리는가
- 호스트 키 지문을 시크릿으로 검증하는가
- 잡 안에서
sudo를 쓰지 않는가 - 러너가
--ephemeral로 등록돼 있는가 - 테스트 DB가 러너별로 나뉘고 잡마다 초기화되는가
- 외부 포크 PR이 셀프호스티드 러너에 닿지 않는가
돌아보며
- 호스티드 러너가 공짜로 해 주던 일이 많았다. Docker, 깨끗한 파일시스템, 일정한 환경. 월 2,000분을 아끼는 대신 이것들을 제가 직접 만들어야 했습니다.
- 빨간색보다 가짜 초록색이 더 비싸다. 깨지는 문제는 바로 눈에 띈니다. 상태가 남는 문제는 운영에서야 드러납니다. AI가 만든 PR을 CI가 걸러 주기로 했다면, 그 CI가 거짓말을 하지 않는 것이 먼저입니다.
- 일회용이 기본값이어야 한다. 5번과 6번은 결국 하나의 해법으로 풀렸습니다. 처음부터
--ephemeral로 시작했다면 겨지 않았을 문제입니다. - 로그 처음에 환경을 찍어 두면 반은 풀린다. 러너 이름, PHP 버전, 확장 목록 세 줄이 2번 문제를 찾는 시간을 하루에서 10분으로 줄여 주었습니다.
- 네트워크 경로는 코드 밖의 변수다. 1번과 4번은 코드가 아니라 "이 주소가 지금 어디로 가는가"의 문제였습니다. 코드를 의심하기 전에 경로부터 확인합니다.
댓글 0개