본문으로 건너뛰기
← Development Log

Development Log

7편. SSH Tunnel을 벗어나기 - Cloudflare Tunnel·Access와 Google SSO

SSH Tunnel 의존을 줄이고 Cloudflare Tunnel, Access와 Google OIDC로 홈서버 관리 화면의 인증 경계를 만든 과정입니다.

  • #Cloudflare
  • #OIDC
  • #Security

문제 Argo CD를 볼 때마다 SSH Tunnel을 먼저 열어야 했고, 운영 도구 접근 과정이 번거로웠다. 선택 홈서버 인바운드 포트를 추가로 열지 않고 Cloudflare Tunnel로 outbound 경로를 만들고, Access로 앞단 인증을 붙인 뒤 Argo CD 자체에는 Google OIDC와 RBAC를 연결했다. 결과 argocd.ddongmy.com에서 Cloudflare Access → Tunnel → Argo CD → Google SSO로 이어지는 관리 접근 경로를 만들었다. 배운 점 운영 도구 공개는 단순히 포트를 여는 문제가 아니라 네트워크 경로, 앞단 인증, 애플리케이션 인증, 권한 부여를 별도 계층으로 설계하는 문제였다.

6편까지 Argo CD Web Terminal을 정상화했지만, Argo CD UI에 들어갈 때마다 Mac에서 SSH 포트포워딩을 먼저 열어야 했다. 기능은 안전했지만 운영 도구를 확인하기 위한 진입 비용이 계속 남아 있었다. 이번 목표는 Argo CD를 인터넷에 포트 오픈해서 직접 노출하는 것이 아니라, Cloudflare Tunnel로 홈서버에서 outbound 연결을 만들고, Cloudflare Access로 사용자 인증을 통과한 요청만 Argo CD에 전달하는 구조로 바꾸는 것이었다. 이후 Argo CD 자체에도 Google OIDC를 붙여 로컬 admin 비밀번호 의존도를 줄였고, 마지막에는 SSO 사용자의 RBAC까지 연결했다.

시작점 — SSH Tunnel은 안전했지만 매번 준비가 필요했다

기존 Argo CD 접근은 외부에 80/443 또는 Argo 전용 포트를 추가로 열지 않고 SSH Tunnel을 이용했다.

plain
Mac
  ↓ SSH
Home Server
  ↓ port-forward
argocd-server

보안 측면에서는 단순하고 이해하기 쉬운 방식이었다. 하지만 Argo CD를 확인할 때마다 SSH 세션을 먼저 만들고 포트포워딩을 유지해야 했다.

plain
Argo CD 확인하고 싶음
        ↓
SSH 접속
        ↓
로컬 포트포워딩
        ↓
localhost 접속
        ↓
Argo CD

Web Terminal까지 붙인 이후에는 Argo CD가 단순 배포 화면이 아니라 실제 운영·진단 진입점이 되었기 때문에, 이 접근 과정을 더 자연스럽게 만들 필요가 생겼다.

어떤 접근 방식을 선택할지 비교했다

후보는 크게 네 가지였다.

방식장점아쉬운 점
SSH Config + Port Forward구성이 단순하고 추가 서비스가 거의 필요 없음접속 전에 SSH 세션이 필요함
Tailscale / WireGuard사설망처럼 여러 내부 서비스에 접근 가능접속 단말에도 클라이언트 구성이 필요함
공개 Ingress + 인증브라우저에서 바로 접근 가능홈서버 인바운드 노출 범위가 커짐
Cloudflare Tunnel + Access브라우저 접근, 별도 인바운드 포트 불필요, 앞단 인증 가능Cloudflare에 DNS·인증 경로를 의존함

이번 목적은 내부 운영 도구 하나를 브라우저에서 편하게 접근하되, 홈서버에 새 포트를 열지 않는 것이었다. 그래서 Cloudflare Tunnel + Access를 선택했다.

Tailscale이 나쁜 선택이라서 제외한 것은 아니다. 여러 서버·SSH·DB까지 사설망처럼 접근하는 목적이라면 Tailscale이 더 자연스러울 수 있다. 이번에는 웹 기반 Argo CD 하나를 안전하게 공개하는 것이 중심이어서 Cloudflare Access의 인증 게이트가 더 직접적인 선택이었다.

최종적으로 만들고 싶었던 경로

핵심은 홈서버 쪽에서 Cloudflare로 연결을 만드는 것이다.

plain
외부 → 홈서버 신규 포트 오픈 ❌
홈서버 cloudflared → Cloudflare outbound 연결 ✅

1. DNS 권한을 Gabia에서 Cloudflare로 옮겼다

