1인 회사 시스템 build

로컬은 됐는데 컨테이너서 터지는 이유 — 읽기전용 파일시스템

로컬에선 되던 코드가 컨테이너에서 AccessDeniedException으로 죽는 이유를 읽기전용 파일시스템 관점에서 짚고, 임시 파일 경로·예외 처리·볼륨 마운트로 푸는 법을 정리합니다.


로컬에선 멀쩡했는데 배포하니 죽었습니다

로컬에서 몇 번을 돌려도 잘 되던 코드가, 컨테이너 이미지로 올리자마자 AccessDeniedException을 던지며 죽었습니다. 처음엔 코드가 바뀐 것도 아닌데 왜 죽지 싶어 한참을 헤맸습니다.

로그를 따라가 보니 원인은 단순했습니다. 앱이 자기가 설치된 폴더, 그러니까 /app/build 밑에 임시 폴더를 하나 만들려다가 실패하고 있었습니다. 로컬에선 제 계정이 그 폴더에 쓰기 권한을 갖고 있으니 아무 일 없이 넘어갔는데, 컨테이너 안에서는 그 경로에 쓸 수가 없었던 것입니다.

‘로컬에선 됐는데’로 시작하는 배포 사고의 대표적인 원인 중 하나가 바로 이 파일시스템 권한 차이입니다. 코드는 그대로인데 환경이 다르니, 로컬에서 우연히 통과하던 가정이 컨테이너에서 무너집니다.

왜 컨테이너에선 자기 폴더에도 못 쓰나

컨테이너 파일시스템은 보통 읽기 전용이거나, 최소한 앱이 올라간 경로에 쓰기 권한이 없습니다. 이건 사고가 아니라 오히려 권장되는 구성입니다. 앱 바이너리가 놓인 경로를 못 쓰게 막아 두면, 침해가 일어나도 공격자가 바이너리를 변조하거나 뭔가를 심어 영속화하기가 어려워지기 때문입니다.

문제는 앱 쪽 코드가 이 전제를 모르고 있을 때 생깁니다. “임시 파일 하나 만드는 건데 설마 실패하겠어” 하는 코드가 자기 설치 경로 밑에 폴더를 만들려 들면, 읽기전용 파일시스템 앞에서 그대로 막힙니다.

여기에 한 가지가 더 겹치면 사고가 커집니다. 그 폴더 생성 코드가 try-catch 밖에 있으면, 임시 폴더 하나 못 만든 게 예외로 번져 나가면서 기능 전체가 멈춰 버립니다. 원래는 부수적인 작업이었을 뿐인데, 예외 처리가 없으니 핵심 흐름까지 같이 죽는 것입니다. 제 경우가 정확히 이랬습니다. 없어도 그만인 임시 폴더 때문에 요청 전체가 실패하고 있었습니다.

세 가지로 나눠 풀었습니다

정리하고 보니 해법은 세 갈래였습니다.

첫째, 임시 파일은 앱 경로가 아니라 java.io.tmpdir, 보통 /tmp에 만들도록 바꿨습니다. 애초에 임시 데이터를 앱 설치 경로에 두는 게 이상한 것이었고, 표준 임시 디렉토리를 쓰면 읽기전용 루트에서도 걸리지 않습니다.

// before: 앱 경로 밑에 임시 폴더 → 읽기전용 FS에서 실패
File tmp = new File("/app/build/tmp");

// after: 표준 임시 디렉토리 사용
File tmp = new File(System.getProperty("java.io.tmpdir"), "myapp");

둘째, 폴더 생성과 쓰기를 try 안으로 넣었습니다. 이렇게 하면 임시 파일 만들기가 실패하더라도 그 건만 건너뛰고, 정작 중요한 기능은 계속 굴러갑니다. 부수 작업의 실패가 전체를 끌고 내려가지 않게 하는 것이 목적입니다.

try {
    Files.createDirectories(tmp.toPath());
    // ... 임시 파일 쓰기
} catch (IOException e) {
    // 이 건만 건너뛰고 나머지는 계속 진행
    log.warn("임시 파일 생성 실패, 건너뜁니다", e);
}

셋째, 정말로 데이터를 영속적으로 남겨야 하는 경우입니다. 이건 /tmp로 될 일이 아니라, 볼륨을 마운트해서 그 경로에만 쓰도록 해야 합니다. 임시 데이터와 영속 데이터를 구분하고, 영속 데이터는 명시적으로 쓰기가 허용된 경로로 보내는 것입니다.

읽기전용을 기본으로 두고 설계하기

한 번 겪고 나니 관점이 조금 바뀌었습니다. 컨테이너의 루트 파일시스템을 읽기 전용(readOnlyRootFilesystem)으로 두는 것을 기본값으로 삼는 편이 낫다는 쪽으로요. 침해가 일어났을 때 바이너리 변조나 영속화를 막아 주는 안전장치이기 때문입니다.

대신 그 전제 위에서는, 앱이 자기가 어디에 쓰는지를 명시적으로 알고 있어야 합니다. 임시 데이터는 /tmp로, 영속 데이터는 마운트한 볼륨으로. 어디에도 쓰지 않아도 되면 가장 좋고요. “그냥 되겠지” 하고 아무 경로에나 쓰던 코드가, 읽기전용을 기본으로 두는 순간 전부 드러납니다.

결국 이 사고는 코드의 버그라기보다 환경에 대한 가정의 문제였습니다. 로컬이라는 관대한 환경이 가려 주던 가정이 컨테이너에서 걷힌 것뿐입니다. 그래서 저는 이제 새 기능을 만들 때 “이 코드가 파일을 쓰나? 쓴다면 어디에?”를 한 번 더 확인합니다. 그 경로가 /tmp도 아니고 마운트한 볼륨도 아니라면, 배포 전에 손볼 곳이 하나 생긴 것입니다.


비슷한 삽질과 그걸 정리한 기준들을 뉴스레터로도 나눕니다. 1인 개발하며 부딪힌 배포·운영 이야기가 궁금하시면 구독해 두시면 좋겠습니다.

자주 묻는 질문

컨테이너에서 AccessDeniedException이 뜨는데 로컬에선 왜 멀쩡했나요?

로컬은 앱이 실행되는 폴더에 대부분 쓰기 권한이 있습니다. 반면 컨테이너 파일시스템은 읽기 전용이거나 앱 경로에 쓰기 권한이 없는 경우가 많습니다. 그래서 앱이 자기 폴더 밑에 임시 폴더를 만들려다 권한 거부로 죽는 것입니다.

임시 파일은 어디에 만들어야 안전한가요?

java.io.tmpdir이 가리키는 경로, 보통 /tmp에 만드는 것이 안전합니다. 앱 설치 경로(/app 아래) 대신 쓰기가 허용된 임시 디렉토리를 명시적으로 쓰면 읽기전용 루트 파일시스템에서도 문제가 없습니다.

readOnlyRootFilesystem을 켜면 앱이 아예 못 쓰게 되는 것 아닌가요?

루트 파일시스템을 읽기 전용으로 두더라도, 앱이 써야 하는 경로만 별도로 열어 주면 됩니다. /tmp를 쓰기 가능한 임시 볼륨으로 두거나, 영속 저장이 필요한 경로에 볼륨을 마운트하는 식입니다. 앱이 어디에 쓰는지 명시적으로 설계하는 것이 핵심입니다.

#컨테이너#파일시스템#배포#1인 개발#보안