SSD Nodes Learn 8GB RAM — 연 $66
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-01

Ubuntu 24.04 VPS에 GitHub Actions runner 설치

Ubuntu 24.04와 runner version 2.336.0 기준으로 전용 사용자, checksum 검증, config.sh, systemd service 등록 방법과 fork pull request의 원격 코드 실행 위험을 설명합니다.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

자체 호스팅 GitHub Actions runner의 역할

자체 호스팅 GitHub Actions runner는 사용자가 소유한 VPS에 설치하는 프로그램입니다. 이 프로그램은 GitHub에 작업을 요청하고 사용자의 하드웨어에서 작업을 실행합니다. 하나의 repository에 등록하고 systemd service로 설치하면, 서버가 재부팅될 때마다 다시 시작됩니다. 작업 예약은 GitHub가 수행합니다. 실제 작업은 사용자의 서버가 수행합니다.

사용자가 소유한 서버에서 CI (continuous integration)를 실행할 가치는 2가지 이유에서 있습니다. 빌드 사용 시간이 더 이상 측정되지 않으며, 작업에서 사용자의 시스템에만 있는 항목에 접근할 수 있습니다. 예를 들면 이미 준비된 빌드 캐시나 private network가 있습니다. 대신 보안 문제가 발생합니다. runner는 workflow file에 지정된 모든 작업을 runner에 지정한 사용자 권한으로 실행합니다. 따라서 workflow file은 설계상 원격 코드 실행입니다. private repository에서는 신뢰하는 사용자만 workflow file을 추가할 수 있으므로 문제가 되지 않습니다. public repository에서는 실제 위험이 있으며, fork pull request 관련 섹션에서 그 작동 방식을 설명합니다.

이하의 내용은 Ubuntu 24.04와 runner version 2.336.0을 기준으로 합니다. 이는 2026년 7월 현재 최신 release입니다.

시작하기 전에 필요한 사항

일반 관리자 계정과 sudo가 있는 VPS에서 시작합니다. 상태는 새 VPS에서 처음 10분 동안 수행하는 작업을 완료한 상태여야 합니다. 인바운드 포트를 열 필요는 없습니다. runner는 GitHub에 아웃바운드 HTTPS(hypertext transfer protocol secure) 연결을 열고 작업을 기다리는 동안 연결을 유지합니다. 따라서 GitHub가 서버에 연결하지 않습니다. 방화벽은 외부에 대해 닫힌 상태로 유지해도 작업이 도착합니다.

또한 repository에 대한 관리자 권한이 필요합니다. repository 설정에 registration token이 표시되기 때문입니다.

runner 전용 사용자 생성

runner를 root 또는 자신의 관리자 사용자로 실행하지 마십시오. 모든 작업은 runner 사용자의 권한을 상속합니다. 따라서 runner 사용자가 sudo를 사용할 수 있으면 sudo를 호출하는 workflow가 실행됩니다. 자체 홈 디렉터리 외에는 아무것도 소유하지 않는 권한이 없는 사용자를 하나 생성합니다. 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-runner

passwd -l는 비밀번호를 잠급니다. 따라서 누구도 해당 비밀번호로 gharunner에 로그인할 수 없습니다. runner 디렉터리에 mode 700을 적용하는 것이 중요합니다. runner는 이 디렉터리에 자격 증명을 평문으로 저장하며, checkout에는 비공개 소스가 포함될 수 있습니다.

계속 진행하기 전에 다음 두 속성을 모두 확인합니다.

sudo passwd -S gharunner
sudo -l -U gharunner

passwd -Sgharunner L로 시작하는 행을 출력합니다. 여기서 L는 비밀번호가 잠겨 있음을 의미합니다. sudo -l -U gharunner의 결과는 is not allowed to run sudo이어야 합니다. 허용된 명령 목록이 대신 출력되면 해당 계정이 sudo 그룹에 속한 것이므로 방금 구축한 격리가 해제된 상태입니다.

runner를 다운로드하고 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 파일을 사용합니다. aarch64actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz를 사용합니다.

