본문 바로가기
DevOps, SRE

망분리 환경에서 OTEL agent를 어떻게 넣을까 - 사업장 폐쇄망까지 고려한 빌드 시 bake-in side image

by Ramos 2026. 6. 16.

내가 맡은 플랫폼의 백엔드 서비스(이하 플랫폼 백엔드)에 관측성을 붙이면서 마지막까지 손이 멈췄던 지점은 "트레이스를 어떻게 보낼까"가 아니라 "agent jar를 어디서 어떻게 가져올까"였다. 이 플랫폼 백엔드는 소스 관리망과 배포망이 분리된 망분리 환경에서 돌고, 최종적으로는 인터넷이 닿지 않는 사업장 폐쇄망까지 납품된다. 라이브러리는 Nexus, 이미지는 Harbor를 통해서만 들어온다. 플랫폼에는 이미 고객사 워크로드가 공용으로 쓰라고 준비된 OTEL agent가 있었다. 그냥 그걸 써도 됐는데, 나는 그러지 않았고 agent jar를 빌드 시점에 별도 side image로 구워 넣는 쪽을 택했다. 이 글은 그 선택의 배경과 구조에 대한 기록이다.

Intro

  • 플랫폼 백엔드는 우리 플랫폼이 고객사에 제공하는 공용 CI/CD(Tekton 기반)를 타지 않는다. 플랫폼은 고객사 소스코드를 dev/prd/stg/edu 같은 클러스터에 배포해주는 도구고, 플랫폼 백엔드 자신은 거기에 배포되지 않는다.
  • 플랫폼 백엔드는 별도 라인을 쓴다. 소스는 회사 내부망 GitHub Enterprise(GHE)에서 관리되고, 망분리 연계를 위해 Helm chart + GitHub Actions + ArgoCD 로 배포된다. (ccp-chart 레포가 서비스별 Helm chart와 CD 워크플로를 보유)
  • 환경은 alpha / beta(둘 다 NKS Public 위 사내 환경) / prod(사업장 폐쇄망) 로 나뉜다. 각 환경은 common·dev 클러스터로 구성되고 prod에는 prd/stg/edu 클러스터가 더 붙는다. dev/prd/stg/edu는 고객사 워크로드가 배포되는 클러스터고, 플랫폼 백엔드는 각 환경의 common cluster에 배포된다.
  • 외부 인터넷을 직접 못 친다(특히 prod는 완전 폐쇄망). 라이브러리는 Nexus, 이미지는 Harbor에 사전 패킹되고, 고객사 워크로드가 쓰라고 OTEL agent도 Nexus에 공용으로 올라가 있다. 플랫폼 백엔드도 이걸 받아 쓸 수는 있었다.
  • 하지만 나는 부팅마다 Nexus를 왕복하는 의존을 피하려고, agent jar를 빌드 시 1회만 Nexus에서 받아 별도 side image에 bake-in하고 런타임에는 initContainer가 image 안의 jar를 로컬 cp만 하게 구성했다.
  • 효과는 둘이다.
    • 사업장 주소 변경 흡수 - BE 앱 이미지는 전 사업장 공통, agent 이미지 경로만 사업장별로 교체.
    • 폐쇄망 런타임 지연 제거 - 부팅 시 Nexus fetch 자체가 없어 콜드스타트 왕복/단일 장애점이 사라진다.

먼저 오해부터 푼다 - 플랫폼 백엔드는 플랫폼의 CI/CD를 타지 않는다

이 글을 정확히 쓰려면 헷갈리기 쉬운 구분 하나를 먼저 분명히 해둬야 한다. 우리 환경에는 CI/CD가 두 종류 있다.

  • 플랫폼이 고객사에 제공하는 CI/CD: 각 환경의 common cluster에 올린 Tekton/Harbor/Nexus/ArgoCD 묶음이다. 이건 고객사의 소스코드를 같은 환경의 dev/prd/stg/edu 클러스터에 빌드·배포해주는 제품이다. dev/prd/stg/edu는 "고객사 코드가 어느 클러스터에 배포되느냐"를 가리키는 이름이고, 플랫폼 백엔드는 이 고객사용 클러스터가 아니라 common cluster 자체에 산다.
  • 플랫폼 백엔드 자신의 CI/CD: 플랫폼 백엔드는 플랫폼을 공급하는 쪽이라 별도 라인을 탄다. 소스는 회사 내부망 GHE에서 관리되고, 배포는 Helm chart + GitHub Actions + ArgoCD로 이뤄진다.