ddongmy.com의 등록기관은 그대로 Gabia에 두고, authoritative DNS만 Cloudflare로 위임했다. 기존 서비스가 끊기지 않도록 Cloudflare에 먼저 기존 DNS 레코드를 확인했다.

plain
A  ddongmy.com  → 49.168.156.207
A  www          → 49.168.156.207

두 레코드는 기존 동작을 최대한 그대로 유지하기 위해 일단 DNS only로 두었다. 그 다음 Gabia의 기존 nameserver를 Cloudflare가 발급한 nameserver 두 개로 교체했다. 기존 Gabia NS와 Cloudflare NS를 섞어 추가하지 않은 이유는 하나의 zone에 대해 서로 다른 authoritative DNS가 응답하면 resolver마다 다른 레코드를 받을 수 있기 때문이다.

plain
Registrar
Gabia 유지

Authoritative DNS
Gabia NS → Cloudflare NS로 교체

DNSSEC는 기존에 설정되어 있지 않아 별도의 DS 레코드 정리는 필요하지 않았다.

2. Cloudflare Tunnel은 K3s 안에서 실행하기로 했다

Tunnel을 홈서버 host process로 설치할 수도 있었지만, 현재 운영 단위가 K3s로 이동한 상태였다. 그래서 cloudflared도 Kubernetes Deployment로 관리하기로 했다. 이렇게 하면 Argo CD Service의 변경 가능한 ClusterIP를 직접 적지 않고 Kubernetes Service DNS를 사용할 수 있다.

plain
argocd-server.argocd.svc.cluster.local:80

Tunnel token은 manifest에 평문으로 넣지 않고 Kubernetes Secret에 저장했다.

bash
kubectl create namespace cloudflare
kubectl create secret generic tunnel-token \
  -n cloudflare \
  --from-literal=token='<TUNNEL_TOKEN>'

그리고 cloudflared Deployment에서 Secret을 환경변수로 주입했다.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflare
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --no-autoupdate
            - run
            - --token
            - $(TUNNEL_TOKEN)
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: tunnel-token
                  key: token

3. 첫 실행은 CrashLoopBackOff였다

Deployment를 올린 직후 Pod가 정상 기동하지 않았다.

plain
READY   STATUS
0/1     CrashLoopBackOff

처음 --previous 로그를 보려고 했지만 containerd에서 이전 로그를 가져오지 못했다. 그래서 바로 설정을 고치지 않고 kubectl describe pod로 상태부터 확인했다.

plain
State:      Terminated
Reason:     Error
Exit Code:  255
Restart:    반복

이미지는 정상 pull됐고 Secret 환경변수도 주입된 것으로 표시됐다. 다음으로 현재 container log를 확인했다.

bash
kubectl logs -n cloudflare deployment/cloudflared --tail=100

결정적인 메시지가 나왔다.

plain
Provided Tunnel token is not valid.

즉 Kubernetes, containerd, image pull 문제가 아니라 Tunnel token 값 자체의 문제였다.

4. Docker 명령 전체가 아니라 --token 뒤의 값만 필요했다

Cloudflare Dashboard는 예시로 다음 형태의 Docker 실행 명령을 보여준다.

bash
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJ...

여기서 Kubernetes Secret에 필요한 것은 명령 전체가 아니라 --token** 뒤의 eyJ... 문자열 하나뿐**이다. 잘못 들어간 Secret을 올바른 token으로 교체했다.

bash
kubectl create secret generic tunnel-token \
  -n cloudflare \
  --from-literal=token="$TUNNEL_TOKEN" \
  --dry-run=client -o yaml | kubectl apply -f -

Secret을 수정해도 이미 실행 중인 container의 환경변수가 자동으로 바뀌지는 않으므로 Deployment를 재시작했다.

bash
kubectl rollout restart deployment/cloudflared -n cloudflare

그 뒤 Pod가 1/1 Running으로 올라왔고 Cloudflare Dashboard에서도 Tunnel 연결을 확인할 수 있었다.

이번에도 중요한 것은 CrashLoopBackOff만 보고 manifest를 임의로 바꾸지 않은 것이다. Pod 상태 → image/환경변수 주입 여부 → 실제 application log 순서로 좁히니 원인은 token 하나로 빠르게 수렴했다.

5. argocd.ddongmy.com을 Kubernetes Service로 연결했다

Tunnel의 Published Application에 다음 경로를 연결했다.

plain
Hostname
argocd.ddongmy.com

Service
http://argocd-server.argocd.svc.cluster.local:80

처음에는 origin이 http://인 것이 이상해 보였다. 하지만 외부 사용자의 HTTPS와 K3s 내부 origin 통신은 다른 구간이다.