이제 다운로드한 파일을 확인합니다. 아래 SHA256(secure hash algorithm, 256 bit) 값은 2.336.0 x64 tarball에 해당합니다. GitHub는 release 페이지와 New self-hosted runner 화면에 현재 release의 값을 표시합니다. 이 값은 버전마다 변경되므로 다른 버전을 설치할 때는 해당 위치에서 값을 복사합니다.

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가 문제를 찾도록 두지 마십시오. 일부만 기록된 archive는 gzip: stdin: unexpected end of filetar: Unexpected EOF in archive에서 실패합니다. 이 메시지는 파일이 손상되었다는 사실은 알려 주지만, 파일이 중간에 잘렸는지 또는 다른 파일로 대체되었는지는 알려 주지 않습니다.

tar xzf ./actions-runner-linux-x64-2.336.0.tar.gz
ls

tarball에 포함되는 항목과 포함되지 않는 항목

압축을 해제하면 디렉터리에 config.sh, run.sh, env.sh, safe_sleep.sh, bin/externals/이 있습니다. bin/에는 runner 바이너리와 bin/installdependencies.sh이 있습니다. externals/에는 JavaScript action이 실행되는 번들 Node runtime이 있습니다.

아직 svc.sh은 없습니다. GitHub 문서에서는 이를 “runner를 성공적으로 추가한 후 생성되는” 스크립트라고 설명합니다. 이 파일은 repository와 runner 이름이 service 이름에 포함된 template에서 생성됩니다. 따라서 ./config.sh 전에 sudo ./svc.sh install를 실행하면 sudo: ./svc.sh: command not found 오류가 발생합니다. 먼저 등록한 다음 service를 설치합니다.

runner 종속 항목 설치

runner는 .NET 애플리케이션이므로 몇 가지 공유 라이브러리가 필요합니다. runner 사용자의 셸을 유지한 상태에서 sudo로 라이브러리를 설치합니다. 이 스크립트는 시스템 패키지 데이터베이스에 기록하기 때문입니다.

exit
cd /home/gharunner/actions-runner
sudo ./bin/installdependencies.sh

Ubuntu 24.04에서는 libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64libicu74가 설치됩니다. 이 스크립트는 각 라이브러리에 대해 여러 버전 이름을 시도하고, 사용 중인 릴리스에서 제공하는 이름을 사용합니다. 따라서 동일한 스크립트를 이전 버전의 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를 실행합니다. 따라서 링크를 확인할 수 없으면 나중에 원인을 알기 어려운 충돌이 발생하는 대신 스크립트가 중지됩니다.

저장소에 runner 등록

저장소에서 token을 가져옵니다. Settings로 이동한 다음 Actions, Runners, New self-hosted runner를 차례로 엽니다. 페이지에 A로 시작하는 registration token이 표시됩니다. 이 token은 생성 후 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

각 flag의 기능은 다음과 같습니다. --name은 runner가 저장소에 표시되는 이름이므로 6개월 후에도 알아볼 수 있는 이름을 선택합니다. --labels은 사용자 지정 label을 추가합니다. runner에는 별도로 지정하지 않아도 self-hosted, Linux, X64이 이미 포함됩니다. --work는 checkout이 저장될 디렉터리를 지정하며, runner 디렉터리 안에 생성됩니다. --unattended은 대화형 prompt에 기본값으로 응답합니다. 따라서 명령을 script에 넣어 실행할 때 사용합니다. --replace은 같은 이름의 기존 등록을 삭제하고 새 등록으로 대체합니다. 서버를 재구축할 때 사용합니다.

명령이 성공하면 다음 줄로 끝납니다.

√ Runner successfully added
√ Runner connection is good
√ Settings Saved.

이제 runner 디렉터리에 .runner, .credentials, .credentials_rsaparams로 등록 정보가 저장됩니다. 마지막 2개 파일은 이 runner를 GitHub에 식별하는 데 사용되므로, 이 파일을 읽을 수 있는 사용자는 runner를 사칭할 수 있습니다. 따라서 디렉터리 mode는 700이며 해당 사용자는 sudo를 사용할 수 없습니다.

