CDN 서버 구축일지

개발

CDN 서버 구축일지
최종 수정일:

도입 배경

최근 Next.js로 블로그를 전환하며, 캐시 서버의 필요성이 수직으로 상승했습니다.
처음엔 next/images를 사용해보려 했는데, 안 그래도 배포할 일도 많은데 on-demand로 계속 이미지 만드는 걸 보니 영 탐탁지 않더라고요.

그리고, Next.js를 Kubernetes 등으로 배포해보신 분들은 아시겠지만, 정적 파일 문제로 백화 현상이 발생하는 경우가 허다합니다.
Blue / Green으로 전체 트래픽을 한꺼번에 전환하면 이 현상이 좀 덜하긴 한데, 오래된 페이지가 캐시 되어있는 상태에서 사용자가 상호작용하기 전까지 백화현상이 발생하는 등 문제가 발생합니다.

일전에 PHP로 만들었던 CDN 서버가 있긴 한데, 해당 서버는 next/images의 역할(이미지 리사이즈)을 대체할 수가 없어, 겸사겸사 Rust로 다시 만들어봤습니다.
코드는 marshallku-blog-cdn에서 확인하실 수 있습니다.

제작 과정

엔드 포인트 추가

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/files/*path", get(handle_files_request))
        .route("/images/*path", get(handle_image_request));
 
    let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| String::from("127.0.0.1"));
    let addr = format!("{}:41890", bind_address);
    let listener = tokio::net::TcpListener::bind(addr.as_str()).await.unwrap();
 
    info!("Server running at http://{}", addr);
 
    axum::serve(listener, app.into_make_service())
        .await
        .unwrap()
}

/files/:PATH에서는 파일을, /images/:PATH에서는 이미지를 처리하도록 엔드 포인트를 추가했습니다.

원본 서버에서 파일 불러오기

pub async fn fetch_and_cache(
    host: String,
    file_path: &PathBuf,
    path: &str,
) -> Result<(), reqwest::Error> {
    let url = format!("{}{}", host, path);
    let response = match Client::new().get(&url).send().await?.error_for_status() {
        Ok(response) => response.bytes().await?,
        Err(err) => {
            error!("Failed to fetch {}", url);
            return Err(err);
        }
    };
 
    if let Some(parent) = file_path.parent() {
        fs::create_dir_all(parent).ok();
    }
 
    fs::write(file_path, &response).ok();
    Ok(())
}

reqwest를 사용해 원본 서버에서 파일을 훔쳐와 로컬에 저장해 줍니다.

두 가지만 신경 쓰면 됩니다.

파일 처리

pub fn response_error(status_code: StatusCode) -> Response {
    status_code.into_response()
}
 
pub async fn response_file(file_path: &PathBuf) -> Response {
    let file = match tokio::fs::File::open(file_path).await {
        Ok(file) => file,
        Err(_) => {
            return response_error(StatusCode::INTERNAL_SERVER_ERROR);
        }
    };
    let stream = ReaderStream::new(file);
    let body = Body::from_stream(stream);
 
    body.into_response()
}

axum으로 파일을 응답하려면, tokio::fs::File::open으로 파일을 열어 ReaderStream으로 변환한 후 Body로 변환해 응답하면 됩니다.

추가로, 파일을 불러오지 못하는 등 오류가 발생했을 때 사용하기 위해 response_error도 추가했습니다.

const CDN_ROOT: &str = "cdn_root";
 
async fn handle_files_request(Path(path): Path<String>) -> impl IntoResponse {
    let file_path = PathBuf::from(format!("{}/files/{}", CDN_ROOT, path));
 
    if file_path.exists() {
        error!("File exists but respond with Rust: {:?}", file_path);
        return http::response_file(&file_path).await;
    }
 
    if let Err(_) =
        fetch::fetch_and_cache("https://marshallku.com/".to_string(), &file_path, &path).await
    {
        return http::response_error(StatusCode::NOT_FOUND);
    }
 
    http::response_file(&file_path).await
}

이제 /files/:PATH로 요청이 들어왔을 때

  1. 로컬에 파일이 존재하면 파일 응답
  2. 파일이 없으면 원본 서버에서 파일 불러오기
  3. 파일 응답 (파일 불러오지 못하면 404 응답)

순으로 처리하도록 하면, 꽤 간단하게 파일 서버까지는 만들 수 있습니다.

여기까지만 해도, 처음에 언급했던 정적 파일 문제는 해결할 수 있습니다.

const YEAR_TO_SECONDS: u32 = 31536000;
 
pub fn get_cache_header(age: u32) -> HeaderMap {
    let mut headers = HeaderMap::new();
    let cache_age = if age <= 0 {
        "no-cache".to_string()
    } else {
        format!("public, max-age={}", age)
    };
 
    headers.insert("Cache-Control", cache_age.parse().unwrap());
 
    headers
}

위 작업까지 진행했다가 큰 낭패를 봤습니다...
Nginx에서 Cache-Control 헤더를 추가했는데, 잘못된 응답까지 캐싱해버리는 문제가 발생했습니다.

Cache-Control 헤더를 추가하는 함수를 따로 제작하고, body.into_response() 대신 (get_cache_header(31536000), body).into_response()처럼 헤더와 함께 응답하도록 수정했습니다.

proxy_hide_header Cache-Control;
proxy_hide_header Expires;
expires off;

이렇게 Nginx에서도 Rust 서버로 proxy pass 할 땐 캐시 관련 헤더를 숨기도록 설정했습니다.

이미지 처리

경로 파싱

저는 파일이 한 번 저장되면 Nginx가 응답하도록 하는 게 목표여서 https://example.com/path/to/image.png?size=100x100처럼 쿼리를 사용해 간단히 정보를 전달하도록 만들 수 없고, https://example.com/path/to/image.w100.png처럼 파일명을 수정해 정보를 전달하도록 했습니다.

pub fn get_resize_width_from_path(path: &str) -> Option<u32> {
    path.split('.').find_map(|part| {
        if part.starts_with('w') && part[1..].chars().all(char::is_numeric) {
            part[1..].parse::<u32>().ok()
        } else {
            None
        }
    })
}

w100과 같은 정보를 추출하기 위해 위와 같은 함수를 만들었습니다.
파일 명을 w20.jpg처럼 지으면 문제가 생길 수 있지만...아무래도 이런 애플리케이션 만들 때 제일 좋은 건 입력하는 사람이 극히 한정적(저 혼자)이니, 그런 이상한 짓을 할 사람이 없습니다.

pub fn get_original_path(path: &str, has_resize: bool) -> String {
    let (dir, filename) = match path.rfind('/') {
        Some(index) => (&path[..=index], &path[index + 1..]),
        None => ("", path),
    };
 
    let mut parts: Vec<&str> = filename.split('.').collect();
 
    if parts.last() == Some(&"webp") {
        parts.pop();
    }
 
    if has_resize {
        parts.remove(parts.len() - 2);
    }
 
    format!("{}{}", dir, parts.join("."))
}

w100이나 webp같은 추가적인 정보들을 제거해 원본 파일명을 추출하는 함수도 만들었습니다.

#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_get_resize_width_from_path() {
        assert_eq!(
            get_resize_width_from_path("/path/to/file.w100.jpg"),
            Some(100)
        );
        assert_eq!(
            get_resize_width_from_path("/path/to/file.with.dot.w200.jpg"),
            Some(200)
        );
        assert_eq!(
            get_resize_width_from_path("/path/to/file.with.dot.w200w.jpg"),
            None
        );
        assert_eq!(
            get_resize_width_from_path("/path/to/file.with.dot.w300.jpg.webp"),
            Some(300)
        );
        assert_eq!(
            get_resize_width_from_path("/path/to/file.with.dot.300.jpg.webp"),
            None
        );
        assert_eq!(get_resize_width_from_path("/path/to/file.jpg"), None);
        assert_eq!(
            get_resize_width_from_path("/path/to/file.with.dot.jpg"),
            None
        );
    }
 
    #[test]
    fn test_get_original_path() {
        let paths = vec![
            "/path/to/file.w100.jpg",
            "/path/to/webp.w100.jpg.webp",
            "/path/to/file.with.dot.w100.jpg",
            "/path/to/webp.with.dot.w100.jpg.webp",
            "/path/to/file.jpg",
            "/path/to/file.with.dot.jpg",
            "/path/to/webp.with.dot.jpg.webp",
        ];
 
        let expected = vec![
            "/path/to/file.jpg",
            "/path/to/webp.jpg",
            "/path/to/file.with.dot.jpg",
            "/path/to/webp.with.dot.jpg",
            "/path/to/file.jpg",
            "/path/to/file.with.dot.jpg",
            "/path/to/webp.with.dot.jpg",
        ];
 
        for (i, path) in paths.iter().enumerate() {
            assert_eq!(
                get_original_path(path, get_resize_width_from_path(path).is_some()),
                expected[i]
            );
        }
    }
}

작업하다보니 잔실수가 많아, 테스트도 조금 추가해뒀습니다.

이미지 리사이즈

pub async fn resize_and_response_image(
    image: image::DynamicImage,
    width: Option<u32>,
    original_path: &PathBuf,
    target_path: &PathBuf,
) -> Response {
    if image.width() <= width.unwrap() {
        copy(&original_path, &target_path).ok();
        return response_file(&target_path).await;
    }
 
    let resize_height = width.unwrap() * image.height() / image.width();
    let resized_image = image.thumbnail(width.unwrap(), resize_height);
 
    match resized_image.save(target_path.clone()) {
        Ok(_) => response_file(&target_path).await,
        Err(_) => response_error(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

DynamicImagethumbnail()을 사용해서 간단하게 이미지를 리사이즈할 수 있습니다.
이미지 크기를 모든 상황에 파악할 수 있는 게 아니라서, 요청된 너비가 실제 이미지보다 클 때 원본 이미지를 응답하도록 하는 작업도 추가했습니다.

사실 제가 하는 작업이랄 게 별로 없어서, Python으로 이미지를 처리할 때랑 큰 차이가 없었습니다.

async fn handle_image_request(Path(path): Path<String>) -> impl IntoResponse {
    let file_path = PathBuf::from(format!("{}/images/{}", CDN_ROOT, path));
 
    if file_path.exists() {
        error!("File exists but respond with Rust: {:?}", file_path);
        // 필요하면 이 부분에선 이미지를 다시 불러오도록 할 수 있습니다.
        return http::response_file(&file_path).await;
    }
 
    let resize_width = path::get_resize_width_from_path(&path);
    let original_path = path::get_original_path(&path, resize_width.is_some());
    let original_file_path = PathBuf::from(format!("{}/images/{}", CDN_ROOT, original_path));
 
    // 원본 파일이 없으면 불러오기
    if !original_file_path.exists() {
        if let Err(_) =
            fetch::fetch_and_cache("https://marshallku.com/".to_string(), &original_file_path, &original_path).await
        {
            return http::response_error(StatusCode::NOT_FOUND);
        }
    }
 
    // 리사이즈 정보가 없으면 원본 이미지 응답
    if resize_width.is_none() {
        return http::response_file(&file_path).await;
    }
 
    let image = match image::open(&original_file_path) {
        Ok(image) => image,
        Err(_) => {
            return http::response_error(StatusCode::INTERNAL_SERVER_ERROR);
        }
    };
 
    // 이미지 리사이즈하고 응답
    img::save_resized_image(image, resize_width, &original_file_path, &file_path).await
}

이제 /images/:PATH로 요청이 들어왔을 때까지 처리할 수 있습니다.
path에서 구현했던 함수들을 사용해 리사이즈 정보와 원본 파일명을 추출하고, 파일을 적당히 가공해 응답해주면 됩니다.

DynamicImage로 이미지 만들고 가공하는 게 쉽게 한 만큼 생각보다 성능이 안 좋긴 합니다만...한 번만 가공하면 파일을 다시 만들 일이 없으니 큰 문제는 없어 보입니다.

WebP 변환

pub fn save_image_to_webp(image: &image::DynamicImage, path: &PathBuf) -> Result<(), String> {
    let encoder = match Encoder::from_image(&image) {
        Ok(e) => e,
        Err(e) => {
            return Err(e.to_string());
        }
    };
    let webp_memory = encoder.encode(100f32);
 
    write(&path, &*webp_memory).ok();
    Ok(())
}

webp 패키지를 사용하면 DynamicImage를 WebP로 변환할 수 있습니다.
이것도 vCPU 하나, RAM 1GB인 초라한 클라우드 서버에서는 10초까지도 걸리는데, 상술한 것처럼 한 번만 가공하면 되니 크게 문제가되진 않습니다.

근데 Python도 5초 내외로 끊었는데...Rust가 어떻게 이렇게까지 걸릴 일 있는지 모르겠네요.

if !convert_to_webp {
    return img::save_resized_image(image, resize_width, &original_file_path, &file_path).await;
}
 
let path_with_webp = format!("{}.webp", original_path);
let file_path_with_webp = PathBuf::from(format!("{}/images/{}", CDN_ROOT, path_with_webp));
 
if let Err(_) = img::save_image_to_webp(&image, &file_path_with_webp) {
    return http::response_error(StatusCode::INTERNAL_SERVER_ERROR);
}
 
let image_webp = match image::open(&file_path_with_webp) {
    Ok(image) => image,
    Err(_) => {
        return http::response_error(StatusCode::INTERNAL_SERVER_ERROR);
    }
};
 
img::save_resized_image(image_webp, resize_width, &file_path_with_webp, &file_path).await

handle_image_request에서 WebP로 변환 여부를 확인하고, 변환하지 않을 땐 기존 로직대로, 아닐 땐 WebP로 변환하도록 추가해주면 됩니다.
추가로, resize_and_response_image에서 resize_widthNone이면 원본 이미지를 응답하도록 하는 작업도 진행했습니다.

이정도까지 왔으면, Next.js의 CDN 서버로서 역할은 충분히 수행할 수 있습니다.

로그 추가

pub fn trace_layer_on_request(request: &Request<Body>, _span: &Span) {
    let user_agent = request
        .headers()
        .get("user-agent")
        .map_or("<no user-agent>", |h| {
            h.to_str().unwrap_or("<invalid utf8>")
        });
 
    let referer = request
        .headers()
        .get("referer")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("<no referer>");
 
    let ip_address = request
        .headers()
        .get("x-forwarded-for")
        .or_else(|| request.headers().get("x-real-ip"))
        .and_then(|value| value.to_str().ok())
        .unwrap_or("<no ip>");
 
    info!(
        "User-Agent: {:?} Referrer: {:?} IP: {:?}",
        user_agent, referer, ip_address
    )
}

메인 On-premise 서버는 Nginx 로그도 Newrelic에서 수집하는데, 이 애플리케이션은 별도 클라우드에서 동작하다 보니 애플리케이션 로그만 수집할 수 있게 잡다한 접속 정보를 수집할 수 있게 해봤습니다.

근데 사실 이 로그로 별달리 할 게 없어서...좀 허튼짓인가 싶긴 합니다만, 나중에 트래픽 터지거나 했을 때 어디서 호출되는지 확인하는 용도로 쓸 수 있지 않을까 싶어 일단은 찍고 있습니다.

tracing_subscriber::fmt()
    .with_target(false)
    .compact()
    .init();
 
// ... 
 
let app = Router::new()
    .route("/files/*path", get(handle_files_request))
    .route("/images/*path", get(handle_image_request))
    .layer(
        TraceLayer::new_for_http()
            .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
            .on_response(trace::DefaultOnResponse::new().level(Level::INFO))
            .on_request(log::trace_layer_on_request),
    );

이정도면 간단하게 모니터링하는 데 필요한 로그들은 전부 남습니다.

환경 변수 추가

#[derive(Clone, Debug)]
pub struct Env {
    pub address: Cow<'static, str>,
    pub port: u16,
    pub host: Cow<'static, str>,
}
 
impl Env {
    pub fn new() -> Self {
        let address = match std::env::var("BIND_ADDRESS") {
            Ok(address) => Cow::Owned(address),
            Err(_) => Cow::Owned("127.0.0.1".to_string()),
        };
        let port = match std::env::var("PORT") {
            Ok(port) => port.parse().unwrap_or(41890),
            Err(_) => 41890,
        };
        let host = match std::env::var("HOST") {
            Ok(host) => Cow::Owned(host),
            Err(_) => Cow::Owned("http://localhost/".to_string()),
        };
 
        Self {
            address,
            port,
            host,
        }
    }
}

일단 제 블로그에 쓸 목적으로 만들긴 했지만, host 정보, 포트 정보 등은 환경 변수로 관리해 조금 더 확장성을 높일 수 있도록 했습니다.
.env 파일을 사용하진 않고, 어차피 Docker Compose 등 컨테이너 환경에서 관리할 거라, 환경 변수로 관리하도록 했습니다.

Nicholas Rempel 님의 Handling environment variables with Axum을 참고했습니다.

#[derive(Clone)]
pub struct AppState {
    host: String,
    port: u16,
    address: String,
}
 
impl AppState {
    pub fn from_env() -> Self {
        let env = env::Env::new();
 
        Self {
            host: env.host.into_owned(),
            port: env.port,
            address: env.address.into_owned(),
        }
    }
}

main.rs에서 AppState를 구현해

let app = Router::new()
    .route("/files/*path", get(handle_files_request))
    .route("/images/*path", get(handle_image_request))
    .layer(
        TraceLayer::new_for_http()
            .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
            .on_response(trace::DefaultOnResponse::new().level(Level::INFO))
            .on_request(log::trace_layer_on_request),
    )
    .with_state(state);

위와 같이 with_state로 상태를 추가하면, 각 핸들러에서 State<AppState>로 상태를 불러올 수 있습니다.

배포

앞선 Rust 프로젝트에서도 Docker를 사용해서, 그대로 사용할 생각이었습니다.

15,000초 동안 진행하는 cargo chef cook

문제없이 빌드되고 동작하는 걸 확인하고, 제 작고 소중한 클라우드에서 이미지를 빌드했는데...

FROM rust:1.73-alpine AS chef
 
WORKDIR /usr/src/menu-today
 
RUN set -eux; \
    apk add --no-cache musl-dev pkgconfig libressl-dev; \
    cargo install cargo-chef; \
    rm -rf $CARGO_HOME/registry

이 작업만 십여 분의 시간이 걸리더니, 의존하는 패키지들을 수십 분이 지나도 빌드가 안 끝납니다.
결국 한숨 자고 와서 다시 확인해봤는데도...여전히 수십 개의 패키지를 빌드해야 끝나는 상황이더라고요.

결국 Github Actions를 사용해 GitHub Container Registry에 이미지를 올려두는 방식으로 변경했습니다.

Github Actions로 3분 56초만에 빌드된 이미지

나름대로 합리적인 시간에 빌드를 끝냅니다.

예전에 npm package 배포하듯, Cargo.toml의 버전을 올리면 자동으로 tag, release를 생성하도록 했습니다.

# toml
PPREVIOUS_VERSION=$(git show HEAD~1:Cargo.toml | grep version -m 1 | cut -d '"' -f 2)
CURRENT_VERSION=$(grep version Cargo.toml -m 1 | cut -d '"' -f 2)
 
if [ "$PPREVIOUS_VERSION" != "$CURRENT_VERSION" ]; then
    echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
fi

Cargo.toml에서 version 을 찾아 version = "0.0.0" 형태에서 0.0.0만 추출되도록 했습니다.

Cargo.toml에 버전 하나 바꿨다고 무자비하게 이미지까지 registry에 push하는 건 불안정할 수 있는데, 거듭 강조하지만 미친 짓을 할 사람이 저 혼자라 관리 포인트를 줄이는 걸 최우선으로 생각했습니다.
이미지 대부분이 로컬에 저장된 상태라 잠깐 장애가 생겨도 큰 문제가 없기도 하고, 자주 실수하는 부분들에 TC까지 추가된 상태에서 대규모 장애 터뜨리면...뭐 그날이 제 은퇴일 아니겠습니까.

적용

interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
    src: string;
    forceSize?: number;
    disableWebP?: boolean;
}
 
const IMAGE_SIZE = [480, 600, 860, 1180];
 
function Image({ src, alt, width, height, forceSize, disableWebP, ...rest }: ImageProps) {
    const extension = src.split(".").pop();
    const srcWithoutExtension = src.split(".").slice(0, -1).join(".");
    const hasCdnUrl = process.env.NEXT_PUBLIC_CDN_URL !== "";
 
    if (!disableWebP && hasCdnUrl) {
        const sizes = forceSize
            ? [forceSize]
            : width && height
              ? IMAGE_SIZE.filter((size) => size < Number(width))
              : IMAGE_SIZE;
 
        return (
            <picture>
                {sizes.map((size) => (
                    <source
                        key={`webp-${size}`}
                        type="image/webp"
                        srcSet={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}.w${size}.${extension}.webp`}
                        media={forceSize ? "" : `(max-width: ${size}px)`}
                    />
                ))}
                {!forceSize && (
                    <source
                        type="image/webp"
                        srcSet={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}.${extension}.webp`}
                    />
                )}
                {sizes.map((size) => (
                    <source
                        key={size}
                        srcSet={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}.w${size}.${extension}`}
                        media={forceSize ? "" : `(max-width: ${size}px)`}
                    />
                ))}
                {!forceSize && (
                    <source srcSet={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}.${extension}`} />
                )}
                <img
                    src={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}${
                        forceSize ? `.w${forceSize}` : ""
                    }.${extension}`}
                    alt={alt || ""}
                    width={width}
                    height={height}
                    loading="lazy"
                    {...rest}
                />
            </picture>
        );
    }
 
    return (
        <img
            src={`${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}${
                forceSize && hasCdnUrl ? `.w${forceSize}` : ""
            }.${extension}`}
            srcSet={
                width && height && hasCdnUrl
                    ? IMAGE_SIZE.filter((size) => size < Number(width))
                          .map(
                              (size) =>
                                  `${process.env.NEXT_PUBLIC_CDN_URL}${srcWithoutExtension}.w${size}.${extension} ${size}w`,
                          )
                          .join(", ")
                    : undefined
            }
            alt={alt || ""}
            width={width}
            height={height}
            loading="lazy"
            {...rest}
        />
    );
}

이제 next/image 대신 사용할 Image 컴포넌트를 제작해줍니다.
너비와 높이를 불러오지 못하고, 글 목록에서처럼 고정된 너비가 필요한 때도 있어, forceSize로 너비를 강제로 지정할 수 있는 옵션 정도를 추가했습니다.

너비들을 너무 작은 크기(480px)까지 만들다 보니, 작은 기기들에서 화면 크기에 과하게 잘 맞게 최적화된 이미지가 보여 조금 자글자글한 느낌도 있어서...680px 정도를 하한으로 둬야 하나 싶습니다.
추가로 Nginx에서 응답하려고 하는 욕심에 CDN 서버 코드도 그렇고 여기도 그렇고 이미지 경로 만들어내는 코드가 조금 지저분해지긴 해서...Rust가 모든 요청을 처리하게 작업해야 할지도 고민 중입니다.

/** @type {import('next').NextConfig} */
const nextConfig = {
    // ...
    assetPrefix: process.env.NEXT_PUBLIC_FILE_CDN_URL,
    // ...
};

정적 파일들 처리를 위해선 next.config.jsassetPrefix를 추가해주면 됩니다.

결과

Lighthouse 100점 달성

소소하긴 하지만, 이제 특정 페이지들에선 다시 Lighthouse 100점을 달성했습니다.
최적화 좀 한다고 여기저기 도와드리고 다니는데...이런 간단한 페이지도 95점 이상 달성 못 해두면 아무래도 안되지 않을까 싶어서 예전처럼 대범하게 CDN 서버 내려버리고 그런 건 안 하지 않을까 싶네요.
메모리 사용량도 next/images 안 쓰는데도 메인 서버에서 돌리는 애플리케이션들 통틀어 프론트 서버가 제일 높아서...더욱이 걷어낼 수가 없어 보입니다.

그리고 개인적으로 새 pod들 rolling으로 배포하거나 하면 정적 파일들 왕창 못 불러오는 현상이 개인적으로 스트레스였는데, 그에 대한 간단한 해결책도 찾은듯해 꽤 만족스러운 결과입니다.

Report an issue