처음 이 구조를 설명하면 "그럼 플랫폼 백엔드도 Tekton으로 빌드되겠네"라고 자연스럽게 넘겨짚게 되는데, 아니다. 같은 common cluster 위에 도구가 함께 살아 있을 뿐, 플랫폼 백엔드의 배포 경로는 플랫폼 제품 라인과 완전히 분리돼 있다. 이 구분을 깔아두지 않으면 뒤에 나올 "OTEL agent를 공용 Nexus에서 쓸까 말까"의 선택지가 엉뚱하게 읽힌다.

물리 배치 - 소스망과 배포망이 갈라져 있다

먼저 환경 구조부터. 플랫폼 백엔드가 사는 환경은 세 갈래이고, 각 환경은 여러 클러스터로 쪼개져 있다.

  • alpha·beta: NHN Cloud Kubernetes Service(NKS) Public 위에 올린 사내 환경이다. 각각 common·dev 클러스터를 가진다. beta는 alpha과 완전히 별개로 구성된 환경이다.
  • prod: 사업장의 폐쇄망이다. common·dev에 더해 prd/stg/edu 같은 고객사 워크로드 클러스터가 함께 들어간다.
  • 어느 환경이든 플랫폼 백엔드는 그 환경의 common cluster에 배포된다. dev/prd/stg/edu는 고객사 코드의 몫이다.

플랫폼 백엔드 라인의 진짜 까다로운 점은 망분리다. 소스코드는 회사 내부망 GHE에 있고, 실제 배포 대상 인프라(Gitea/Harbor/Nexus/ArgoCD)는 각 환경의 common cluster 안에 있다. 두 망을 잇는 다리가 CD 파이프라인이다.

흐름을 말로 풀면 이렇다.

  1. BE 이미지가 새로 빌드되면, ccp-chart 레포의 GitHub Actions chart-cd 워크플로가 해당 환경 values 파일의 global.image.tag를 자동 갱신한다.
  2. 같은 워크플로가 chart를 사내 배포망의 Gitea mirror 레포로 동기화한다. (소스망과 배포망 사이를 넘는 지점이 여기다. runner는 self-hosted)
  3. ArgoCD가 Gitea의 chart를 감시하다가 변경을 그 환경의 common cluster에 sync한다. 이 GHE → runner → ArgoCD 자동 흐름은 사내 환경(alpha·beta)에 한정된다.
  4. 각 환경의 노드는 배포 시 Harbor에서 이미지를 pull하고, 필요한 라이브러리는 Nexus에서 받는다. 둘 다 환경 안의 미러다.

환경 간 이동은 승격(promotion) 형태다. alpha에서 CI 태그로 검증한 이미지를 beta에서 릴리즈 태그(시멘틱 버저닝)로 고정하고, prod는 그 고정 버전을 사업장 Harbor에 사전 적재한 뒤 설치/배포한다. prod는 GHE와 연계되지 않는다. 사내에서 찍은 버전이 사업장으로 단방향으로 건너가 설치되는 구조다.

여기서 핵심은 두 가지다. 하나는 모든 외부 의존이 Nexus/Harbor로 사전 패킹된다는 점이다. 런타임에 외부에서 뭘 받아온다는 가정 자체가 성립하지 않는다. 다른 하나는 환경마다 Harbor/Nexus 주소가 다르다는 점이다. alpha은 사내 Harbor, beta는 또 별도 Harbor, prod는 사업장 전용 Harbor다. 사업장으로 나가면 Nexus/Harbor 주소가 통째로 바뀐다. 이 "주소가 환경·사업장별로 바뀐다"는 사실이 뒤의 설계 결정에서 한 축이 된다.

