Automation 11 min read 중급

수정

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
Server room hardware representing a self-hosted CI agent running on local infrastructure
자료 이미지: Photo by Albert Stoynov on Unsplash
이 글의 목차
  1. 배경
  2. 공개 문서에서 가린 정보
  3. 최종 구조
  4. 무엇을 NAS로 옮긴 것인가
  5. 왜 아침마다 장애처럼 보였나
  6. 자동 감시와 자동 복구 설계
  7. Slack 운영 모델
  8. 왜 Azure VM이 아니라 NAS였나
  9. 다국가 확장 관점의 비용 효과
  10. 실제로 마주친 문제
  11. Docker Compose 플러그인 문제
  12. Root 실행 제한
  13. 중복 agent session
  14. 일회성 배포 컨테이너
  15. 보안 관점
  16. 운영 성과
  17. 앞으로의 개선

운영 자동화는 잘 돌아갈 때는 잘 보이지 않는다. 하지만 멈추는 순간, 어디에 비용과 실행 권한이 묶여 있었는지가 바로 드러난다.

이번 작업은 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 disabledAzure 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에는 결과를 남기는 것이 더 낫다.

자동 감시 대상은 아래처럼 나눌 수 있다.

CategoryCheckAutomatic action
Schedule오늘 06:00~23:00 KST window가 살아 있는가schedule guard 실패 시 PR 생성 또는 pipeline 설정 복구
Pipelinearticle-generation queue가 enabled인가disabled면 enabled로 복구
AgentNAS 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 정리
CostAzure 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에 들어가지 않아도 정해진 조치를 실행할 수 있다.

예상 명령은 아래 정도면 충분하다.

CommandPurpose
/hwmoon status전체 운영 상태 조회
/hwmoon rerun article현재 시간대 기사 생성 재실행
/hwmoon repair article-pipelinequeue, schedule, generation flag 점검 및 복구
/hwmoon redeploy newsnews Static Web Apps CD 재실행
/hwmoon restart agentNAS 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

이 의미는 명확하다.

  1. NAS agent가 Azure DevOps에 등록됐다.
  2. agent pool에서 job을 받을 수 있다.
  3. hosted minutes 없이 scheduled pipeline을 실행할 수 있다.
  4. 기사 자동 발행 장애의 직접 원인이 제거됐다.

운영 성과를 정리하면 아래와 같다.

항목결과
hosted minutes 장애우회 완료
scheduled job 수신확인
NAS Docker agent정상 실행
추가 Azure VM 비용없음
다국가 확장 대비runner 비용 증가를 완화
남은 리스크NAS 단일 장애점, PAT 만료, 컨테이너 중복 실행

앞으로의 개선

다음 단계는 자동화 자체보다 운영 안정성이다. 특히 아침 발행 시간대에 사람이 직접 확인하지 않아도 되는 구조가 필요하다.

우선순위는 아래 순서로 본다.

  1. NAS 재부팅 후 agent 자동 복구 확인
  2. PAT 만료일 캘린더 등록
  3. 오래된 deployment helper container 정리 루틴 작성
  4. schedule, queue, agent, deploy 상태를 점검하는 watchdog pipeline 추가
  5. Slack 운영 리포트와 자동 복구 결과 알림 연결
  6. Slack slash command로 정해진 복구 작업 실행
  7. 국가별 발행량이 늘면 second self-hosted agent 추가 검토
  8. 상업 트래픽이 커지면 cloud fallback runner 설계

지금 구조는 완성형 대기업 인프라라기보다, 작은 서비스가 실제 운영으로 넘어가기 위한 비용 효율적인 발판에 가깝다. 그래도 효과는 분명했다. 자동화가 Azure hosted minutes 한도에서 벗어났고, 앞으로 국가별 콘텐츠 발행이 늘어나도 runner 비용이 바로 선형 증가하지 않는 구조가 생겼다.

내가 이번 작업에서 얻은 결론은 간단하다.

작은 서비스의 초기 자동화는 꼭 cloud resource를 더 붙여야만 해결되는 것은 아니다. 이미 켜져 있는 장비가 있고, 보안 경계를 이해하고, 운영 책임을 감당할 수 있다면 NAS self-hosted agent는 충분히 좋은 runner가 될 수 있다.

시리즈

Publisher Infrastructure

전체 8편 중 2편입니다. 관련 구축 기록을 순서대로 모아 전체 맥락을 쉽게 확인할 수 있습니다.

  1. 1. Azure DNS와 Zoho Mail로 도메인 이메일 만들기
  2. 2. Synology NAS로 Azure DevOps Self-hosted Agent 운영하기
  3. 3. Pipeline은 성공했는데 Static Web Apps가 404를 냈던 이유
  4. 4. Azure Repos를 운영 원본으로 두고 GitHub를 백업으로 쓰는 전략
  5. 5. Slack 기반 AIOps로 NAS Agent 운영 자동화하기
  6. 6. news.hwmoon.com 기사 자동 발행 중단 재발 방지 기록
  7. 7. news.hwmoon.com AdSense 승인 전 준비와 운영 전환 기록
  8. 8. news.hwmoon.com 배포 중단 원인과 Agent 자가복구 체계 구축 기록

관련 글