Synology NAS로 Azure DevOps Self-hosted Agent 운영하기
Azure DevOps hosted minutes 소진 문제를 Synology NAS 기반 self-hosted agent로 해결하면서 얻은 비용, 운영, 확장성 관점의 기록입니다.
운영 자동화는 잘 돌아갈 때는 잘 보이지 않는다. 하지만 멈추는 순간, 어디에 비용과 실행 권한이 묶여 있었는지가 바로 드러난다.
이번 작업은 Azure DevOps의 Microsoft-hosted agent 무료 실행 시간이 소진되면서 시작됐다. 스케줄은 정상적으로 트리거됐지만, job이 실행되기 전에 멈췄다. 원인은 단순했다.
Your organization has no free minutes remaining.
이 메시지는 파이프라인 코드 문제가 아니었다. 기사 생성 스크립트도, 빌드도, 배포도 아직 시작하지 못한 상태였다. 실행할 agent를 배정받지 못했기 때문에 자동 발행 자체가 막힌 것이다.
배경
news.hwmoon.com은 정적 사이트지만, 운영 흐름은 완전히 정적이지 않다. 정해진 시간에 새 글을 만들고, 검증하고, 빌드하고, 배포하는 자동화가 필요하다.
기존 구조는 Azure DevOps pipeline이 Microsoft-hosted Ubuntu agent에서 실행되는 방식이었다.
Azure DevOps schedule
-> Microsoft-hosted agent
-> article generation
-> content validation
-> static build
-> Azure Static Web Apps deploy
이 방식은 시작하기 쉽다. 로컬 서버도 필요 없고, agent 관리도 필요 없다. 하지만 실행 시간이 quota에 묶인다. 개인 프로젝트나 초기 서비스에서는 이 한도가 생각보다 빨리 병목이 된다.
특히 자동 발행처럼 하루 여러 번 반복되는 작업은 사용량이 누적된다. 한국어 사이트 하나만 운영할 때는 버틸 수 있어도, 나중에 us, jp, au, de 같은 국가별 브리핑을 확장하면 실행 횟수는 곧바로 늘어난다.
그래서 runner 비용을 별도 Azure VM으로 밀어내기 전에, 이미 보유하고 있던 Synology NAS를 self-hosted agent로 활용하기로 했다.
공개 문서에서 가린 정보
이 글은 실제 운영 기록을 기반으로 하지만, 아래 정보는 공개하지 않는다.
| 항목 | 공개 여부 | 이유 |
|---|---|---|
| NAS 내부 IP | 비공개 | 내부 네트워크 식별자 |
| SSH 포트 | 비공개 | 운영 접근 정보 |
| SSH 계정명 | 비공개 | 계정 추측 방지 |
| Azure DevOps PAT | 비공개 | secret |
| 실제 host 식별자 | 비공개 | 운영 환경 노출 최소화 |
| 컨테이너 구조 | 공개 | 재현 가능한 기술 구조 |
| 운영 판단 기준 | 공개 | 다른 환경에서도 재사용 가능 |
개인 기술 블로그에서 중요한 것은 모든 값을 공개하는 것이 아니라, 어떤 판단으로 어떤 구조를 선택했는지 설명하는 것이다.
최종 구조
최종 구조는 Azure DevOps의 Default agent pool에 NAS 기반 Docker container를 등록하는 방식이다.
Azure DevOps schedule
-> Agent pool
-> Synology NAS self-hosted agent
-> article generation
-> validation
-> build
-> deploy
NAS 쪽에는 Azure Pipelines agent를 포함한 컨테이너를 하나 띄운다. 이 컨테이너는 Azure DevOps와 outbound HTTPS 연결을 유지하면서 job을 기다린다.
핵심은 NAS로 들어오는 포트를 열 필요가 없다는 점이다. agent가 Azure DevOps로 나가서 연결을 유지하므로, public inbound endpoint를 만들지 않아도 된다.
운영 단위는 아래처럼 잡았다.
Agent container
- Azure Pipelines agent runtime
- Node.js
- Azure CLI
- Git
- build tools
- persistent work directory
컨테이너는 restart unless-stopped로 실행한다. NAS 재부팅이나 Docker 재시작 이후에도 컨테이너가 자동 복구될 수 있게 하기 위해서다.
왜 Azure VM이 아니라 NAS였나
Azure VM을 하나 띄워서 self-hosted agent로 쓰는 방법도 가능하다. 하지만 초기 단계에서는 NAS가 더 합리적이었다.
Azure VM 방식은 다음 비용 요소가 생긴다.
- VM compute
- OS disk
- public IP 또는 네트워크 구성
- monitoring/logging
- backup 또는 snapshot
- 보안 업데이트 운영
작은 VM이라도 24시간 켜두면 비용은 계속 누적된다. 반면 NAS는 이미 켜져 있는 장비였다. 추가 비용은 사실상 기존 전기료와 장비 운영비 안에 흡수된다.
물론 NAS가 무조건 더 좋은 선택은 아니다. 고가용성, 장애 격리, 기업 보안 통제, 지역별 runner 분산이 필요하면 cloud runner가 더 적합할 수 있다. 하지만 개인 서비스, 실험 단계의 publisher automation, 저비용 MVP에는 NAS 기반 self-hosted agent가 꽤 강한 선택지가 된다.
다국가 확장 관점의 비용 효과
이 구조의 장점은 사이트가 하나일 때보다 여러 국가로 확장할 때 더 잘 보인다.
예를 들어 한국어 브리핑만 운영하면 하루 발행 job은 제한적이다. 하지만 국가별 콘텐츠를 분리하면 실행 단위가 늘어난다.
kr article generation
us article generation
jp article generation
au article generation
de article generation
각 국가별로 수집, 생성, 검증, 빌드, 배포가 붙으면 pipeline 실행 시간이 선형적으로 증가한다. Microsoft-hosted minutes를 쓰면 이 증가분이 곧 quota와 비용 압박으로 이어진다.
NAS self-hosted agent는 runner 비용을 고정비에 가깝게 만든다. 작업이 늘어나도 Azure DevOps hosted minutes를 추가로 태우지 않는다. 대신 NAS의 CPU, 메모리, 네트워크, queue 대기 시간이 새로운 한계가 된다.
초기 다국가 확장에서는 이 tradeoff가 나쁘지 않다.
| 확장 단계 | 권장 runner |
|---|---|
| 1개 국가, 실험 단계 | Microsoft-hosted 또는 NAS |
| 1~5개 국가, 저비용 자동화 | NAS self-hosted agent |
| 국가별 고빈도 발행 | NAS + 추가 self-hosted agent |
| 상업용 고가용성 필요 | Cloud VM runner 또는 managed build pool |
즉 NAS는 “영원한 최종 인프라”라기보다, 초기 자동화 비용을 낮추고 구조를 검증하기 좋은 중간 단계다.
실제로 마주친 문제
구축 과정이 깔끔하지만은 않았다. 몇 가지 문제가 있었다.
Docker Compose 플러그인 문제
Synology 환경의 Docker CLI에서 compose plugin이 기대대로 동작하지 않았다.
unknown shorthand flag
compose failed to fetch metadata
그래서 최종 운영 방식은 compose가 아니라 명시적인 docker build와 docker run으로 정했다. UI에서 보이는 Container Manager 상태와 CLI compose 상태가 다를 수 있기 때문에, 운영 명령을 단순화하는 쪽이 낫다고 판단했다.
Root 실행 제한
Azure Pipelines agent는 기본적으로 root 실행을 거부한다. 컨테이너 환경에서는 root로 실행되는 경우가 많기 때문에 별도 허용 플래그가 필요했다.
AGENT_ALLOW_RUNASROOT=1
이 값은 일반 서버에서 무작정 쓰라는 뜻은 아니다. 컨테이너를 agent 전용으로 격리하고, runner가 수행하는 job을 신뢰할 수 있을 때만 선택할 수 있는 방식이다.
중복 agent session
동일한 agent 이름으로 여러 컨테이너를 띄우면 active session 충돌이 발생할 수 있다.
A session for this agent already exists.
이 경우 해결은 복잡하지 않다. 오래된 컨테이너를 정리하고, 운영 대상으로 삼을 컨테이너 하나만 남긴다. self-hosted agent는 이름과 세션이 운영 단위이므로, 중복 실행을 피해야 한다.
일회성 배포 컨테이너
Static Web Apps 배포 과정에서 일회성 컨테이너가 남을 수 있다. 종료 코드가 0이면 대체로 실패가 아니라 정상 종료된 작업 흔적이다.
다만 Container Manager 화면에서는 지저분해 보일 수 있으므로 운영 컨테이너와 일회성 컨테이너를 구분해야 한다.
keep:
- Azure Pipelines agent
- WordPress
- MySQL
- reverse proxy
cleanup candidates:
- exited deployment helper containers
보안 관점
self-hosted agent는 편리하지만 권한이 강하다.
특히 Docker socket을 컨테이너에 mount하면 컨테이너가 host Docker를 제어할 수 있다. 이 구조는 빌드와 배포에 유용하지만, 신뢰할 수 없는 코드를 실행하는 runner에는 맞지 않는다.
현재 기준의 운영 원칙은 아래다.
- PAT는
.env또는 secret store에만 둔다. - PAT는 문서와 git에 남기지 않는다.
- agent container는 신뢰된 pipeline 전용으로 유지한다.
- 외부 inbound port는 열지 않는다.
- SSH key는 로컬에 두고 NAS에는 public key만 등록한다.
- 동일 agent 이름의 중복 컨테이너를 만들지 않는다.
이 정도면 개인 프로젝트의 self-hosted runner로는 현실적인 균형점이다.
운영 성과
전환 후 확인한 가장 중요한 로그는 아래 흐름이다.
Successfully added the agent
Settings Saved
Listening for Jobs
Running job
이 의미는 명확하다.
- NAS agent가 Azure DevOps에 등록됐다.
- agent pool에서 job을 받을 수 있다.
- hosted minutes 없이 scheduled pipeline을 실행할 수 있다.
- 기사 자동 발행 장애의 직접 원인이 제거됐다.
운영 성과를 정리하면 아래와 같다.
| 항목 | 결과 |
|---|---|
| hosted minutes 장애 | 우회 완료 |
| scheduled job 수신 | 확인 |
| NAS Docker agent | 정상 실행 |
| 추가 Azure VM 비용 | 없음 |
| 다국가 확장 대비 | runner 비용 증가를 완화 |
| 남은 리스크 | NAS 단일 장애점, PAT 만료, 컨테이너 중복 실행 |
앞으로의 개선
다음 단계는 자동화 자체보다 운영 안정성이다.
우선순위는 아래 순서로 본다.
- NAS 재부팅 후 agent 자동 복구 확인
- PAT 만료일 캘린더 등록
- 오래된 deployment helper container 정리 루틴 작성
- pipeline 실패 알림을 Slack 또는 이메일로 연결
- 국가별 발행량이 늘면 second self-hosted agent 추가 검토
- 상업 트래픽이 커지면 cloud fallback runner 설계
지금 구조는 완성형 대기업 인프라라기보다, 작은 서비스가 실제 운영으로 넘어가기 위한 비용 효율적인 발판에 가깝다. 그래도 효과는 분명했다. 자동화가 Azure hosted minutes 한도에서 벗어났고, 앞으로 국가별 콘텐츠 발행이 늘어나도 runner 비용이 바로 선형 증가하지 않는 구조가 생겼다.
내가 이번 작업에서 얻은 결론은 간단하다.
작은 서비스의 초기 자동화는 꼭 cloud resource를 더 붙여야만 해결되는 것은 아니다. 이미 켜져 있는 장비가 있고, 보안 경계를 이해하고, 운영 책임을 감당할 수 있다면 NAS self-hosted agent는 충분히 좋은 runner가 될 수 있다.