환경 기반 클러스터 구성 이미지 태그 CD 주체
alpha NKS Public (사내) common, dev CI 태그 개발팀 (GHE Actions → ArgoCD)
beta NKS Public (사내, 별도) common, dev 릴리즈 태그 (SemVer 고정) 개발팀 (Cross-Harbor 복제 → beta ArgoCD)
prod 사업장 폐쇄망 common, dev, prd, stg, edu 릴리즈 고정 버전 납품팀 (Harbor 사전 적재 후 설치, GHE 비연계)

(플랫폼 백엔드는 세 환경 모두 common cluster에 배포된다.)

그래서 OTEL agent를 어디서 가져오나

이제 본론이다. OTel Java agent를 다는 일 자체는 흔하다. opentelemetry-javaagent.jar를 받아 -javaagent: 로 매달면 끝이다. 평범한 클라우드라면 어디서든 받아오면 그만이다.

문제는 우리 환경이 외부 인터넷을 직접 못 친다는 점이다. 사내 환경(alpha·beta)도 외부 접근이 막혀 있고, 사업장(prod)은 아예 완전 폐쇄망이다. 그래서 빌드 노드도 런타임 노드도 외부 아티팩트를 두 문으로만 받는다. 라이브러리는 Nexus, 컨테이너 이미지는 Harbor.

그리고 앞에서 봤듯, 플랫폼에는 이미 고객사 워크로드가 공용으로 쓰라고 OTEL agent가 Nexus에 패킹돼 있었다. 자연스러운 결론은 하나였다.

"공용 agent가 Nexus에 있으니, 플랫폼 백엔드도 부팅할 때 거기서 받아 쓰면 되겠네."

실제로 그렇게 써도 동작은 한다. 그런데 여기서 멈칫했다. 플랫폼 백엔드는 고객사 워크로드와 성격이 다르기 때문이다. 플랫폼 백엔드는 단순 애플리케이션이 아니라 플랫폼 역할을 겸하는 컴포넌트라, 부팅 동작에 거는 기대치가 다르다. 공용 Nexus 경로를 그대로 쓰는 방식, 그러니까 부팅 시 Nexus에서 agent jar를 받는 쪽은 다음 비용을 런타임에 매번 부담한다.

  • 콜드스타트마다 Nexus 왕복. Pod가 재기동될 때마다 jar fetch가 일어난다. 내부 미러라도 네트워크 hop과 지연은 0이 아니다.
  • 런타임 Nexus 의존 = 단일 장애점. Nexus가 잠깐 흔들리면 부팅이 흔들린다. 관측성을 붙이려다 가용성을 깎는 셈이다.
  • 환경·사업장별 주소 변경에 취약. 앞에서 봤듯 환경마다 Nexus 주소가 바뀐다. 부팅 시 fetch 방식이면 그 주소를 런타임 설정으로 끌고 다녀야 한다.

관측성은 있으면 좋은 보조 기능이지, 그것 때문에 본체의 콜드스타트나 가용성이 흔들리면 안 된다. 그래서 나는 fetch 시점을 런타임에서 빌드 타임으로 당기기로 했다.

💡 결과를 원인처럼 설명하지 않기.
"agent를 Nexus에 올려두면 어디서든 받아 쓸 수 있다"는 말은 맞지만, 그건 공급 관점의 장점이다. 소비 관점에서 부팅마다 받아 쓰는 비용은 따로 계산해야 한다. 공급이 편한 것과 소비가 빠른 것은 다른 축이다.

선택한 구조 - 빌드 시 bake-in, 런타임은 로컬 cp

핵심은 한 문장으로 줄어든다. agent jar를 빌드 시점에 단 1회 Nexus에서 받아 별도 side image에 구워 넣고, 런타임에는 그 image 안의 jar를 emptyDir로 복사만 한다.

side image는 일부러 군더더기 없이 짰다. busybox 베이스에 jar 하나만 얹는다.

# otel-javaagent side image (발췌)
FROM busybox:1.36-musl

ARG OTEL_AGENT_VERSION=2.15.0
ARG OTEL_AGENT_URL
ARG OTEL_AGENT_SHA256

