안녕하세요. LINE VOOM 서버 개발자 박찬우입니다. 최근 신규 프로젝트를 진행하면서 각 하부 모듈 인증을 공용화할 방법을 고민하게 됐습니다. 해당 프로젝트는 크게 두 가지, 세부적으로는 10개가 넘는 하부 모듈로 구성돼 있었는데요. 일부 모듈은 오픈소스를 활용했기 때문에 인증을 소스 코드 수준에서 일괄 적용하기 어려웠고, 유지 보수 비용도 과다하게 발생할 것으로 예상됐습니다. 고민 끝에 내린 결론은 사이드카 패턴을 이용한 인증 프록시를 별도로 개발해 적용하는 것이었습니다.
이번 글에서는 서비스 인증을 사이드카 프록시(Sidecar proxy)로 구현한 사례를 공유하려고 합니다. 이 글이 프로젝트 유지 보수 비용을 절감할 수 있는 한 방편으로 활용되기를 바라며 글을 시작하겠습니다.
참고. 이번 글에서 말씀드리는 내용은 프로젝트가 쿠버네티스 환경에서 작동한다고 전제합니다.
사이드카 프록시란
먼저 사이드카 프록시가 무엇인지 살펴보겠습니다.
사이드카 패턴
사이드카 패턴이 무엇인지는 이름에서 쉽게 유추할 수 있습니다. 오토바이에 부착된 사이드카를 연상하셨다면 이미 사이드카 패턴의 핵심을 이해한 것과 다름없습니다. 오토바이 사이드카에 승선한 사람은 주 운전자와 동력을 공유하며 주 운전자에게 여러 가지 도움을 줄 수 있습니다. 오토바이가 아닌 개발 세계에서는 사이드카가 인증이나 로깅, 설정값 등을 애플리케이션에 제공할 수 있습니다. 이때 사이드카는 애플리케이션과는 전혀 다른 언어로 작성해도 상관없습니다. 엄연히 별도 애플리케이션으로 작동하기 때문입니다.
사이드카 프록시
쿠버네티스 환경에서의 사이드카는 특히 파드(pod)와 컨테이너 관계에 주목해야 합니다. 동일한 파드의 모든 컨테이너는 동일한 가상 네트워크 장치를 공유하므로 네트워크를 통해 서로 안전하게 상호 작용할 수 있습니다. 이런 특성을 이용해서 사이드카 프록시만 외부에 노출하고 프록시와 애플리케이션 사이는 보안 네트워크로 연결할 수 있습니다.
여기까지 읽으셨다면 Nginx와 같은 리버스 프록시를 떠올리고 비교하실 겁니다. 만약 쿠버네티스가 아닌 환경이라면 Nginx와 같은 리버스 프록시를 통해 애플리케이션을 캡슐화하고 해당 애플리케이션에 대한 인증을 제공할 수도 있습니다. 하지만 쿠버네티스 환경에서 레플리카셋(ReplicaSet)이나 디플로이먼트(Deployment)로 배포한 경우 파드가 속한 노드가 지속해서 바뀔 수 있으며, 이때 네트워크 정보도 모두 바뀌게 됩니다. 스테이트풀셋(StatefulSets)으로 배포한다면 이런 염려가 없겠지만, HPA(Horizontal Pod Autoscale)나 확장/가용성이 필요한 일반적인 애플리케이션의 배포 형태는 레플리카셋이나 디플로이먼트입니다.
Nginx의 연결 지점을 로드밸런서나 쿠버네티스의 서비스 객체로 지정해 트래픽을 중계하는 것을 고려할 수도 있겠지만, 그렇게 하면 네트워크 홉(hop)이 추가되거나 범위가 아닌 개별 파드별 지정이 어려우며 무엇보다 필요한 인증 로직을 구현해서 넣을 곳이 마땅치 않습니다. 결론적으로 쿠버네티스 환경에서 기존의 리버스 프록시만으로 인증을 적용하는 것은 어렵습니다.
사이드카 프록시 구현
해당 프로젝트는 외부에 존재하는 인증 플랫폼과 통합해야 했으며, 외부 인증 플랫폼은 비대칭키 방식을 채택하고 있었습니다. 개인키로 암호화한 정보를 매 요청마다 전송했으므로 인증 사이드카는 공개키를 사용해 이 정보를 복호화하고 검증해야 했습니다.
이를 실제로 어떻게 구현했는지 소스 코드와 함께 설명하겠습니다. 우선 가장 핵심 역할을 하는 프록시는 Spring Boot로 제작했습니다. Node.js를 쓸지 Spring Boot를 쓸지 고심하다 조금 더 손에 익숙한 Spring Boot를 골랐습니다. 프록시는 크게 두 부분으로 나눠 개발했습니다. Rest API 요청을 받아 경로와 헤더 정보를 수정하는 ProxyController와 인증을 처리하는 AuthenticationFilter입니다.
ProxyController
ProxyController에서 핵심은 ProxyExchange입니다. Spring Cloud Gateway에서 제공하는 이 클래스를 사용하면 간단하면서도 효과적인 방법으로 프록시 서버를 구축할 수 있습니다.
ProxyController.java
@RestController
@Slf4j
public class ProxyController {
@Value("${proxy.target.scheme}")
private String targetScheme;
@Value("${proxy.target.host}")
private String targetHost;
@Value("${proxy.target.port}")
private String targetPort;
@Value("${proxy.response.headers.remove}")
private List<String> removeHeaders;
@RequestMapping(value = "/**")
public Mono<ResponseEntity<byte[]>> proxy(ServerHttpRequest request, ProxyExchange<byte[]> proxy) {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpRequest(request);
String modifiedUri = builder.scheme(targetScheme).host(targetHost).port(targetPort).build(false)
.toUriString();
log.info("origin uri: {}, target uri: {}", request.getURI(), modifiedUri);
return proxy.uri(modifiedUri).forward(r -> {
HttpHeaders filtered = filter(r.getHeaders());
return ResponseEntity.status(r.getStatusCode()).headers(filtered).body(r.getBody());
});
}
@VisibleForTesting
@Nullable
protected HttpHeaders filter(@Nullable HttpHeaders httpHeaders) {
if (CollectionUtils.isEmpty(httpHeaders) || CollectionUtils.isEmpty(removeHeaders)) {
return httpHeaders;
}
HttpHeaders filtered = new HttpHeaders();
httpHeaders.entrySet().stream()
.filter(entry -> !removeHeaders.contains(entry.getKey().toLowerCase()))
.forEach(entry -> filtered.addAll(entry.getKey(), entry.getValue()));
return filtered;
}
target scheme과 host, port는 프록시가 호출할 URL 정보입니다. response.headers.remove 헤더는 프록시가 서비스를 호출한 후 받은 응답에서 특정 헤더를 제거하기 위한 것입니다. 이게 필요한 대표적인 경우가 x-frames-options입니다. 응답 헤더에 이 값이 있으면 iframe을 통한 화면 구성이 불가능합니다. 이와 같이 보안을 위한 헤더 값이 애플리케이션에 영향을 줄 경우 프록시가 응답 헤더에서 제거하도록 구현했습니다. Forward 메서드 파라미터가 ResponseEntity<T>->ResponseEntity<S>인 Function이기 때문에 아래와 같이 Converter를 이용해서 원하는 형태로 응답을 변형할 수 있습니다.
Function<ResponseEntity<T>, ResponseEntity<S>> converter
모든 환경 변수는 프록시 소스 코드나 Docker 이미지가 아니라 쿠버네티스 환경 변수와 연동되도록 해서 등록과 수정을 편하게 할 수 있도록 설정했습니다. 즉 Boot의 위의 환경 변수들은 아래와 같은 Helm Chart 값으로 대체됩니다.
Values.yaml
envs:
config:
"PROXY.TARGET.HOST": "127.0.0.1"
"PROXY.TARGET.PORT": "8080"
"PROXY.TARGET.SCHEME": "http"
"PROXY.RESPONSE.HEADERS.REMOVE": "x-frame-options"
위 Values.yaml 파일 내용은 아래와 같이 ConfigMap에 매핑됩니다.
ConfigMap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
...........
data:
{{- toYaml .Values.proxy.envs.config | nindent 2 }}
그리고 위 ConfigMap의 값들은 아래와 같은 설정에 따라 프록시 컨테이너를 배포할 때 환경 변수로 등록됩니다.
Deployment
envFrom:
- configMapRef:
name: {{ .Values.proxy.name }}
최종적으로 등록된 환경 변수는 Spring Boot가 기동할 때 읽어서 매핑합니다. 요약하면 Values → ConfigMap → Deployment → ProxyController 순서로 값을 참조합니다. 예를 들어 ProxyController의 targetHost의 값은 127.0.0.1이 됩니다.
ProxyController
@Value("${proxy.target.host}") <- 127.0.01
private String targetHost;
AuthenticationFilter
ProxyController가 요청 경로 변경이나 응답 변형 등을 수행하는 프록시 기능을 수행한다면 인증 로직은 어디 있을까요? 바로 AuthenticationFilter에 있습니다. AuthenticationFilter는 WebFilter로 구현했으므로 모든 요청은 WebFilter를 거칩니다.
이때 인증을 적용해서는 안 되는 경로도 인증을 거친다는 문제가 발생합니다. Readiness probe 경로나 liveness probe 경로가 대표적인 예입니다. 이를 해결하기 위해 exclude.path라는 변수를 만들고 특정 경로는 인증을 제외하도록 만들었습니다. 이 값도 물론 앞서 ProxyController와 같이 여러 단계를 거쳐 환경 변수로 주입됩니다.
AuthenticationFiler.java
public class AuthenticationFilter implements WebFilter {
@Value("${authentication.key}")
private String key;
@Value("${authentication.servicecode}")
private String serviceCode;
@Value("${authentication.exclude.path}")
private List<String> excludePath;
@NonNull
private String[] decryptAccessSignature(@NonNull String signature) {
final String[] decrypted;
try {
.....
} catch (Exception e) {
log.warn("Decrypt access signature failed.", e);
throw new AuthenticationException(e);
}
return decrypted;
}
@VisibleForTesting
@Nullable
protected User getUserInfo(@NonNull HttpHeaders httpHeaders) throws IOException {
.........
}
@Override
public Mono<Void> filter(ServerWebExchange serverWebExchange, WebFilterChain webFilterChain) {
final String requestPath = serverWebExchange.getRequest().getURI().getPath();
final String requestParam = serverWebExchange.getRequest().getQueryParams().toString();
if (excludePath.contains(requestPath)) {
return webFilterChain.filter(serverWebExchange);
}
HttpHeaders httpHeaders = serverWebExchange.getRequest().getHeaders();
final String signature = httpHeaders.getFirst(AuthHttpHeader.ACCESS_SIGNATURE);
...........
} catch (IOException ex) {
throw new AuthenticationException("Invalid access.");
}
return webFilterChain.filter(serverWebExchange);
}
}
파드 배포
이제 준비된 인증 프록시와 애플리케이션을 하나의 파드로 함께 배포해야 합니다. Helm chart 구성은 아래와 같습니다.
Deployment.yaml
containers:
- name: {{ .application.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
volumeMounts:
.....
envFrom:
.....
ports:
- name: http
containerPort: 8080
protocol: TCP
{{- with .Values.readinessProbe }}
......
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.proxy.enabled }}
- name: {{ .Values.proxy.name }}
image: "{{ .Values.proxy.image }}:{{ .Values.proxy.imageTag }}"
imagePullPolicy: {{ .Values.proxy.imagePullPolicy }}
volumeMounts:
......
ports:
- name: proxy
containerPort: {{ .Values.proxy.port }}
protocol: TCP
......
env:
- name: SPRING_PROFILES_ACTIVE
value: {{ .Values.proxy.springProfile }}
....
envFrom:
- configMapRef:
name: {{ .Values.proxy.name }}
애플리케이션과 프록시 컨테이너의 리소스 설정이 나란히 있습니다.
ApplicationResource.yaml
resources:
limits:
cpu: 10000m
memory: 10000Mi
requests:
cpu: 5000m
memory: 4000Mi
두 컨테이너가 하나의 파드에서 작동할 때 리소스 문제가 발생하지 않도록 리소스를 배분합니다. 각 컨테이너 리소스 limits 값의 총합은 파드가 할당된 노드의 물리적 값보다 작아야 합니다.
ProxyResource.yaml
resources:
limits:
cpu: 1500m
memory: 5000Mi
requests:
cpu: 1000m
memory: 2000Mi
서비스 객체는 프록시만 외부에 노출하도록 설정합니다. 이 서비스 객체 설정에 따라 쿠버네티스가 제어하는 로드밸런서 객체가 생성되고, 로드밸런서 → 프록시 구간 트래픽 설정이 완료됩니다.
Service.yaml
spec:
type: {{ .Values.service.type }} // LoadBalancer
externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }} // Local
ports:
{{- if .Values.proxy.enabled }}
- port: {{ .Values.service.port }}
targetPort: proxy
protocol: TCP
name: proxy
{{- else }}
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
{{- end }}
배포 후 발생한 문제 및 해결 방법
배포를 완료한 후 한 가지 문제가 발생했습니다. 특정 파라미터가 포함된 요청이 작동하지 않는 문제였습니다. 문제를 해결하기 위해 프록시가 호출 경로에 있을 때와 없을 때의 URL을 비교했고, URL 인코딩이 중복 수행된 것을 발견했습니다. 이에 아래와 같이 UriComponents build(boolean encoded) 파라미터를 false로 변경해 URL 인코딩이 중복 수행되는 문제를 해결했습니다.
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpRequest(request);
String modifiedUri = builder.scheme(targetScheme).host(targetHost).port(targetPort).build(false)
.toUriString();
사이드카 프록시 적용 효과
사이드카 프록시를 적용한 후 아래와 같은 이점을 얻었습니다.
자체 개발 컴포넌트와 오픈 소스 모두 동일한 인증 체계로 통합
사이드카 프록시는 별도 애플리케이션으로 인증을 수행하기에 인증 대상 애플리케이션 소스 코드를 수정할 필요가 없습니다. 따라서 어떤 애플리케이션에도 적용할 수 있는데요. 이 점은 오픈 소스의 경우에 특히 유용했습니다.
유지 보수 부담 감소
인증 관련 수정이 발생할 때 사이드카 프록시를 수정하는 것만으로 대응할 수 있었습니다. 인증 대상을 추가하거나 삭제하는 것도 마찬가지로 사이드카 프록시를 사이드카로 추가 혹은 삭제하기만 하면 되기 때문에 매우 손쉬웠습니다. 간단한 인증 대상 추가는 이미 Harbor에 업로드돼 있는 사이드카 프록시 이미지를 사용하는 것만으로 가능했습니다.
인증 이외 처리에도 활용 가능
원래 의도했던 인증과 로깅뿐 아니라 진행 과정에서 발생한 다른 이슈를 해결할 때도 유용했습니다. 특히 오픈소스를 직접 손대지 않고 요청과 응답에 변형을 가할 수 있다는 점이 매우 유용했습니다. 사이드카 패턴의 한 가지 활용 예로 인증을 들었습니다만, 다른 기능이 필요할 때 선택할 수 있는 또 하나의 옵션이 마련됐습니다.
마치며
애플리케이션 환경은 계속해서 변화하고, 애플리케이션과 애플리케이션을 둘러싼 환경은 상호 작용하며 발전해 나갑니다. 과거에 최선이라고 생각했거나 한계라고 여겼던 것들도 환경이 변하면 더 이상 최선도 한계도 아닐 수 있습니다. 이런 관점에서 쿠버네티스의 이점은 인프라 환경의 변화에만 국한되지 않습니다. 애플리케이션 아키텍처를 쿠버네티스 환경에 맞게 변경하고 다시 구성한다면 그동안 까다롭고 번거롭다고 여겼던 작업들도 다시 탄생시킬 수 있습니다.
모든 개발자가 DevOps 엔지니어일 필요는 없지만, 애플리케이션과 그 환경을 별개 직무로 여기기보다는 함께 발전하는 하나의 구성 요소로 인식하는 것은 필요하다고 생각합니다. 그 인식 위에서 저는 앞으로도 변화를 주시하고 적용을 고민한 후 과감히 실행해 보고자 하며, 이 글에서 소개한 코드는 그런 관점에서 의미가 있다고 생각합니다. 이 글이 독자분들께 유지 보수 비용을 줄일 수 있는 작은 인사이트를 제공할 수 있기를 바라며 이만 마치겠습니다.