Handshake failed due to invalid Upgrade header: null

λ³Έ 글은 μœ„μ™€ 같은 μ›Ή μ†ŒμΌ“ μ—°κ²° μ‹œμ— μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 였λ₯˜ λ‘œκ·Έκ°€ λ°œμƒν•œ 건에 λŒ€ν•œ κ΄€λ ¨ λ‚΄μš©μ„ κΈ°λ‘ν•˜κΈ° μœ„ν•œ 것 μž…λ‹ˆλ‹€. 이 였λ₯˜ λ‘œκ·ΈλŠ” μŠ€ν”„λ§ μ›Ή μ†ŒμΌ“ λͺ¨λ“ˆμ—μ„œ DefaultHandshakeHandlerλ₯Ό 톡해 ν•Έλ“œμ‰μ΄ν¬λ₯Ό μˆ˜ν–‰ν•˜λŠ” κ³Όμ •μ—μ„œ μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ›Ή μ†ŒμΌ“ 연결에 λŒ€ν•΄ 였λ₯˜ 둜그둜 κΈ°λ‘ν•˜λ„λ‘ λ˜μ–΄μžˆλŠ”λ° Upgrade 헀더에 μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 값이 μ „λ‹¬λ˜μ—ˆλ‹€λŠ” μ˜λ―Έμž…λ‹ˆλ‹€.

Connection: Upgrade
Upgrade: websocket

일반적으둜 μ›Ή μ†ŒμΌ“ 연결은 Protocol upgrade mechanism으둜 HTTP 톡신에 λŒ€ν•΄ 컀λ„₯μ…˜μ„ μ „ν™˜ν•˜λŠ” 과정을 거치게 λ˜λŠ”λ° μ„œλ²„μ—μ„œλŠ” μ›Ήμ†ŒμΌ“ μ—”λ“œν¬μΈνŠΈμ— ν•΄λ‹Ήν•˜λŠ” μš”μ²­μ— λŒ€ν•΄μ„œλŠ” Upgrade 헀더λ₯Ό ν™•μΈν•˜κ³  websocket 이 μ „λ‹¬λ˜μ—ˆλŠ”μ§€λ₯Ό ν™•μΈν•©λ‹ˆλ‹€.

μ›Ήμ†ŒμΌ“ ν”„λ‘μ‹œ

μ‹œμŠ€ν…œμ΄ λ™μž‘ν•˜λŠ” 인프라 ν™˜κ²½μ€ AWS ν΄λΌμš°λ“œ μ„œλΉ„μŠ€λ‘œ κ΅¬μ„±λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. 일반적으둜 λ‘œλ“œλ°ΈλŸ°μ‹±μ„ μœ„ν•΄μ„œ μ‚¬μš©ν•˜λŠ” Elastic Load Balancing κΈ°λŠ₯에 λ”°λ₯΄λ©΄ ALB, NLB λͺ¨λ‘ μ›Ήμ†ŒμΌ“μ„ μ§€μ›ν•œλ‹€κ³  λ˜μ–΄μžˆμœΌλ―€λ‘œ μ›Ήμ†ŒμΌ“ 연결에 μ œν•œμ μΈ ν™˜κ²½μ€ μ•„λ‹™λ‹ˆλ‹€. ν˜„μž¬ μ‘°μ§μ—μ„œ ν”Œλž«νΌμœΌλ‘œμ¨ μ œκ³΅ν•˜λŠ” ν™˜κ²½μ€ EC ν‚€ 기반의 μΈμ¦μ„œμ˜ μ œμ•½μ‚¬ν•­μœΌλ‘œ NLBλ₯Ό μ‚¬μš©ν•΄μ„œ TCP ν”„λ‘μ‹œλ₯Ό μˆ˜ν–‰ν•˜κ³  SSL μ˜€ν”„λ‘œλ“œλŠ” Nginxμ—μ„œ μˆ˜ν–‰ν•œ ν›„ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ„œλ²„λ‘œ νŠΈλž˜ν”½μ΄ μ „λ‹¬λ˜λŠ” κ΅¬μ‘°μž…λ‹ˆλ‹€.