# 빌드 시 1회만 폐쇄망 Nexus 미러에서 fetch (URL 은 환경/사업장별 Nexus 주소)
ADD ${OTEL_AGENT_URL} /opentelemetry-javaagent.jar

# SHA256 이 제공되면 무결성 검증
RUN if [ -n "${OTEL_AGENT_SHA256}" ]; then \
        echo "${OTEL_AGENT_SHA256}  /opentelemetry-javaagent.jar" | sha256sum -c - ; \
    fi && chmod 0644 /opentelemetry-javaagent.jar

빌드는 --build-arg OTEL_AGENT_URL=...nexus.../opentelemetry-javaagent-2.15.0.jar 로 환경별 Nexus 주소를 주입해 굽고, 결과 이미지를 그 환경의 Harbor에 push한다.

docker buildx build \
  --platform linux/amd64 --provenance=false --sbom=false \
  --build-arg OTEL_AGENT_VERSION=2.15.0 \
  --build-arg OTEL_AGENT_URL=https://nexus.<site>/<repo>/opentelemetry-javaagent-2.15.0.jar \
  --build-arg OTEL_AGENT_SHA256=<sha256-of-jar> \
  --tag harbor.<site>/infra/otel-javaagent:2.15.0 \
  --push -f Dockerfile .

런타임에서는 Helm 템플릿이 otelAgent.enabled: true 일 때 initContainer를 자동 구성한다. initContainer가 image 안의 jar를 공유 emptyDir로 cp하고, 메인 컨테이너가 그걸 -javaagent로 로드한다.

# values-<env>.yaml (발췌)
otelAgent:
  enabled: true
  image: harbor.<site>/infra/otel-javaagent:2.15.0
  jarPathInImage: /opentelemetry-javaagent.jar
  mountPath: /otel

javaOpts: "-Xms512m -Xmx1536m -javaagent:/otel/opentelemetry-javaagent.jar"

시점별 책임을 표로 정리하면 명확해진다.

시점 동작 외부 의존
빌드 (버전당 1회) Nexus에서 jar 다운로드 → SHA256 검증 → side image bake-in → Harbor push Nexus (1회)
Pod 부팅 initContainer가 image 안의 jar를 emptyDir로 cp, 메인 컨테이너가 -javaagent로 로드 없음 (image pull은 Harbor, 노드 캐시 적중)

부팅 시 발생하는 유일한 "다운로드"는 side image의 image pull(노드 ← Harbor) 뿐이고, kubelet이 레이어를 노드에 캐싱하므로 2회차부터는 거의 즉시다. 런타임에 Nexus는 단 한 번도 호출되지 않는다.

이 구조가 푸는 두 가지 문제

사업장(환경) 주소 변경 흡수

BE 앱 이미지는 전 사업장 공통으로 둔다. 환경·사업장마다 바뀌는 것은 agent side image의 경로(otelAgent.image)뿐이다. 환경이 바뀌어 Nexus/Harbor 주소가 달라져도 BE 앱 이미지를 재빌드할 필요가 없다. values에서 agent 이미지 경로만 갈아끼우면 된다.

실제로 beta로 승격할 때도 agent 이미지를 재빌드하지 않고, alpha의 이미지를 beta Harbor로 Cross-Harbor 복제(skopeo) 만 하면 됐다. 앱과 agent가 분리돼 있으니 가능한 운영이다.

폐쇄망 런타임 지연 제거

부팅 시 다운로드 자체를 없앴기 때문에 폐쇄망 지연 문제가 사라진다.

  • Nexus 접근은 빌드 1회뿐이라 런타임은 Nexus 가용성/지연에 무의존이다.
  • Pod 재기동마다 반복되던 HTTP fetch가 없으니 콜드스타트 지연이 제거된다.
  • jar가 Harbor 레이어와 노드 image 캐시에 이미 존재해, 2회차부터 거의 즉시 뜬다.

반대로 했다면 (반례 대비)