브라우저에서 Cloudflare까지는 HTTPS이고, Cloudflare Tunnel을 거쳐 K3s 내부 Service로 전달되는 마지막 hop에서 HTTP를 사용한다. 따라서 https://argocd.ddongmy.com으로 접근하면서 origin Service는 http://...:80을 사용할 수 있었다.

6. Tunnel만 열지 않고 Cloudflare Access를 앞에 붙였다

Tunnel이 연결되면 hostname으로 Argo CD까지 도달할 수 있는 경로가 생긴다. 여기서 바로 사용을 끝내지 않고 Access 정책을 먼저 붙였다. Cloudflare Zero Trust에서 Self-hosted application을 만들고 대상 hostname을 다음으로 지정했다.

plain
argocd.ddongmy.com

Access policy는 내 로그인 이메일 하나만 Allow하도록 구성했다.

plain
Internet
  ↓
Cloudflare Access
  ↓ Allow policy 일치?
  ├─ No  → 차단
  └─ Yes → Tunnel → Argo CD

즉 Tunnel은 네트워크 경로, Access는 그 경로 앞의 인증·인가 게이트 역할을 한다.

7. 여기까지 했더니 인증을 두 번 해야 했다

Access를 통과하면 Argo CD 로그인 화면이 정상적으로 나타났다. 하지만 매번 다음 두 인증을 연속으로 해야 했다.

plain
Cloudflare Access 인증
        ↓
Argo CD admin + password
        ↓
Applications

보안상 틀린 구조는 아니지만, 이미 외부 진입 시 사용자 인증을 했는데 Argo CD에서 다시 로컬 admin 비밀번호를 입력해야 하는 것이 불편했다. 그래서 다음 목표를 Argo CD 자체도 Google OIDC로 로그인하도록 바꾸는 것으로 잡았다.

8. Google OAuth는 기존 앱 프로젝트와 분리했다

Google Cloud에는 다른 개인 프로젝트가 이미 있었지만, Argo CD SSO 설정과 OAuth consent 구성을 섞지 않기 위해 별도 프로젝트를 만들었다. OAuth 앱 대상은 개인 Google 계정으로 사용할 것이므로 External로 설정하고, 테스트 사용자에 실제 로그인할 계정을 등록했다. Web Application OAuth Client를 만든 뒤 redirect URI는 Argo CD의 OIDC callback으로 지정했다.

plain
https://argocd.ddongmy.com/auth/callback

Argo CD 공식 문서도 외부 OIDC Provider를 사용할 때 callback을 <ARGOCD_URL>/auth/callback으로 안내한다.

9. Client Secret은 ConfigMap이 아니라 Kubernetes Secret에 저장했다

Google OAuth Client에는 Client ID와 Client Secret이 있다. Client ID는 설정에 노출되어도 되는 식별값이지만 Client Secret은 민감정보다. 따라서 Client Secret은 기존 argocd-secret에 저장했다.

plain
argocd-secret
└─ oidc.google.clientSecret

그리고 argocd-cm에서는 Secret 값을 직접 쓰지 않고 $oidc.google.clientSecret로 참조했다.

yaml
url: https://argocd.ddongmy.com

oidc.config: |
  name: Google
  issuer: https://accounts.google.com
  clientID: <GOOGLE_CLIENT_ID>
  clientSecret: $oidc.google.clientSecret
  requestedScopes: ["openid", "profile", "email"]

설정을 적용한 뒤 argocd-server만 rollout restart했다.

bash
kubectl rollout restart deployment/argocd-server -n argocd

이후 Argo CD 로그인 화면에 Google 로그인 경로가 나타났고 실제 Google 계정 인증까지 성공했다.

10. Google 로그인은 됐는데 Application이 하나도 보이지 않았다

여기서 마지막 문제가 나왔다. Google SSO 로그인 자체는 성공했고 Argo CD UI도 열렸지만, 기존 admin 계정에서는 보이던 Application이 전부 사라진 것처럼 보였다. 처음 보면 다른 Argo CD 인스턴스에 들어온 것처럼 느껴질 수 있다. 하지만 실제로는 인증(Authentication)은 성공했지만 인가(Authorization)가 없는 상태였다.

plain
Google OIDC
  ↓
"누구인지" 확인 ✅ Authentication
  ↓
Argo CD RBAC
  ↓
"무엇을 할 수 있는지" 권한 없음 ❌ Authorization

argocd-rbac-cm을 확인하니 data가 아예 없는 초기 상태였다.

bash
kubectl get configmap argocd-rbac-cm -n argocd -o yaml

따라서 Google 계정의 email claim을 Argo CD role:admin에 명시적으로 매핑했다.

yaml
data:
  policy.csv: |
    g, <MY_GOOGLE_EMAIL>, role:admin
  scopes: '[groups, email]'

적용 후 Google 계정으로 다시 접근하자 기존 Application을 정상적으로 볼 수 있었다.

