수정
Synology NAS로 Azure DevOps Self-hosted Agent 운영하기
Azure DevOps hosted minutes 소진 문제를 Synology NAS 기반 self-hosted agent로 해결하면서 얻은 비용, 운영, 확장성 관점의 기록입니다.
- Azure Pipelines Agent
- 4.273.0
- Docker
- 24.0.2
- Node.js
- 24.15.0
이 글의 목차
운영 자동화는 잘 돌아갈 때는 잘 보이지 않는다. 하지만 멈추는 순간, 어디에 비용과 실행 권한이 묶여 있었는지가 바로 드러난다.
이번 작업은 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 재시작 이후에도 컨테이너가 자동 복구될 수 있게 하기 위해서다.
무엇을 NAS로 옮긴 것인가
여기서 중요한 범위 정리가 있다.
이 작업은 Azure DevOps 전체를 NAS로 이전한 것이 아니다. Azure Repos, Pull Request, pipeline definition, schedule, branch policy는 그대로 Azure DevOps에 남아 있다. NAS로 옮긴 것은 job을 실제로 실행하는 runner, 즉 Microsoft-hosted agent가 하던 실행 역할이다.
Keep in Azure:
- Azure Repos
- Azure Pipelines definition
- PR and branch policy
- schedule trigger
- Azure Static Web Apps hosting
Move to NAS:
- pipeline job execution
- npm install / build / validation
- article generation runtime
이 차이는 꽤 중요하다. hosted minutes 제한을 피하려면 Azure DevOps를 버릴 필요가 없다. job execution만 self-hosted agent로 돌리면 된다.
따라서 NAS self-hosted agent는 이 프로젝트에서 원래 의도한 대책이 맞다. 목적은 “Azure를 모두 제거하는 것”이 아니라, 반복 실행으로 소모되는 Microsoft-hosted minutes를 내 장비의 runner로 대체하는 것이다.
왜 아침마다 장애처럼 보였나
NAS runner를 붙였다고 모든 운영 문제가 사라지는 것은 아니다. 실제로 이후에도 아침 시간대에 글이 올라오지 않는 문제가 반복됐다. 다만 원인은 hosted minutes와 달랐다.
반복적으로 확인된 원인은 크게 네 가지였다.
| 원인 | 성격 | NAS 대책과의 관계 |
|---|---|---|
| Pipeline queue disabled | Azure DevOps 설정 상태 | NAS runner와 별개 |
| YAML schedule disabled | 코드 설정 상태 | NAS runner와 별개 |
articleGenerationEnabled=false | 운영 플래그 상태 | NAS runner와 별개 |
| Direct Static Web Apps deploy hang | 배포 task 경로 문제 | self-hosted agent 운영 이슈 |
즉 NAS 전환은 hosted minutes 병목을 해결했지만, “스케줄이 꺼져 있는지”, “pipeline이 disabled인지”, “생성은 됐지만 배포가 끝까지 갔는지”까지 자동으로 감시해주지는 않는다.
여기서 운영 방향을 한 번 더 바꿔야 했다. 사람이 아침마다 확인하는 방식이 아니라, 시스템이 스스로 상태를 점검하고 가능한 조치는 자동으로 수행해야 한다.
자동 감시와 자동 복구 설계
내가 원하는 운영 모델은 단순 알림이 아니다.
Detect
-> classify
-> auto-remediate if safe
-> notify result
-> escalate only when human action is required
운영자는 “승인할까요?” 같은 질문을 매번 받을 필요가 없다. 이미 정해진 안전한 조치는 자동으로 수행하고, Slack에는 결과를 남기는 것이 더 낫다.
자동 감시 대상은 아래처럼 나눌 수 있다.
| Category | Check | Automatic action |
|---|---|---|
| Schedule | 오늘 06:00~23:00 KST window가 살아 있는가 | schedule guard 실패 시 PR 생성 또는 pipeline 설정 복구 |
| Pipeline | article-generation queue가 enabled인가 | disabled면 enabled로 복구 |
| Agent | NAS agent가 online인가 | agent container 상태 확인, 필요 시 restart job 실행 |
| Article | 최근 N시간 내 successful generation이 있는가 | 누락된 slot 재실행 |
| Deploy | 생성된 route가 public URL에서 200인가 | CD pipeline 재실행 |
| Cleanup | 종료된 deploy helper container가 쌓였는가 | 안전한 exited helper container 정리 |
| Cost | Azure cost가 예산선을 넘는가 | Slack warning, 비용 발생 변경은 사람 승인 |
자동 복구는 모두 같은 수준으로 처리하면 안 된다. 안전한 조치와 위험한 조치를 분리해야 한다.
Safe automation:
- pipeline queue enable
- missed article generation rerun
- CD pipeline rerun
- exited deployment helper container cleanup
- NAS agent container restart
- Slack status report
Human approval required:
- secret rotation
- PAT 재발급
- DNS 변경
- Terraform apply
- Azure 리소스 신규 생성
- 과금 구조 변경
이렇게 나누면 알림은 줄어들고, 실제로 사람이 봐야 하는 이벤트만 남는다.
Slack 운영 모델
Slack은 두 단계로 쓰는 것이 좋다.
첫 번째는 상태 리포트다. 장애가 있든 없든, 매일 정해진 시간에 현재 운영 상태를 짧게 보고한다.
HWMOON Operations Report
Schedule: OK
Article generation: OK, last success 08:00 KST
Deploy: OK
NAS agent: Online
Public site: 200
Open issues: 0
Auto-remediation: none
문제가 있으면 같은 메시지에 조치 결과를 붙인다.
Issue detected:
article-generation pipeline was disabled
Action:
pipeline queueStatus restored to enabled
Result:
manual verification run started
두 번째는 slash command다. Slack에서 명령을 입력하면 운영자가 Azure Portal이나 NAS에 들어가지 않아도 정해진 조치를 실행할 수 있다.
예상 명령은 아래 정도면 충분하다.
| Command | Purpose |
|---|---|
/hwmoon status | 전체 운영 상태 조회 |
/hwmoon rerun article | 현재 시간대 기사 생성 재실행 |
/hwmoon repair article-pipeline | queue, schedule, generation flag 점검 및 복구 |
/hwmoon redeploy news | news Static Web Apps CD 재실행 |
/hwmoon restart agent | NAS agent restart maintenance job 실행 |
/hwmoon cleanup containers | 종료된 deploy helper container 정리 |
중요한 점은 Slack이 NAS에 직접 접속하지 않는다는 것이다. NAS에 public inbound endpoint를 열면 보안 경계가 커진다.
더 안전한 구조는 이렇다.
Slack slash command
-> small public HTTPS endpoint
-> verify Slack signing secret
-> call Azure DevOps REST API
-> queue maintenance pipeline
-> NAS self-hosted agent executes repair
-> result posted back to Slack
이 구조에서는 NAS가 계속 outbound 방식으로 Azure DevOps에만 연결한다. Slack command는 Azure DevOps pipeline을 큐잉할 뿐이고, 실제 복구 작업은 NAS agent가 수행한다.
초기 구현은 Azure Functions Consumption이나 작은 serverless endpoint면 충분하다. 비용은 매우 낮고, 나중에 사용량이 늘면 별도 운영 API로 분리할 수 있다.
왜 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 정리 루틴 작성
- schedule, queue, agent, deploy 상태를 점검하는 watchdog pipeline 추가
- Slack 운영 리포트와 자동 복구 결과 알림 연결
- Slack slash command로 정해진 복구 작업 실행
- 국가별 발행량이 늘면 second self-hosted agent 추가 검토
- 상업 트래픽이 커지면 cloud fallback runner 설계
지금 구조는 완성형 대기업 인프라라기보다, 작은 서비스가 실제 운영으로 넘어가기 위한 비용 효율적인 발판에 가깝다. 그래도 효과는 분명했다. 자동화가 Azure hosted minutes 한도에서 벗어났고, 앞으로 국가별 콘텐츠 발행이 늘어나도 runner 비용이 바로 선형 증가하지 않는 구조가 생겼다.
내가 이번 작업에서 얻은 결론은 간단하다.
작은 서비스의 초기 자동화는 꼭 cloud resource를 더 붙여야만 해결되는 것은 아니다. 이미 켜져 있는 장비가 있고, 보안 경계를 이해하고, 운영 책임을 감당할 수 있다면 NAS self-hosted agent는 충분히 좋은 runner가 될 수 있다.
시리즈
Publisher Infrastructure
전체 8편 중 2편입니다. 관련 구축 기록을 순서대로 모아 전체 맥락을 쉽게 확인할 수 있습니다.
- 1. Azure DNS와 Zoho Mail로 도메인 이메일 만들기
- 2. Synology NAS로 Azure DevOps Self-hosted Agent 운영하기
- 3. Pipeline은 성공했는데 Static Web Apps가 404를 냈던 이유
- 4. Azure Repos를 운영 원본으로 두고 GitHub를 백업으로 쓰는 전략
- 5. Slack 기반 AIOps로 NAS Agent 운영 자동화하기
- 6. news.hwmoon.com 기사 자동 발행 중단 재발 방지 기록
- 7. news.hwmoon.com AdSense 승인 전 준비와 운영 전환 기록
- 8. news.hwmoon.com 배포 중단 원인과 Agent 자가복구 체계 구축 기록