λ‹€λ§Œ, μœ„ λ¬Έμ œκ°€ λ°œμƒν–ˆλ‹€κ³  μ•ˆλ‚΄λœ νŠΉμ • 고객이 직접 κ΅¬μ„±ν•˜λŠ” ν™˜κ²½μ—μ„œλŠ” ELB λ ˆλ²¨μ—μ„œ SSL μ˜€ν”„λ‘œλ“œλ₯Ό μˆ˜ν–‰ν•œ ν›„μ˜ νŠΈλž˜ν”½λ§Œ Nginx둜 μ „λ‹¬λ˜μ–΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ„œλ²„λ‘œ ν”„λ‘μ‹œλ˜λ―€λ‘œ μ•½κ°„μ˜ μš”μ²­μ΄ μ „λ‹¬λ˜λŠ” 방식이 λ‹€λ¦…λ‹ˆλ‹€. λ”°λΌμ„œ, ELB λ ˆλ²¨μ—μ„œ νŠΈλž˜ν”½μ„ μ „λ‹¬ν•˜λŠ” κ³Όμ •μ—μ„œ Upgrade 헀더가 μœ μ‹€λ  κ°€λŠ₯성도 μ˜μ‹¬ν•΄λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€.

각 ν™˜κ²½μ˜ Nginxμ—λŠ” Upgarde 헀더에 λŒ€ν•œ ν”„λ‘μ‹œ ꡬ성에 따라 Upgarde 헀더 값을 μ• ν”Œλ¦¬μΌ€μ΄μ…˜μœΌλ‘œ μ „λ‹¬λ˜λ„λ‘ $http_upgrade λ³€μˆ˜κ°€ μ„€μ •λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.

νƒ€μž„μ•„μ›ƒ

일반적으둜 AWS ν΄λΌμš°λ“œ ν™˜κ²½μ—μ„œ ELBλ₯Ό μ‚¬μš©ν•  λ•Œ μ›Ήμ†ŒμΌ“ μ—°κ²° λ¬Έμ œκ°€ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€. μ΄λŠ” ELB λ‘œλ“œλ°ΈλŸ°μ„œ μ†μ„±μ˜ idle_timeout.timeout_seconds 기본값이 60초 μ΄μ–΄μ„œ λ°œμƒν•˜λŠ” 뢀뢄에 λŒ€ν•΄μ„œλŠ” νƒ€μž„μ•„μ›ƒμ„ 90초둜 μ„€μ •λ˜μ–΄ μ›Ή μ†ŒμΌ“ 연결에 λŒ€ν•œ 뢀뢄은 μ •μƒμ μœΌλ‘œ μœ μ§€ν•˜λŠ” μƒνƒœμž…λ‹ˆλ‹€.

ν΄λΌμ΄μ–ΈνŠΈμ˜ 초기 연결을 μ œμ™Έν•˜κ³ λŠ” μ„œλ²„μ—μ„œ 1λΆ„λ§ˆλ‹€ μŠ€μΌ€μ€„μ— μ˜ν•΄ μ–΄λ– ν•œ λ©”μ‹œμ§€λ₯Ό μ „λ‹¬ν•˜λŠ”λ° μ„œλ²„κ°€ μ „λ‹¬ν•˜λŠ” 타이밍 상 60초 이내에 μ „λ‹¬λ˜λŠ” νŠΈλž˜ν”½μ΄ μ—†λ‹€κ³  νŒλ‹¨λ˜μ–΄ 연결이 해지될 수 μžˆμŠ΅λ‹ˆλ‹€.

HTTP2

일반적인 μ›Ή μš”μ²­μ€ HTTP2둜 연결될 수 μžˆλ„λ‘ μ§€μ›ν•˜κ³  μžˆλŠ”λ° μ›Ήμ†ŒμΌ“μ— λŒ€ν•œ 연결에 λŒ€ν•΄μ„œλŠ” HTTP 1.1의 μ—…κ·Έλ ˆμ΄λ“œ λ§€μ»€λ‹ˆμ¦˜μ„ μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€.

curl --include \
     --no-buffer \
     --http1.1 \
     --location \
     --header "Connection: Upgrade" \
     --header "Upgrade: websocket" \
     --header "Host: example.com" \
     --header "Origin: https://example.com" \
     --header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
     --header "Sec-WebSocket-Version: 13" \
     https://example.com/websocket/server/sessionid/websocket