만약 진짜로 "BE가 부팅 시 Nexus에서 agent를 받는" 방식이었다면, 폐쇄망에서 오히려 더 나빠졌을 구조다. 매 콜드스타트마다 Nexus 왕복이 붙고, 런타임이 Nexus에 의존하며, 그 Nexus가 단일 장애점이 된다. 지금 설계(빌드 시 bake)는 이 셋을 의도적으로 회피한 결과다.

표현을 정정해두면, 이 구조는 "부팅 시 다운로드"가 아니라 "빌드 시 1회 bake-in, 런타임은 로컬 cp, Nexus 무의존" 으로 말해야 정확하다.

환경별 토글은 그대로 살아 있다

bake-in 구조라고 해서 환경별 on/off가 굳어지는 건 아니다. Tracing 스택 유무에 따라 환경별로 다르게 켠다.

환경 otelAgent traces logs 메모
alpha enabled otlp otlp 현행 기준
beta enabled otlp none 2026-06 Collector/Jaeger 구축으로 복원, 로그는 별도 트랙
prod (사업장) disabled (placeholder) off off Tracing 스택 부재, 사업장 구축 시 toggle

prod는 otelAgent.enabled: false + OTEL_* 주석으로 toggle 준비만 해 둔다. 사업장에 Jaeger가 구축되면

  • 주석 해제
  • -javaagent 추가
  • enabled: true + 이미지 경로 교체 순으로 켜면 된다.
    메트릭은 어느 환경이든 BE가 직접 보내지 않는다(OTEL_METRICS_EXPORTER: none). RED는 Collector의 spanmetrics가 trace에서 파생한다.

트레이드오프와 보강 로드맵

완벽한 선택은 아니다. 정직하게 비용도 적어둔다.

  • 버전 갱신 시 재빌드 필요. agent 버전을 올리려면 side image를 다시 굽고 Harbor에 push해야 한다. 공용 Nexus 경로를 쓰면 Nexus만 갱신하면 되는 것과 대비된다. 다만 agent 버전 업은 분기/반기 주기라 이 비용은 충분히 감수할 만하다. 갱신 절차도 BE 이미지 영향 없이 otelAgent.image 값 교체 + ArgoCD sync로 끝난다.
  • image 관리 포인트 증가. 환경별로 agent 이미지 경로를 values로 관리해야 한다. 대신 그 덕에 앞서 다룬 "BE 앱 이미지 공통화"가 가능해지므로, 관리 포인트가 한 쪽으로 모인다.
  • 공용 자산과의 분기. 플랫폼 공용 OTEL agent와 플랫폼 백엔드 전용 agent 이미지가 따로 존재하게 된다. 버전 정책을 둘 사이에 맞춰두지 않으면 드리프트가 생길 수 있다. → 보강: agent 버전을 명시 build-arg로 pin하고, 가능하면 digest pin으로 재현성을 확보한다.

남은 일은 결국 세 가지로 모인다. alpha에서 빌드-타임 bake 흐름을 깔끔히 검증한 뒤, 사업장(prod) 진입 전에

  • agent 버전 pin 정책,
  • 환경별 otelAgent.image values 매핑,
  • Harbor/Nexus 주소 전환 체크리스트를 미리 정렬
    이 사항들을 처리해준다. 그래야 사업장 납품 때 주소만 바꿔 끼우고 끝낼 수 있다.

마치며

관측성을 붙이는 일은 보통 "무엇을 보낼까"의 문제로 시작하지만, 망분리 환경, 특히 사업장 폐쇄망까지 납품되는 구조에서는 "어디서 어떻게 가져올까"가 그만큼 중요한 설계 변수가 된다. 게다가 플랫폼 백엔드는 플랫폼의 공용 CI/CD를 타는 고객사 워크로드가 아니라, 자체 Helm + GHE Actions + ArgoCD 라인으로 배포되는 플랫폼 컴포넌트다. 그래서 공용 Nexus agent의 공급 편의보다, 부팅 소비 비용을 더 무겁게 봤다. fetch 시점을 런타임에서 빌드 타임으로 한 칸 당겼을 뿐인데, 환경별 주소 변경과 폐쇄망 지연이라는 두 문제가 동시에 풀렸다. 작은 위치 이동이 만든 차이가 꽤 마음에 드는 결과였다.