Dev log

GitHub Actions 2,000분을 다 쓴 뒤, Synology NAS에 Self-hosted Runner 구축하기

J5040 NAS의 Docker 환경에 GitHub Actions Self-hosted Runner를 설치하고 Docker 및 PostgreSQL CI까지 검증한 과정

GitHub Actions 무료 2,000분을 모두 사용해서, Synology NAS의 Docker 컨테이너 안에 Self-hosted Runner를 만들었다. 최종적으로는 Runner 컨테이너에서 NAS Docker socket을 사용하고, PostgreSQL CI는 services: 대신 docker run으로 직접 띄우는 방식으로 검증했다.

최종 구조

NAS에서 GitHub로 연결하는 RunnerNAS에서 GitHub로 연결하는 Runner
NAS에서 GitHub로 연결하는 Runner좌우로 스크롤하여 전체 보기원본 크게 보기

Runner는 외부 요청을 받는 서버가 아니다. GitHub로 outbound HTTPS 443 연결을 만들고 작업을 받아온다. 그래서 NAS에 별도 포트 매핑이나 공유기 포트포워딩은 필요하지 않았다.

환경

항목값
저장소GitHub 개인 비공개 저장소
NAS CPUIntel Pentium Silver J5040
아키텍처x86_64
컨테이너 OSUbuntu 24.04
GitHub Actions Runner2.336.0
Runner 이름 예시ubuntu-private-runner-v2
전용 라벨 예시private-ci
컨테이너 Docker CLI29.1.3
Synology Docker Engine20.10.3
Synology Docker API최대 1.41

이번 러너는 개인 저장소 단위로 등록했다. A 저장소에 붙인 러너는 A 저장소용이다. 다른 비공개 저장소에도 쓰려면 저장소별 러너 컨테이너를 따로 두는 편이 관리하기 쉽다.

워크플로에서는 전용 라벨로 러너를 지정한다.

runs-on: [self-hosted, private-ci]

최종 컨테이너 설정

최종적으로 중요한 설정은 아래 명령에 모두 들어 있다.

NAS 호스트에서 실행

docker run -d \
  --name github-runner-private-v2 \
  --restart unless-stopped \
  --user runner \
  --group-add "${SOCKET_GID}" \
  --workdir /actions-runner \
  -e DOCKER_API_VERSION=1.41 \
  -v github-runner-private-v2:/actions-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  private-actions-runner:2.336.0-ubuntu24.04 \
  ./run.sh
옵션필요한 이유
--restart unless-stoppedNAS 재시작 후에도 러너를 자동 실행한다.
--user runner러너를 root가 아닌 일반 사용자로 실행한다.
--group-add "${SOCKET_GID}"일반 사용자가 Docker socket에 접근하게 한다.
--workdir /actions-runner러너 실행 위치를 고정한다.
-e DOCKER_API_VERSION=1.41Synology Docker API 버전에 맞춘다.
-v github-runner-private-v2:/actions-runner러너 등록 정보와 업데이트 파일을 영구 저장한다.
-v /var/run/docker.sock:/var/run/docker.sockNAS Docker Engine을 사용한다.
./run.shRunner listener를 실행한다.

설치 순서

1. 볼륨 생성

러너 파일은 컨테이너 안이 아니라 영구 볼륨에 둬야 한다.

docker volume create github-runner-private-v2

2. Docker socket GID 확인

ls -n /var/run/docker.sock
SOCKET_GID=<DOCKER_SOCKET_GID>

이 값을 최종 컨테이너의 --group-add "${SOCKET_GID}"에 넣는다.

3. 설치용 컨테이너 실행

처음부터 --user runner로 실행하면 패키지 설치가 불편하다. 설치 단계에서는 root로 들어갈 수 있는 컨테이너를 따로 만들었다.

docker run -dit \
  --name github-runner-private-v2-setup \
  --workdir /actions-runner \
  -v github-runner-private-v2:/actions-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e DOCKER_API_VERSION=1.41 \
  ubuntu:24.04 \
  bash

4. 패키지와 Docker CLI 설치

아래 명령은 Runner 컨테이너 내부에서 실행한다.

apt-get update
apt-get install -y \
  curl ca-certificates tar gzip sudo git jq iproute2 \
  postgresql-client libicu74 libicu-dev

Ubuntu 24.04에서는 libicu74, libicu-dev가 필요했다. 없으면 Runner 실행 중 다음 오류가 발생할 수 있다.

Libicu's dependencies is missing for Dotnet Core 6.0