그런데 μœ„ cURL λͺ…λ Ήμ–΄μ—μ„œ –http1.1 μ˜΅μ…˜μ„ μ œμ™Έν•˜λ©΄ Upgarde 헀더에 websocket에 μ „λ‹¬λ˜μ§€ μ•ŠλŠ” 상황이 λ°œμƒν•¨μ„ ν™•μΈν•˜μ˜€μŠ΅λ‹ˆλ‹€. κ²°κ΅­ 일반적인 λΈŒλΌμš°μ €λ₯Ό ν†΅ν•œ μš”μ²­μ΄ μ•„λ‹ˆλΌ νŠΉμ • ν΄λΌμ΄μ–ΈνŠΈκ°€ 직접 μ›Ήμ†ŒμΌ“ 연결을 μ‹œλ„ν•  κ°€λŠ₯성도 μžˆλ‹€λŠ” 이야기 μž…λ‹ˆλ‹€.

wscat λ˜λŠ” postman μœΌλ‘œλ„ μ›Ή μ†ŒμΌ“ 연결을 μ‹œλ„ν•΄λ³΄μ•˜μ§€λ§Œ μ •μƒμ μœΌλ‘œ 연결됨을 확인할 수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€.

μŠ€ν”„λ§ μ›Ή μ†ŒμΌ“

μ‹œμŠ€ν…œ μž…μž₯μ—μ„œλŠ” μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ μ›Ήμ†ŒμΌ“ 연결이 μš”μ²­λ˜λŠ” λΆ€λΆ„μ΄λ―€λ‘œ ν•΄λ‹Ή 였λ₯˜ λ‘œκ·Έλ§ŒμœΌλ‘œλŠ” μ–΄λ–»κ²Œ μš”μ²­λ˜μ—ˆλŠ”κ°€λ₯Ό κ²€ν† ν•  수 μžˆλŠ” λ°©μ•ˆμ΄ μ—†μŠ΅λ‹ˆλ‹€. μŠ€ν”„λ§ μ›Ή μ†ŒμΌ“ λͺ¨λ“ˆμ—μ„œ μš”μ²­ 정보λ₯Ό 기둝할 수 μžˆλŠ” λ°©μ•ˆμ„ 찾아보도둝 ν•©μ‹œλ‹€.

RequestUpgradeStrategy

RequestUpgradeStrategyλŠ” HTTP μš”μ²­μ— λŒ€ν•΄μ„œ μ›Ή μ†ŒμΌ“ μ—°κ²°λ‘œ μ—…κ·Έλ ˆμ΄λ“œν•˜κΈ° μœ„ν•œ μ „λž΅μ΄λ©° μ–Έλ”ν† μš°λ₯Ό μ‚¬μš©ν•˜κ³  μžˆλ‹€λ©΄ UndertowRequestUpgradeStrategyκ°€ μ‚¬μš©λ©λ‹ˆλ‹€.

public class CustomRequestUpgradeStrategy extends UndertowRequestUpgradeStrategy {
    @Override
    protected void upgradeInternal(ServerHttpRequest request, ServerHttpResponse response, String selectedProtocol, List<Extension> selectedExtensions, Endpoint endpoint) throws HandshakeFailureException {
        // NOTE: ν•Έλ“œμ‰μ΄ν¬ κ³Όμ •μ—μ„œ κ²€μ¦λœ μš”μ²­μ— λŒ€ν•΄μ„œ μ—…κ·Έλ ˆμ΄λ“œλ₯Ό μˆ˜ν–‰ν•œλ‹€.
        super.upgradeInternal(request, response, selectedProtocol, selectedExtensions, endpoint);
    }
}