runner를 systemd 서비스로 설치

터미널에서 ./run.sh을 실행하는 방법은 한 번의 테스트에는 적합하지만, SSH 세션이 종료되면 함께 종료됩니다. 부팅 시 runner가 시작되도록 서비스를 설치합니다. VPS의 systemd 서비스와 타이머에서는 unit 파일 자체를 설명합니다. 여기서는 svc.sh이 대신 unit 파일을 작성합니다.

exit
cd /home/gharunner/actions-runner
sudo ./svc.sh install gharunner
sudo ./svc.sh start
sudo ./svc.sh status

svc.sh/etc/systemd/system에 unit 파일을 작성하고 서비스를 활성화하므로 root 권한이 필요합니다. install 뒤의 인수는 서비스가 실행되는 사용자입니다. gharunner을 명시적으로 전달합니다. 인수를 지정하지 않으면 스크립트는 $SUDO_USER로 대체합니다. $SUDO_USER은 관리자 계정이므로 모든 작업이 sudo를 사용할 수 있는 사용자로 실행됩니다.

unit 이름은 다음 형식으로 저장소와 runner의 이름을 조합해 지정됩니다: actions.runner.YOUR-USER-YOUR-REPO.vps-runner-1.service. 이 이름을 직접 입력할 필요는 없습니다.

systemctl list-units 'actions.runner.*'
sudo journalctl -u 'actions.runner.*' -n 20 --no-pager

정상적으로 실행되는 runner는 √ Connected to GitHub을 기록한 다음 Listening for Jobs으로 끝나는 줄을 기록하며, 저장소의 Runners 페이지에는 Idle 상태로 표시됩니다. Offline으로 표시되는 runner는 실행 중이 아니거나 포트 443을 통해 GitHub에 연결할 수 없는 상태입니다.

runner에 작업 보내기

runs-on은 label로 runner를 선택합니다. self-hosted과 자체 label을 함께 지정합니다. 이렇게 하면 의도하지 않은 runner에서 작업이 실행되지 않습니다.

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에서 대기하면 label이 일치하지 않는 것입니다. runs-on의 모든 label이 runner에 존재해야 합니다. 단어 하나가 추가되어도 어디에도 오류가 표시되지 않은 채 작업이 대기열에 남습니다. 저장소 설정에서 runner 옆에 표시된 label과 목록을 비교합니다.

자체 호스팅 러너와 공개 리포지토리를 함께 사용하면 안 되는 이유

이 부분은 많은 사용자가 건너뜁니다. GitHub의 지침은 명확합니다. 자체 호스팅 러너는 “공개 리포지토리에서 거의 사용해서는 안 되며”, “일시적인 깨끗한 가상 머신에서 실행된다는 보장이 없고, 워크플로의 신뢰할 수 없는 코드에 의해 지속적으로 침해될 수 있습니다”.

작동 방식은 간단합니다. 포크에서 생성된 pull request에는 워크플로 파일의 자체 복사본이 포함됩니다. 공개 리포지토리가 러너에서 pull request 워크플로를 실행하면, 리포지토리를 포크할 수 있는 사람은 누구나 자신의 명령을 VPS에서 실행하는 워크플로를 제안할 수 있습니다. 제안하는 내용 자체가 실행되므로 쓰기 권한은 필요하지 않습니다.

승인 설정은 이 문제를 완화할 뿐, 해결하지는 못합니다. 공개 리포지토리의 기본 정책에서는 관리자가 최초 기여자의 포크 워크플로를 승인해야 합니다. 해당 사용자를 한 번 승인하면 이후 해당 사용자의 pull request는 새 승인 요청 없이 실행됩니다. 따라서 보호 장치는 매번 diff를 검토하는 사람입니다. 빌드 스크립트의 세 단계 아래에 숨겨진 페이로드는 쉽게 발견하지 못할 수 있습니다.