Docker socket을 마운트해도 컨테이너 안에 Docker CLI가 없으면 docker 명령을 쓸 수 없다. 그래서 Docker CLI만 설치했다. Docker daemon은 NAS에 있는 것을 사용한다.

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update
apt-get install -y docker-ce-cli
docker version --format '{{.Client.Version}}'

5. runner 사용자 생성

config.sh와 run.sh는 root로 실행하면 안 된다.

useradd -m -s /bin/bash runner
chown -R runner:runner /actions-runner

실행 권한 기준은 이렇게 나눴다.

작업사용자
패키지 설치root
./bin/installdependencies.shroot
./config.shrunner
./run.shrunner

6. Runner 다운로드와 등록

아래 명령은 Runner 컨테이너 내부에서 실행한다.

cd /actions-runner

curl -o actions-runner-linux-x64-2.336.0.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz

tar xzf actions-runner-linux-x64-2.336.0.tar.gz
chown -R runner:runner /actions-runner

./bin/installdependencies.sh

등록은 runner 사용자로 전환해서 진행한다.

su - runner
cd /actions-runner

./config.sh \
  --url https://github.com/<OWNER>/<REPOSITORY> \
  --token <ONE_TIME_RUNNER_TOKEN> \
  --name ubuntu-private-runner-v2 \
  --labels private-ci \
  --work _work \
  --unattended

등록 후 /actions-runner에 .runner, .credentials, _work가 생겼는지 확인한다.

ls -la /actions-runner

7. 이미지 저장 후 최종 실행

이번에는 빠르게 복구하기 위해 설치 컨테이너를 이미지로 저장했다.

docker stop github-runner-private-v2-setup
docker commit github-runner-private-v2-setup private-actions-runner:2.336.0-ubuntu24.04

이후 앞에서 정리한 최종 docker run 명령으로 실행했다. 다만 docker commit은 임시 해결에 가깝다. 나중에는 Dockerfile로 재현 가능하게 만드는 것이 맞다.

실패한 접근

처음에는 Ubuntu 컨테이너 루트에서 러너를 풀고 실행했다.

docker run -dit --name github-runner-private ubuntu:24.04 bash

이후 /actions-runner 볼륨을 연결했지만 비어 있었다. 이유는 러너가 /actions-runner가 아니라 컨테이너 루트 파일시스템에 설치됐기 때문이다.

확인은 이렇게 했다.

docker exec github-runner-private sh -lc 'ps -ef | grep Runner | grep -v grep'
docker exec github-runner-private sh -lc 'ls -la /actions-runner'
docker exec github-runner-private sh -lc 'readlink -f /proc/1/cwd'

실제 실행 파일은 /bin/Runner.Listener, 실행 위치는 /였다. 그래서 기존 러너를 바로 지우지 않고, ubuntu-private-runner-v2를 새로 병렬 구축했다. 새 러너가 online/idle, Docker, PostgreSQL까지 통과하기 전까지 기존 컨테이너는 백업으로 남겨두는 편이 안전했다.

오류별 해결

오류원인해결
curl: command not foundUbuntu 기본 이미지에 curl 없음apt-get install -y curl ca-certificates
Must not run with sudoconfig.sh, run.sh를 root로 실행runner 일반 사용자로 실행
Libicu's dependencies is missing for Dotnet Core 6.0Runner의 .NET 의존성 부족libicu74, libicu-dev 설치
docker: command not found컨테이너 내부에 Docker CLI 없음docker-ce-cli 설치
/var/run/docker.sock: no such file or directoryDocker socket 미마운트-v /var/run/docker.sock:/var/run/docker.sock
client version 1.52 is too new. Maximum supported API version is 1.41Docker CLI와 Synology Docker API 버전 차이DOCKER_API_VERSION=1.41 설정
Container feature is not supported when runner is already running inside containerRunner가 컨테이너 안에서 실행 중이라 services: 사용 불가PostgreSQL을 docker run으로 직접 실행

Docker API 1.41 고정

컨테이너 내부 Docker CLI는 29.1.3이고 기본 API는 1.52였다. 반면 Synology Docker Engine 20.10.3은 API 1.41까지만 지원했다.

그래서 아래 설정이 필요했다.

DOCKER_API_VERSION=1.41

일시 확인은 이렇게 한다.

DOCKER_API_VERSION=1.41 docker version
DOCKER_API_VERSION=1.41 docker ps

영구 설정은 두 가지 방식이 있다.

echo 'DOCKER_API_VERSION=1.41' >> /actions-runner/.env