μœ„ 처럼 μ—…κ·Έλ ˆμ΄λ“œλ₯Ό μˆ˜ν–‰ν•˜λŠ” κ³Όμ •μ—μ„œ μš”μ²­κ³Ό 응닡에 λŒ€ν•΄ λΆ€κ°€ 처리λ₯Ό μˆ˜ν–‰ν•  수 μžˆμŠ΅λ‹ˆλ‹€λ§Œ DefaultHandshakeHandler λΌλŠ” ν΄λž˜μŠ€μ—μ„œ upgradeInternal ν•¨μˆ˜κ°€ ν˜ΈμΆœλ˜λŠ” μœ„μΉ˜λ₯Ό μ‚΄νŽ΄λ³΄λ©΄ μ›Ή μ†ŒμΌ“ μ—°κ²° μš”μ²­μ— λŒ€ν•΄μ„œ 검증을 μˆ˜ν–‰ν•˜κ³ λ‚˜μ„œ λ§ˆμ§€λ§‰μ— upgradeInternal ν•¨μˆ˜κ°€ ν˜ΈμΆœλ˜λ―€λ‘œ λ³Έ λ¬Έμ œκ°€ λ°œμƒν–ˆμ„λ•ŒλŠ” μš”μ²­ 정보λ₯Ό νŒŒμ•…ν•  수 μ—†μŠ΅λ‹ˆλ‹€.

DefaultHandshakeHandler

DefaultHandshakeHandlerλŠ” μŠ€ν”„λ§ μ›Ή μ†ŒμΌ“ λͺ¨λ“ˆμ—μ„œ 기본적으둜 μ‚¬μš©ν•˜λŠ” μ›Ή μ†ŒμΌ“ 연결을 μˆ˜ν–‰ν•˜λŠ” ν•Έλ“€λŸ¬λ‘œ HandshakeHandler둜 λ“±λ‘λœ 빈이 μ—†λ‹€λ©΄ λ‚΄λΆ€μ μœΌλ‘œ DefaultHandshakeHandlerλ₯Ό μƒμ„±ν•˜μ—¬ μ‚¬μš©ν•˜λ„λ‘ λ˜μ–΄μžˆμŠ΅λ‹ˆλ‹€. μ•žμ„œ RequestUpgradeStrategyλ₯Ό μ΄μš©ν•  수 μ—†λŠ” 이유λ₯Ό ν™•μΈν•˜κΈ° μœ„ν•΄ ν•Έλ“œμ‰μ΄ν¬λ₯Ό μˆ˜ν–‰ν•˜λŠ” μ½”λ“œλ₯Ό μ‚΄νŽ΄λ³΄λ„λ‘ ν•˜κ² μŠ΅λ‹ˆλ‹€.

public static class AbstractHandshakeHandler implements HandshakeHandler {
    @Override
	public final boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response,
			WebSocketHandler wsHandler, Map<String, Object> attributes) throws HandshakeFailureException {

		WebSocketHttpHeaders headers = new WebSocketHttpHeaders(request.getHeaders());
		if (logger.isTraceEnabled()) {
			logger.trace("Processing request " + request.getURI() + " with headers=" + headers);
		}
		try {
			if (HttpMethod.GET != request.getMethod()) {
				response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
				response.getHeaders().setAllow(Collections.singleton(HttpMethod.GET));
				if (logger.isErrorEnabled()) {
					logger.error("Handshake failed due to unexpected HTTP method: " + request.getMethod());
				}
				return false;
			}
			if (!"WebSocket".equalsIgnoreCase(headers.getUpgrade())) {
				handleInvalidUpgradeHeader(request, response);
				return false;
			}
			if (!headers.getConnection().contains("Upgrade") && !headers.getConnection().contains("upgrade")) {
				handleInvalidConnectHeader(request, response);
				return false;
			}
			if (!isWebSocketVersionSupported(headers)) {
				handleWebSocketVersionNotSupported(request, response);
				return false;
			}
			if (!isValidOrigin(request)) {
				response.setStatusCode(HttpStatus.FORBIDDEN);
				return false;
			}
			String wsKey = headers.getSecWebSocketKey();
			if (wsKey == null) {
				if (logger.isErrorEnabled()) {
					logger.error("Missing \"Sec-WebSocket-Key\" header");
				}
				response.setStatusCode(HttpStatus.BAD_REQUEST);
				return false;
			}
		}
		catch (IOException ex) {
			throw new HandshakeFailureException(
					"Response update failed during upgrade to WebSocket: " + request.getURI(), ex);
		}

		String subProtocol = selectProtocol(headers.getSecWebSocketProtocol(), wsHandler);
		List<WebSocketExtension> requested = headers.getSecWebSocketExtensions();
		List<WebSocketExtension> supported = this.requestUpgradeStrategy.getSupportedExtensions(request);
		List<WebSocketExtension> extensions = filterRequestedExtensions(request, requested, supported);
		Principal user = determineUser(request, wsHandler, attributes);

		if (logger.isTraceEnabled()) {
			logger.trace("Upgrading to WebSocket, subProtocol=" + subProtocol + ", extensions=" + extensions);
		}
		this.requestUpgradeStrategy.upgrade(request, response, subProtocol, extensions, user, wsHandler, attributes);
		return true;
	}
}