포크 pull request에는 사용자의 secrets가 전달되지 않으며, 해당 GITHUB_TOKEN는 읽기 전용입니다. 따라서 GitHub 내부에서 발생할 수 있는 피해는 제한됩니다. 그러나 서버에는 아무런 보호 효과가 없습니다. 공격자는 gharunner 권한으로 셸을 사용할 수 있습니다. 따라서 해당 사용자가 읽을 수 있는 모든 파일을 읽고, VPS가 사설 네트워크에서 접근할 수 있는 모든 대상에 접근할 수 있으며, ~/.bashrc 또는 다음 작업 중 실행되는 사용자 systemd unit에 무언가를 남길 수 있습니다.

--ephemeral로 등록하면 러너는 작업 1개를 수락한 후 등록을 해제합니다. 따라서 한 작업이 다음 작업의 workspace를 읽을 수 없습니다. 하지만 각 작업마다 무언가가 시스템 또는 컨테이너를 다시 빌드하는 경우에만 도움이 됩니다. 러너 사용자의 home directory에 기록된 백도어는 새로 등록해도 유지되기 때문입니다.

다음 규칙은 간단합니다. 자체 호스팅 러너는 private repository에 사용합니다. 공개 리포지토리에 반드시 연결해야 한다면, 포크 pull request를 해당 러너에서 실행하지 않습니다. 해당 서버에는 다른 서비스를 두지 않습니다. 그리고 해당 시스템을 폐기 가능한 시스템으로 취급합니다.

Docker 작업과 사실상 root인 그룹

컨테이너 작업, 서비스 컨테이너 및 docker build을 호출하는 모든 워크플로 단계에는 runner 호스트에 Docker 데몬이 필요합니다. 일반적인 방법으로 Docker를 설치합니다. VPS에서 Docker 및 Docker Compose 설치에서 그 방법을 다룹니다. 그런 다음 runner 사용자를 docker 그룹에 추가합니다.

이 작업을 수행하기 전에 그 영향을 이해해야 합니다. docker 그룹의 구성원은 root와 동일한 권한을 가집니다. 컨테이너에서 /를 bind mount하고 컨테이너 내부에서 root로 실행할 수 있기 때문입니다. 따라서 Docker 소켓에 연결할 수 있는 워크플로는 /etc/shadow을 포함하여 VPS의 모든 파일을 읽고 쓸 수 있습니다. 신뢰할 수 있는 기여자만 있는 private repository에서는 이러한 대가를 허용할 수 있습니다. 그 외의 환경에서는 권한이 없는 사용자를 사용하는 의미가 사라집니다. Rootless Docker를 사용하면 컨테이너 빌드가 runner 사용자의 자체 권한 범위 안에서 실행됩니다. 대신 storage driver가 느려지고 privileged container를 사용할 수 없습니다.

업데이트 및 runner를 올바르게 제거하기

self-hosted runner는 기본적으로 자동으로 업데이트됩니다. 새 release를 감지하면 자체 파일을 교체하고 service를 다시 시작하므로 일반적으로 별도의 작업이 필요하지 않습니다. 고정된 version이 필요하면 ./config.sh --disableupdate를 사용하여 자동 업데이트를 끕니다. 이후 업데이트는 직접 수행해야 합니다. GitHub 문서에는 --disableupdate로 구성된 runner는 수동으로 업데이트해야 한다고 명시되어 있습니다.

수동 업데이트를 수행해도 registration은 유지됩니다. .runner.credentials는 tarball에 포함되지 않기 때문입니다. service를 중지하고 새 tarball을 gharunner로 다운로드한 후 checksum을 확인합니다. 그런 다음 tar xzf을 사용하여 동일한 directory에 압축을 풀고 service를 다시 시작합니다.

cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh start

runner를 제거하려면 먼저 service를 uninstall한 다음 deregister합니다. removal token은 동일한 Runners page에서 runner의 Remove button을 통해 가져옵니다.

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

deregister하지 않고 directory만 삭제하면 repository에서 runner가 Offline 상태로 계속 표시됩니다. GitHub는 runner가 직접 제거 사실을 알리거나 관리자가 수동으로 entry를 삭제해야만 runner가 사라졌음을 알 수 있기 때문입니다.

표시되는 문자열에 따른 실패 원인