이번 문제는 로그인이 성공했다 = 권한까지 생겼다가 아니라는 점을 다시 확인하게 했다. OIDC는 사용자 신원을 전달하고, 실제 Argo CD resource 권한은 별도의 RBAC 정책으로 결정된다.

최종 구조

기존과 비교하면 사용 흐름도 달라졌다.

plain
Before
Argo CD 보고 싶음
→ SSH
→ Port Forward
→ localhost
→ admin/password
→ Argo CD

After
Argo CD 보고 싶음
→ https://argocd.ddongmy.com
→ Cloudflare Access
→ Google OIDC
→ Argo CD

보안 경계가 어떻게 바뀌었나

이번 변경은 단순히 URL 하나를 만든 것이 아니다.

이전

  • Argo CD는 외부 DNS에 직접 노출하지 않음
  • SSH가 사실상 첫 번째 보안 경계
  • Argo CD 내부 admin 계정이 두 번째 인증

현재

  • argocd.ddongmy.com이라는 공개 hostname은 존재함
  • 홈서버가 Cloudflare로 outbound Tunnel을 생성
  • Cloudflare Access가 첫 번째 인증·정책 경계
  • Argo CD는 Google OIDC로 사용자 신원을 받음
  • Argo CD RBAC가 실제 Application 권한을 결정 중요한 차이는 공개 hostname이 생겼다고 해서 Argo CD Service 자체를 인터넷에 직접 노출한 것은 아니라는 점이다.

이번 과정에서 배운 것

1. Tunnel과 Access는 같은 기능이 아니다

처음에는 둘을 한 덩어리처럼 생각하기 쉽다.

plain
Tunnel = 내부 서비스까지 가는 네트워크 경로
Access = 그 경로를 사용할 수 있는 사람을 결정하는 정책

Tunnel만 연결하고 Access를 빼면 보안 모델이 완성되지 않는다.

2. Kubernetes 안에서 실행하면 Service DNS를 그대로 활용할 수 있다

cloudflared를 K3s에 넣은 덕분에 mutable ClusterIP 대신 다음처럼 서비스 이름으로 연결할 수 있었다.

plain
argocd-server.argocd.svc.cluster.local

Kubernetes 내부 서비스 디스커버리를 그대로 이용하는 구조가 더 자연스러웠다.

3. Secret은 생성했다고 끝이 아니라, 실제 consumer가 다시 읽어야 한다

Tunnel token을 수정한 뒤 Deployment를 재시작한 이유도 이것이다. 환경변수로 주입된 Secret 값은 기존 container가 자동으로 갱신해서 다시 읽지 않는다.

4. HTTP와 HTTPS는 전체 경로를 구간별로 봐야 한다

argocd-server:80을 보고 처음에는 HTTPS가 아닌 것이 불안했지만, 실제 경로는 다음처럼 TLS 경계가 나뉜다.

plain
Browser --HTTPS--> Cloudflare --Tunnel--> cloudflared --HTTP--> K3s Service

프로토콜 하나만 보지 말고 어느 구간에서 TLS가 종료되고 다음 hop이 어디인지를 봐야 했다.

5. Authentication과 Authorization은 반드시 분리해서 봐야 한다

Google 로그인 성공 뒤 Application이 안 보였던 문제가 가장 명확한 예였다.

plain
OIDC 로그인 성공 ≠ Argo CD 권한 보유

SSO 통합에서 IdP 연결까지만 확인하고 끝내면 실제 서비스 권한 문제를 놓칠 수 있다.

운영 관점에서 남은 개선점

현재는 개인 홈서버라 Google 이메일 하나를 role:admin으로 직접 매핑했다. 목적에는 충분하지만 운영 규모가 커진다면 다음 단계가 있다.

  • role:admin 대신 필요한 Application/Project 범위만 허용하는 custom role
  • 팀 단위 운영이라면 이메일 직접 매핑 대신 IdP group 기반 RBAC 검토
  • Cloudflare Access의 origin token validation을 추가해 Access 우회 경로까지 방어
  • cloudflared replica를 2개 이상으로 늘려 tunnel connector 단일 Pod 의존성 완화
  • cloudflare/cloudflared:latest 대신 명시적인 image tag/digest로 버전 고정

참고 자료

결과적으로 Argo CD 접근은 SSH 포트포워딩을 매번 준비하는 방식에서 ****argocd.ddongmy.com****으로 바로 접근하는 방식으로 바뀌었다. 편의성을 얻으면서도 홈서버에 Argo CD용 인바운드 포트를 추가로 열지 않았고, Cloudflare Access → Google OIDC → Argo CD RBAC로 인증과 권한 경계를 명시적으로 분리할 수 있었다.