AbstractHandshakeHandler ν΄λž˜μŠ€μ— λŒ€ν•΄ 둜그 λ ˆλ²¨μ„ Trace둜 μ„€μ •ν•˜λ©΄ μš”μ²­ 정보λ₯Ό 둜그둜 기둝할 수 μžˆμ§€λ§Œ λͺ¨λ“  μš”μ²­μ— λŒ€ν•΄μ„œ κΈ°λ‘ν•˜λ―€λ‘œ λ¬Έμ œκ°€ λ°œμƒν–ˆμ„λ•Œλ§Œ μš”μ²­ 정보λ₯Ό 남길 수 μ—†μŠ΅λ‹ˆλ‹€.

AbstractHandshakeHandler.handleInvalidUpgradeHeader

μ•žμ„œ doHandshake ν•¨μˆ˜λ₯Ό μ‚΄νŽ΄λ³Έ κ²°κ³Ό Upgrade 헀더에 μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 값이 μ „λ‹¬λ˜λŠ” κ²½μš°μ—λŠ” handleInvalidUpgradeHeader ν•¨μˆ˜λ₯Ό ν˜ΈμΆœν•˜λŠ” 것을 확인할 수 μžˆμŠ΅λ‹ˆλ‹€. 이제 μš°λ¦¬λŠ” handleInvalidUpgradeHeader ν•¨μˆ˜λ₯Ό μ˜€λ²„λΌμ΄λ“œ ν•˜μ—¬ μš”μ²­ 정보λ₯Ό ν™•μΈν•˜κ³  였λ₯˜ 둜그둜 기둝할 수 μžˆλŠ” μœ„μΉ˜λ₯Ό μ•Œκ²Œ λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

@Slf4j
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
    @Override
    protected void handleInvalidUpgradeHeader(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
        // NOTE: Upgrade 헀더에 μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 값이 μ „λ‹¬λ˜μ—ˆμ„λ•Œ ν˜ΈμΆœλœλ‹€.
        log.error("Method: {}, URI: {}, Principal: {}, Headers: {}", request.getMethodValue(), request.getURI(), request.getPrincipal(), request.getHeaders()); 
        super.handleInvalidUpgradeHeader(request, response);
    }
}

μ• ν”Œλ¦¬μΌ€μ΄μ…˜ λ ˆλ²¨μ—μ„œλŠ” Upgrade 헀더에 μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ 값이 μ „λ‹¬λ˜λŠ” κ²½μš°μ— λŒ€ν•΄μ„œ 원인을 νŒŒμ•…ν•˜κΈ°λŠ” μ–΄λ ΅μŠ΅λ‹ˆλ‹€. κ·ΈλŸΌμ—λ„ λΆˆκ΅¬ν•˜κ³  λ³Έ λ¬Έμ œκ°€ λ‹€μ‹œ λ°œμƒν–ˆμ„ λ•Œ μ–΄λ–€ μ •λ³΄λ‘œ μš”μ²­λ˜μ—ˆλŠ”μ§€μ— λŒ€ν•œ λ‘œκ·Έκ°€ κΈ°λ‘λ˜μ—ˆμœΌλ―€λ‘œ 원인 νŒŒμ•…μ„ μœ„ν•œ μ‹€λ§ˆλ¦¬λ₯Ό 찾을 수 μžˆλŠ” λ°©μ•ˆμ„ λ§ˆλ ¨ν•  수 있게 λ©λ‹ˆλ‹€.

μ°Έκ³