Must not run with sudo. config.sh는 root로 실행하면 이 메시지를 출력하고 종료합니다. 이 검사는 의도적으로 수행됩니다. _work의 root 소유 파일이 서비스 사용자로 실행되는 이후의 모든 작업을 중단시키기 때문입니다. ./config.shgharunner로 실행합니다. RUNNER_ALLOW_RUNASROOT 변수는 이 검사를 무시하지만, 이를 사용하면 문제가 나중에 발생할 뿐입니다.

sudo: ./svc.sh: command not found. 올바른 디렉터리에 있습니다. svc.sh는 아직 존재하지 않습니다. config.sh가 등록을 완료하지 않았기 때문입니다. runner를 등록한 다음 서비스를 설치합니다.

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. root로 runner 디렉터리에서 sudo ./bin/installdependencies.sh을 실행한 다음 다시 등록합니다.

재부팅 후 Runner Offline. systemctl is-enabled 'actions.runner.*'을 실행합니다. 아무 항목도 표시되지 않으면 ./svc.sh install이 실행된 적이 없는 것입니다. 따라서 runner는 터미널 세션 내부에서만 존재했습니다. unit이 활성화되어 있는데도 runner가 계속 Offline이면 journalctl -u 'actions.runner.*'을 확인하고 외부 HTTPS 연결을 점검합니다.

디스크가 가득 참. 체크아웃, 빌드 캐시 및 Docker 이미지가 _work와 runner 사용자의 홈 디렉터리에 누적되며, 이를 자동으로 정리하는 작업은 없습니다. du -sh /home/gharunner/actions-runner/_work을 모니터링하고 디스크가 가득 차기 전에 예약된 정리 작업을 추가합니다.

FAQ

sudo ./svc.sh install에서 command not found가 표시되는 이유는 무엇입니까?

svc.sh가 runner tarball에 포함되어 있지 않기 때문입니다. ./config.sh가 등록을 완료하면 runner 디렉터리에 이 파일이 생성됩니다. 이때 repository 이름과 runner 이름을 사용해 service 이름을 만듭니다. 먼저 runner 사용자로 ./config.sh을 실행합니다. 그러면 sudo ./svc.sh install gharunner이 script를 찾아 /etc/systemd/systemactions.runner.OWNER-REPO.RUNNER-NAME.service라는 이름의 unit을 기록합니다.

self-hosted runner에 방화벽 port를 열어야 합니까?

아니요. runner는 GitHub에 outbound HTTPS connection을 열고 job을 기다리는 동안 연결을 유지합니다. 따라서 GitHub가 VPS에 connection을 시작하지 않습니다. outbound 443을 허용하고 inbound rule은 닫힌 상태로 유지합니다. service가 실행 중인데 runner가 Offline으로 표시되면 inbound rule이 아니라 outbound filtering과 DNS를 확인합니다.

public repository에서 self-hosted runner를 사용할 수 있습니까?

사용할 수 있지만 GitHub는 권장하지 않습니다. fork에서 생성된 pull request에는 자체 workflow file이 포함됩니다. 따라서 repository를 fork할 수 있는 사람은 사용자의 machine에서 실행될 command를 제안할 수 있습니다. 승인 prompt는 contributor의 첫 실행에만 적용됩니다. public repository에 runner를 연결하는 경우 fork pull request workflow를 비활성화하고, 해당 server에 다른 항목을 두지 않으며, 일정에 따라 machine을 재구축합니다.

Http response code: NotFound과 함께 registration이 실패하는 이유는 무엇입니까?

credential이 잘못된 경우에도 registration call은 URL이 잘못된 경우와 마찬가지로 NotFound를 반환합니다. 따라서 이 message가 오해를 일으킬 수 있습니다. registration token은 표시된 후 1시간이 지나면 만료됩니다. personal access token은 이 call에 사용할 수 없습니다. Settings, Actions, Runners, New self-hosted runner를 다시 열고 새 token을 복사합니다. 또한 --url 값이 사용자가 admin 권한을 가진 repository를 가리키는지 확인합니다.

#github-actions#ci#self-hosted#runner#ubuntu-24-04