Ubuntu 24.04에 GitHub Actions runner 설치 및 설정
Ubuntu 24.04 환경에서 GitHub Actions self-hosted runner를 구축하는 법을 안내합니다. 전용 사용자 생성부터 checksum 검증, config.sh 설정, systemd 서비스 등록 및 fork pull request 보안 위험까지 상세히 다룹니다.
Self-hosted GitHub Actions runner의 역할
Self-hosted GitHub Actions runner는 사용자의 VPS에 설치되어 GitHub에 작업을 요청하고 해당 하드웨어에서 작업을 실행하는 프로그램입니다. 특정 리포지토리에 runner를 등록하고 systemd 서비스로 설치하면, 재부팅 후에도 자동으로 다시 시작됩니다. GitHub가 작업을 예약하면 사용자의 서버가 이를 수행합니다.
직접 소유한 서버에서 CI(지속적 통합)를 수행하는 이유는 두 가지입니다. 빌드 시간 제한에서 자유로워지며, 빌드 캐시나 사설 네트워크와 같이 해당 장비에서만 접근 가능한 자원을 활용할 수 있습니다. 그 대가는 보안입니다. Runner는 워크플로우 파일에 명시된 내용을 지정된 사용자의 권한으로 실행하므로, 워크플로우 파일은 설계상 원격 코드 실행(RCE)과 같습니다. 비공개 리포지토리에서는 신뢰할 수 있는 사람만 워크플로우를 추가할 수 있으므로 문제가 되지 않습니다. 하지만 공개 리포지토리에서는 실질적인 위험이 되며, 이에 대한 메커니즘은 fork pull request 섹션에서 설명합니다.
아래의 모든 내용은 Ubuntu 24.04 환경과 2026년 7월 기준 최신 릴리스인 runner 버전 2.336.0을 기준으로 작성되었습니다.
시작하기 전에 필요한 것
새 VPS에서의 처음 10분에서 도달하는 상태인, 일반 관리자 계정과 sudo 권한이 있는 VPS에서 시작합니다. 인바운드 포트를 열 필요는 없습니다. 러너(runner)가 GitHub로 아웃바운드 HTTPS(hypertext transfer protocol secure) 연결을 생성하고 작업을 기다리는 동안 이를 유지하므로, GitHub가 서버에 직접 연결할 일은 없습니다. 방화벽을 외부로부터 완전히 차단해도 작업은 정상적으로 전달됩니다.
또한 리포지토리 설정에서 등록 토큰을 확인해야 하므로, 해당 리포지토리에 대한 관리자 권한이 필요합니다.
러너를 위한 전용 사용자 생성
러너를 root나 관리자 계정으로 실행하지 마십시오. 모든 작업은 러너 사용자의 권한을 상속받으므로, sudo을 호출하는 워크플로우는 러너 사용자가 sudo를 사용할 수 있다면 성공하게 됩니다. 자신의 홈 디렉터리 외에는 아무것도 소유하지 않는 비권한 사용자를 하나 생성하십시오. VPS에서의 최소 권한 사용자 계정에서 일반적인 패턴을 다룹니다. 다음은 구체적인 설정 방법입니다.
sudo useradd -m -s /bin/bash gharunner
sudo passwd -l gharunner
sudo chmod 750 /home/gharunner
sudo install -d -m 700 -o gharunner -g gharunner /home/gharunner/actions-runnerpasswd -l은 비밀번호를 잠그므로, 누구도 gharunner 계정으로 비밀번호를 사용하여 로그인할 수 없습니다. 러너 디렉터리의 권한을 700으로 설정하는 것은 중요합니다. 러너는 자격 증명을 해당 디렉터리에 평문으로 저장하며, 체크아웃 과정에서 비공개 소스 코드가 포함될 수 있기 때문입니다.
진행하기 전에 두 가지 속성을 모두 확인하십시오:
sudo passwd -S gharunner
sudo -l -U gharunnerpasswd -S는 gharunner L으로 시작하는 줄을 출력하며, 여기서 L은 비밀번호가 잠겨 있음을 의미합니다. sudo -l -U gharunner는 is not allowed to run sudo을 반환해야 합니다. 만약 허용된 명령어 목록이 출력된다면, 해당 계정이 sudo 그룹에 포함된 것이며 방금 구축한 격리 상태가 해제된 것입니다.
러너 다운로드 및 tarball 확인
지금부터는 runner 사용자로 작업합니다.
sudo -iu gharunner
cd ~/actions-runner
RUNNER_VERSION=2.336.0
curl -fL -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"아키텍처가 확실하지 않다면 먼저 uname -m를 실행하십시오. x86_64는 위에서 언급한 linux-x64 파일을 사용합니다. aarch64은 actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz을 사용합니다.
이제 다운로드한 파일의 무결성을 검증합니다. 아래의 SHA256(Secure Hash Algorithm, 256비트) 값은 2.336.0 x64 tarball용입니다. GitHub는 릴리스 페이지와 'New self-hosted runner' 화면에 현재 릴리스의 값을 표시합니다. 이 값은 버전마다 변경되므로, 다른 버전을 설치할 때는 해당 화면에서 값을 복사하십시오.
echo "04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d actions-runner-linux-x64-2.336.0.tar.gz" | sha256sum -c정상적으로 다운로드되었다면 한 줄이 출력됩니다.
actions-runner-linux-x64-2.336.0.tar.gz: OK파일이 잘렸거나 변조되었다면 실패 메시지와 경고가 출력됩니다.
actions-runner-linux-x64-2.336.0.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match검증 단계를 건너뛰고 tar가 문제를 찾도록 두지 마십시오. 절반만 작성된 아카이브는 gzip: stdin: unexpected end of file 및 tar: Unexpected EOF in archive 오류를 발생시키며, 이는 파일이 손상되었다는 사실만 알려줄 뿐 파일이 중간에 잘렸는지 아니면 교체되었는지는 알려주지 않습니다.
tar xzf ./actions-runner-linux-x64-2.336.0.tar.gz
ls타르볼의 구성 요소와 역할
압축을 해제하면 해당 디렉터리에는 config.sh, run.sh, env.sh, safe_sleep.sh, bin/ 및 externals/이 포함되어 있습니다. bin/에는 러너 바이너리와 bin/installdependencies.sh가 들어 있습니다. externals/은 JavaScript 액션이 실행되는 번들 Node 런타임을 포함합니다.
아직 svc.sh은 존재하지 않습니다. GitHub 문서에 따르면 이는 "러너를 성공적으로 추가한 후 생성되는" 스크립트입니다. 이 스크립트는 템플릿을 기반으로 작성되며, 서비스 이름에 저장소와 러너 이름이 포함됩니다. 따라서 ./config.sh 이전에 sudo ./svc.sh install를 실행하면 sudo: ./svc.sh: command not found 오류가 발생하며 실패합니다. 먼저 등록을 수행한 다음 서비스를 설치하십시오.
러너 의존성 설치
러너는 .NET 애플리케이션이므로 몇 가지 공유 라이브러리가 필요합니다. 스크립트가 시스템 패키지 데이터베이스에 쓰기 작업을 수행하므로, 러너 사용자의 셸에서 빠져나와 sudo 권한으로 설치를 진행하십시오.
exit
cd /home/gharunner/actions-runner
sudo ./bin/installdependencies.shUbuntu 24.04 환경에서는 libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 및 libicu74가 설치됩니다. 스크립트는 각 라이브러리에 대해 여러 버전 이름을 시도하며 사용 중인 릴리스에서 제공하는 버전을 유지합니다. 이 덕분에 동일한 스크립트로 이전 버전의 Ubuntu와 Debian에서도 동작합니다.
이 단계를 건너뛰면 ./config.sh은 아무 작업도 수행하지 않고 중단됩니다.
Dependencies is missing for Dotnet Core 6.0
Execute sudo ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.libicu이 누락되면 첫 번째 줄이 Libicu's dependencies is missing for Dotnet Core 6.0인 동일한 안내 메시지가 출력됩니다. 두 경우 모두 원인은 같습니다. config.sh은 시작 전에 번들된 라이브러리를 대상으로 ldd를 실행합니다. 따라서 링크가 해결되지 않으면 나중에 혼란스러운 충돌이 발생하는 대신 스크립트가 즉시 중단됩니다.
리포지토리에 러너 등록하기
리포지토리에서 토큰을 가져옵니다. Settings, Actions, Runners, New self-hosted runner 순서로 이동합니다. 페이지에 A로 시작하는 등록 토큰이 표시됩니다. 이 토큰은 생성 후 1시간이 지나면 만료되므로, 붙여넣을 준비가 되었을 때 생성하십시오.
runner 사용자로 등록을 수행합니다. config.sh은 sudo 권한으로 실행할 수 없습니다.
sudo -iu gharunner
cd ~/actions-runner
./config.sh --url https://github.com/YOUR-USER/YOUR-REPO \
--token PASTE_REGISTRATION_TOKEN_HERE \
--name vps-runner-1 \
--labels vps \
--work _work \
--unattended \
--replace각 플래그의 역할은 다음과 같습니다. --name은 리포지토리에 표시될 러너의 이름이므로, 6개월 뒤에도 알아볼 수 있는 이름을 선택하십시오. --labels은 사용자 정의 레이블을 추가합니다. 러너는 별도의 설정 없이도 기본적으로 self-hosted, Linux, X64 레이블을 가집니다. --work는 러너 디렉터리 내에서 체크아웃이 수행될 디렉터리 이름을 지정합니다. --unattended은 대화형 프롬프트에 기본값으로 응답하며, 스크립트 내에서 명령을 실행할 때 유용합니다. --replace는 동일한 이름의 기존 등록이 있을 경우 실패하는 대신 이를 덮어쓰며, 서버를 재구축할 때 유용합니다.
성공적으로 실행되면 마지막에 다음 줄이 표시됩니다.
√ Runner successfully added
√ Runner connection is good
√ Settings Saved.등록 정보는 러너 디렉터리에 .runner, .credentials, .credentials_rsaparams 파일로 저장됩니다. 뒤의 두 파일은 GitHub에 이 러너를 식별하는 정보를 담고 있으므로, 해당 파일을 읽을 수 있는 사람은 누구나 러너를 사칭할 수 있습니다. 이것이 디렉터리 권한을 700으로 설정하고 사용자가 sudo 권한을 갖지 않도록 하는 이유입니다.
systemd 서비스로 러너 설치하기
터미널에서 ./run.sh을 실행하는 것은 테스트 용도로는 적합하지만, SSH 세션이 종료되면 함께 중단됩니다. 부팅 시 러너가 자동으로 시작되도록 서비스로 설치하십시오. VPS에서의 systemd 서비스 및 타이머에서 유닛 파일에 대해 설명합니다. 여기서는 svc.sh가 사용자를 대신해 유닛 파일을 작성합니다.
exit
cd /home/gharunner/actions-runner
sudo ./svc.sh install gharunner
sudo ./svc.sh start
sudo ./svc.sh statussvc.sh은 /etc/systemd/system에 유닛 파일을 작성하고 활성화해야 하므로 root 권한이 필요합니다. install 뒤에 오는 인자는 서비스가 실행될 사용자 계정입니다. gharunner을 명시적으로 전달하십시오. 인자를 전달하지 않으면 스크립트는 기본값인 $SUDO_USER(관리자 계정)를 사용하며, 이 경우 모든 작업이 sudo를 사용할 수 있는 사용자의 권한으로 실행됩니다.
유닛 이름은 저장소와 러너 이름을 조합하여 actions.runner.YOUR-USER-YOUR-REPO.vps-runner-1.service 형식으로 지정됩니다. 이 이름을 직접 입력할 필요는 없습니다.
systemctl list-units 'actions.runner.*'
sudo journalctl -u 'actions.runner.*' -n 20 --no-pager정상적으로 작동하는 러너는 √ Connected to GitHub을 기록한 뒤 Listening for Jobs으로 끝나는 줄을 출력하며, 저장소의 Runners 페이지에는 Idle 상태로 표시됩니다. Offline으로 표시되는 러너는 실행 중이 아니거나 443 포트를 통해 GitHub에 연결할 수 없는 상태입니다.
러너에 작업 전송
runs-on은 레이블을 기준으로 러너를 선택합니다. 의도하지 않은 러너에서 작업이 실행되지 않도록 self-hosted와 함께 사용자 지정 레이블을 지정하십시오.
name: build
on:
push:
branches: [main]
jobs:
build:
runs-on: [self-hosted, linux, vps]
steps:
- uses: actions/checkout@v5
- run: uname -a작업이 Waiting for a runner to pick up this job 상태에서 대기 중이라면 레이블이 일치하지 않는 것입니다. runs-on에 나열된 모든 레이블이 러너에 존재해야 하며, 레이블 하나라도 다르면 오류 메시지 없이 작업이 대기열에 남습니다. 저장소 설정의 러너 옆에 표시된 레이블 목록과 해당 설정을 비교하십시오.
자체 호스팅 러너와 공개 저장소를 함께 사용하면 안 되는 이유
이 부분은 많은 사람이 간과하는 내용입니다. GitHub의 가이드는 단호합니다. 자체 호스팅 러너는 "공개 저장소에 절대 사용해서는 안 되며", "일회용 클린 가상 머신에서 실행된다는 보장이 없으므로 워크플로우 내의 신뢰할 수 없는 코드에 의해 영구적으로 침해될 수 있다"고 명시합니다.
원리는 간단합니다. 포크(fork)에서 보낸 풀 리퀘스트는 해당 워크플로우 파일의 복사본을 함께 가져옵니다. 만약 공개 저장소에서 풀 리퀘스트 워크플로우를 러너에서 실행하도록 설정했다면, 저장소를 포크할 수 있는 누구든 자신의 명령을 귀하의 VPS에서 실행하는 워크플로우를 제안할 수 있습니다. 제안하는 내용 자체가 실행되는 코드이므로, 저장소에 대한 쓰기 권한은 전혀 필요하지 않습니다.
승인 설정은 이 문제를 완화할 뿐 근본적으로 해결하지는 못합니다. 공개 저장소의 기본 정책은 관리자가 처음 기여하는 사용자의 포크 워크플로우를 승인하도록 요구합니다. 하지만 한 번 승인하고 나면, 이후 해당 사용자가 보내는 풀 리퀘스트는 별도의 확인 없이 실행됩니다. 결국 매번 사람이 직접 diff를 검토해야 하는 구조인데, 빌드 스크립트의 3단계 아래에 숨겨진 페이로드는 놓치기 매우 쉽습니다.
포크 풀 리퀘스트는 귀하의 비밀 값을 전달받지 않으며, 해당 GITHUB_TOKEN는 읽기 전용입니다. 이는 GitHub 내부에서의 피해를 제한할 뿐, 귀하의 서버에는 아무런 도움이 되지 않습니다. 공격자는 gharunner 계정의 셸을 획득하게 되며, 해당 사용자가 읽을 수 있는 모든 파일을 열람하고, VPS가 사설 네트워크를 통해 접근할 수 있는 모든 곳에 도달할 수 있습니다. 또한 ~/.bashrc나 사용자 systemd 유닛에 악성 코드를 남겨 다음 작업이 실행될 때 함께 동작하도록 만들 수 있습니다.
--ephemeral를 사용하여 등록하면 러너는 하나의 작업을 수행한 뒤 등록을 해제하므로, 한 작업이 다음 작업의 워크스페이스를 읽을 수는 없습니다. 하지만 이는 각 작업마다 머신이나 컨테이너를 완전히 새로 구축하는 경우에만 유효합니다. 러너 사용자의 홈 디렉터리에 작성된 백도어는 재등록 후에도 그대로 남아있기 때문입니다.
다음 규칙은 간단합니다. 자체 호스팅 러너는 비공개 저장소에만 사용하십시오. 만약 반드시 공개 저장소에 연결해야 한다면, 포크 풀 리퀘스트를 해당 러너에서 실행하지 말고, 해당 서버에는 다른 어떤 서비스도 두지 않으며, 머신을 언제든 폐기할 수 있는 일회용 자원으로 취급하십시오.
Docker 작업과 실질적인 root 권한 그룹
컨테이너 작업, 서비스 컨테이너 및 docker build을 호출하는 모든 워크플로우 단계는 러너 호스트에서 Docker 데몬을 필요로 합니다. VPS에 Docker 및 Docker Compose 설치에서 다루는 일반적인 방법으로 Docker를 설치한 다음, 러너 사용자를 docker 그룹에 추가하십시오.
이 작업을 수행하기 전에 따르는 위험을 이해해야 합니다. docker 그룹의 멤버십은 root 권한과 동일합니다. 컨테이너가 /를 바인드 마운트하여 내부에서 root로 실행될 수 있기 때문입니다. 따라서 Docker 소켓과 통신할 수 있는 워크플로우는 /etc/shadow을 포함하여 VPS의 모든 파일을 읽고 쓸 수 있습니다. 신뢰할 수 있는 기여자가 있는 비공개 저장소라면 이는 감수할 만한 대가일 수 있습니다. 그 외의 환경에서는 권한이 없는 사용자를 사용하는 의미가 사라집니다. Rootless Docker는 스토리지 드라이버 속도가 느려지고 권한이 있는 컨테이너를 사용할 수 없다는 단점이 있지만, 컨테이너 빌드를 러너 사용자 본인의 권한 범위 내에서 유지합니다.
업데이트 및 러너 깔끔하게 제거하기
자체 호스팅 러너는 기본적으로 스스로 업데이트를 수행합니다. 새로운 릴리스를 감지하면 자체 파일을 교체하고 서비스를 재시작하므로, 일반적으로 사용자가 별도로 조치할 사항은 없습니다. ./config.sh --disableupdate은 특정 버전을 고정해야 할 때 자동 업데이트를 끕니다. 이 설정을 적용한 후에는 업데이트를 직접 관리해야 합니다. GitHub 문서에 따르면 --disableupdate로 구성된 러너는 수동으로 업데이트해야 한다고 명시되어 있습니다.
수동 업데이트를 수행해도 등록 정보는 유지됩니다. .runner 및 .credentials 파일은 tarball에 포함되어 있지 않기 때문입니다. 서비스를 중지하고, 새로운 tarball을 다운로드하여 gharunner로 체크섬을 확인한 뒤, tar xzf을 사용하여 동일한 디렉터리에 압축을 해제하고 서비스를 다시 시작하십시오.
cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh start러너를 제거하려면 먼저 서비스를 삭제한 다음 등록을 해제해야 합니다. 제거 토큰은 러너의 Runners 페이지에 있는 해당 러너의 제거(Remove) 버튼에서 얻을 수 있습니다.
cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh uninstall
sudo -iu gharunner
cd ~/actions-runner
./config.sh remove --token PASTE_REMOVAL_TOKEN_HERE등록을 해제하지 않고 디렉터리만 삭제하면 러너는 리포지토리에서 오프라인(Offline) 상태로 표시됩니다. GitHub는 러너가 직접 알리거나 관리자가 수동으로 항목을 삭제할 때만 러너가 제거되었음을 인식하기 때문입니다.
실패 모드 및 관련 메시지
Must not run with sudo. config.sh를 root 권한으로 실행하면 이 메시지가 출력되고 종료됩니다. 이 검사는 의도된 동작입니다. _work 내의 파일 소유권이 root로 설정되면, 이후 서비스 사용자로 실행되는 모든 작업이 실패하기 때문입니다. ./config.sh은 gharunner 사용자로 실행하십시오. RUNNER_ALLOW_RUNASROOT 변수를 사용하면 이 검사를 우회할 수 있지만, 이는 문제 발생 시점을 뒤로 미룰 뿐입니다.
sudo: ./svc.sh: command not found. 올바른 디렉터리에 위치해 있습니다. config.sh가 등록을 완료하지 않았기 때문에 svc.sh가 아직 존재하지 않는 상태입니다. 러너를 먼저 등록한 뒤 서비스를 설치하십시오.
Http response code: NotFound from 'POST https://api.github.com/actions/runner-registration'. 토큰이 유효한 등록 토큰이 아닙니다. 토큰은 1시간 동안만 유효하므로 만료되었거나, Runners 페이지의 등록 토큰 대신 개인 액세스 토큰(personal access token)을 입력했을 가능성이 있습니다. 새로운 토큰을 생성하여 다시 입력하십시오.
Dependencies is missing for Dotnet Core 6.0. 러너 디렉터리에서 sudo ./bin/installdependencies.sh을 root 권한으로 실행한 뒤 다시 등록하십시오.
재부팅 후 러너 오프라인 상태. systemctl is-enabled 'actions.runner.*'를 실행하십시오. 아무것도 출력되지 않는다면 ./svc.sh install이 실행된 적이 없는 것이며, 러너는 터미널 세션 내에서만 존재했던 것입니다. 유닛이 활성화되어 있음에도 러너가 여전히 오프라인 상태라면 journalctl -u 'actions.runner.*'을 읽고 아웃바운드 HTTPS 연결을 확인하십시오.
디스크 용량 부족. 체크아웃, 빌드 캐시, Docker 이미지가 _work와 러너 사용자의 홈 디렉터리에 계속 쌓이지만, 시스템이 자동으로 이를 정리하지는 않습니다. du -sh /home/gharunner/actions-runner/_work을 모니터링하고, 디스크가 가득 차기 전에 주기적인 정리 작업을 추가하십시오.
FAQ
왜 sudo ./svc.sh install 명령어를 찾을 수 없다고 나옵니까?
svc.sh가 러너 tarball에 포함되어 있지 않기 때문입니다. 이 파일은 ./config.sh 등록 과정이 완료될 때, 사용자의 저장소와 러너 이름을 사용하여 서비스 이름을 생성하면서 러너 디렉터리에 만들어집니다. 먼저 러너 사용자로 ./config.sh을 실행하십시오. 그 후 sudo ./svc.sh install gharunner이 스크립트를 찾아 /etc/systemd/system 경로에 actions.runner.OWNER-REPO.RUNNER-NAME.service라는 이름의 유닛 파일을 작성합니다.
자체 호스팅 러너를 위해 방화벽 포트를 열어야 합니까?
아니요. 러너는 GitHub로 나가는 HTTPS 연결을 생성하고 작업을 기다리는 동안 이를 유지하므로, GitHub가 사용자의 VPS로 직접 연결을 시도하지 않습니다. 443 포트로 나가는 트래픽만 허용하고 들어오는 규칙은 닫아 두십시오. 서비스가 실행 중인데도 러너가 Offline으로 표시된다면, 들어오는 규칙이 아니라 나가는 트래픽 필터링이나 DNS 설정을 확인하십시오.
공개 저장소에서 자체 호스팅 러너를 사용할 수 있습니까?
사용할 수는 있지만, GitHub는 이를 권장하지 않습니다. 포크된 저장소에서 보낸 풀 리퀘스트는 자체 워크플로우 파일을 포함하므로, 저장소를 포크할 수 있는 사람이라면 누구든 사용자의 머신에서 실행될 명령어를 제안할 수 있습니다. 승인 프롬프트는 기여자의 첫 번째 실행에만 적용됩니다. 공개 저장소에 러너를 연결해야 한다면 해당 저장소의 포크 풀 리퀘스트 워크플로우를 비활성화하고, 해당 서버에는 다른 데이터를 두지 않으며, 주기적으로 머신을 재구축하십시오.
왜 Http response code: NotFound 오류와 함께 등록이 실패합니까?
등록 호출 시 URL이 잘못된 경우뿐만 아니라 자격 증명이 잘못된 경우에도 NotFound 응답이 반환되므로 메시지가 혼동을 줄 수 있습니다. 등록 토큰은 표시된 후 1시간이 지나면 만료되며, 이 호출에는 개인 액세스 토큰(PAT)을 사용할 수 없습니다. Settings, Actions, Runners, New self-hosted runner 메뉴로 이동하여 새로운 토큰을 복사하고, --url 값이 관리자 권한이 있는 저장소를 가리키고 있는지 확인하십시오.