또는 컨테이너 실행 옵션에 넣는다.

-e DOCKER_API_VERSION=1.41

나는 컨테이너 옵션으로 고정했다. 환경변수는 이미 실행 중인 run.sh에 자동 반영되지 않으므로 설정 후 러너를 재시작해야 한다.

docker restart github-runner-private-v2

PostgreSQL CI 대체

GitHub-hosted runner에서는 보통 services.postgres를 쓴다. 하지만 Runner 자체가 Docker 컨테이너 안에서 실행되면 services:를 그대로 사용할 수 없었다.

대신 workflow step에서 PostgreSQL 17.10 컨테이너를 직접 띄웠다.

jobs:
  smoke:
    runs-on: [self-hosted, private-ci]
    env:
      POSTGRES_DB: app_test
      POSTGRES_USER: <POSTGRES_USER>
      POSTGRES_PASSWORD: <POSTGRES_PASSWORD>

    steps:
      - uses: actions/checkout@v4

      - name: Start PostgreSQL
        shell: bash
        run: |
          set -euo pipefail
          POSTGRES_CONTAINER="private-postgres-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
          echo "POSTGRES_CONTAINER=${POSTGRES_CONTAINER}" >> "$GITHUB_ENV"

          RUNNER_NETWORK="$(docker inspect "$(hostname)" \
            --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}')"

          docker run -d \
            --name "$POSTGRES_CONTAINER" \
            --network "$RUNNER_NETWORK" \
            -e POSTGRES_DB="$POSTGRES_DB" \
            -e POSTGRES_USER="$POSTGRES_USER" \
            -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
            postgres:17.10

      - name: Wait and verify PostgreSQL
        shell: bash
        env:
          PGPASSWORD: ${{ env.POSTGRES_PASSWORD }}
        run: |
          set -euo pipefail
          POSTGRES_HOST="$(docker inspect "$POSTGRES_CONTAINER" \
            --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')"

          for i in {1..30}; do
            if pg_isready -h "$POSTGRES_HOST" -p 5432 -U "$POSTGRES_USER"; then
              break
            fi
            sleep 1
          done

          nc -zv "$POSTGRES_HOST" 5432
          psql -h "$POSTGRES_HOST" -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c 'SELECT 1;'

      - name: Cleanup PostgreSQL
        if: always()
        shell: bash
        run: |
          docker rm -f "$POSTGRES_CONTAINER" || true

컨테이너 이름에는 GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT를 넣었다. 재실행이나 동시 실행 때 이름 충돌을 피하기 위해서다. 정리는 if: always()로 처리했다. 일반 셸 스크립트라면 trap을 써도 된다.

검증 결과

확인한 것은 다음이다.

  • 새 Runner online/idle 확인
  • Linux/X64 확인
  • private-ci 라벨 확인
  • 기본 셸 실행 통과
  • Docker daemon 연결 통과
  • Alpine 컨테이너 실행 통과
  • PostgreSQL 17.10 실행 통과
  • Runner와 PostgreSQL의 Docker 네트워크 연결 통과
  • SELECT 1 통과
  • 임시 PostgreSQL 컨테이너 자동 삭제 확인

임시 컨테이너가 남지 않았는지는 NAS 호스트에서 확인했다.

docker ps -a --filter "name=private-postgres-"

보안 주의

가장 조심해야 할 설정은 Docker socket 마운트다.

-v /var/run/docker.sock:/var/run/docker.sock

이 설정은 Runner 컨테이너가 NAS Docker 전체를 제어할 수 있다는 뜻이다. 사실상 root 수준 권한에 가깝다. 그래서 아래 기준을 지켜야 한다.

  • 신뢰하는 비공개 저장소에서만 사용한다.
  • 검토되지 않은 외부 PR을 자동 실행하지 않는다.
  • NAS 개인 데이터 폴더를 Runner 컨테이너에 마운트하지 않는다.
  • 토큰과 자격 증명을 이미지, Git, 로그에 넣지 않는다.
  • 기존 컨테이너는 새 러너 검증 완료 전까지 백업으로 유지한다.

현재 상태

완료한 것은 NAS Runner 연결과 Docker/PostgreSQL 대체 방식 검증이다.

아직 완료하지 않은 것은 대상 프로젝트 메인 CI 워크플로의 self-hosted 전환이다.

  • 메인 CI의 runs-on을 private-ci로 변경한다.
  • PostgreSQL services:를 수동 docker run 방식으로 교체한다.
  • 실패 시 PostgreSQL 로그를 확인할 수 있게 만든다.

참고 자료