본문 바로가기
프로그래밍

Docker 메모리 한도를 감으로 정하지 않는 법: 최악 입력과 RSS 기울기

by me_in_sk 2026. 8. 24.
반응형
이미지 편집 엔진

Docker 메모리 한도를 감으로 정하지 않는 법: 최악 입력과 RSS 기울기

가끔 죽는 컨테이너에서 시작해 한도를 실측으로 다시 잡기까지
핵심 요약
  • 간헐 장애는 재현하려 하지 말고 흔적을 찾는다. 자동 복구가 로그를 지우는 동안에도 재시작 카운터는 쌓인다 — 그게 restarts=3을 가리켰다.
  • 재보지 않으면 손에 있던 픽스처가 한도를 정해 버린다. 먼저 정할 것은 "시스템이 받아들이는 가장 무거운 입력"이고, 입력 제약이 여러 개면 그 지점은 어느 한 상한이 아니라 상한들이 함께 걸리는 모서리에 있다.
  • 올릴지 고칠지는 값이 아니라 기울기로 가른다. 재시작 후 같은 작업을 여러 건 넣고 RSS를 찍어 평평하면 한도 상향이 해결, 우상향이면 시간 벌기다.
  • 여유는 유휴가 아니라 부하 피크 대비로 센다. 같은 한도가 유휴 기준 여유 41%, 피크 기준 9%였다.

1.가끔 죽는데 로그에 없다면 무엇부터 보나

간헐 장애는 성가시다. 502 Bad Gateway가 가끔 뜨는데, 확인하러 가면 이미 정상이고, 로그에는 아무것도 없다. 부하를 넣다가 정확히 이 상황을 만났다 — 네 번의 실험 중 두 번만 나오고, 매번 수십 초 안에 저절로 복구됐다.

이럴 때 흔히 하는 일은 밖에서 조건을 바꿔 가며 재현을 시도하는 것이다. 두 개를 세워 봤고, 둘 다 실패했다.

가설단독 재현판정
동시 부하가 원인안 죽음기각
가장 무거운 요청 하나가 원인안 죽음기각

둘 다 기각되면 남는 것은 조합인데, 조합은 밖에서 찔러서는 좁혀지지 않는다. 조합의 가짓수가 금방 커지기 때문이다. 방향을 바꿔야 한다 — 재현하려 하지 말고, 사건이 남긴 흔적을 찾는다.

컨테이너 환경에서 그 흔적이 누적되는 자리는 정해져 있다. 재시작 카운터다.

흔적은 여기 쌓인다bash
$ docker ps --format '{{.Names}}\t{{.Status}}'
web-1        Up 4 hours
worker-1     Up 2 minutes                 <- 방금 떴다. 무거운 작업을 맡는 쪽

$ docker inspect -f '{{.RestartCount}}' worker-1
3                                            <- 세 번 죽었다

502는 프록시가 "뒤쪽에 닿을 수 없다"고 정직하게 보고한 것이었다. 떨어진 것은 프록시도 웹도 아니고 그 뒤의 추론 컨테이너 하나였다. 프록시 계층의 오류 코드는 어느 계층이 죽었는지 말해 주지 않고, 그래서 앞의 두 가설이 엉뚱한 데를 겨눴다.

자동 복구는 가용성을 지키면서 진단 근거를 지운다

restart: unless-stopped가 붙어 있으면 컨테이너는 죽고 다시 뜨는 일을 조용히 반복한다. 서비스는 살아 있으니 좋은 설정이지만, 사건이 흔적 없이 지나간다. 로그는 프로세스와 함께 사라지고, 모니터링은 몇십 초짜리 구멍을 놓친다. 재시작 카운터와 .State.OOMKilled는 그 와중에도 누적되는 몇 안 되는 값이다. 간헐 장애를 볼 때 로그보다 먼저 볼 자리다.

2.한도를 올릴 일인가, 코드를 고칠 일인가

메모리로 죽은 것을 확인했다면 다음 갈림길은 하나다. 한도를 올려서 끝나는 문제인가, 아니면 올려도 죽는 시점만 미뤄지는 문제인가. 전자면 설정 한 줄이고 후자면 코드를 고쳐야 한다. 비용이 완전히 다르므로 찍어서는 안 된다.

판별은 값이 아니라 추세로 한다. 한 번 찍은 "3.5GB 쓴다"로는 둘을 구분할 수 없다. 필요한 절차는 짧다.

  1. 컨테이너를 재시작한다 — 시작점을 0으로 맞춘다
  2. 같은 크기의 작업을 여러 건 순차로 넣는다 (동시에 넣으면 겹침이 섞여 추세가 흐려진다)
  3. 일정 간격으로 RSS를 찍는다. 15초면 충분하다

실제로 재 본 값이다. 최대 크기 작업 5건을 순차로 넣었다.

시각RSS상태
18:14:04 ~ 18:15:4050MiB유휴. 모델이 지연 적재라 아직 없다
18:15:561.018GiB1번 작업 시작
18:16:122.21GiB
18:16:283.527GiB1번 완료
18:16:44 ~ 18:18:203.511 / 3.512 / 3.563 / 3.512 / 3.529 / 3.513GiB2~5번

첫 작업 하나가 50MiB에서 3.53GiB까지 다 쓰고, 이후 네 건은 평평하다. 누수라면 회차마다 조금씩이라도 올라가야 하는데 그렇지 않으므로, 이건 모델과 추론 arena의 정상 상주 크기다. 작업 시간도 첫 건 19.39초 뒤로 11.83 / 11.30 / 11.09 / 11.41초로 떨어져 같은 결론을 뒷받침한다 — 다시 적재하지 않으니 메모리도 다시 잡지 않는다.

추론 컨테이너 RSS — 9MP 작업 5건을 순차로 재시작 직후부터 15초 간격 샘플 0 1GiB 2GiB 3GiB 4GiB 옛 한도 4096MiB 누수였다면 유휴 50MiB — 모델은 지연 적재라 아직 없다 2~5번: 3.51~3.56GiB로 평평 1번 작업 완료 3527MiB 상승 추세가 없다. 누수라면 회차마다 올라가야 한다 — 이건 모델·추론 arena의 상주 크기다.
한 번의 스냅숏으로는 이 판별을 못 한다. 필요한 것은 값이 아니라 반복했을 때의 기울기다.
가져갈 규칙

평평하면 한도 상향이 해결이고, 우상향이면 시간 벌기다. 평평한 경우 올린 만큼 그대로 여유가 되므로 설정으로 끝난다. 우상향이면 얼마를 올려도 언젠가 같은 자리에 서게 되니 누수를 찾아야 한다. 이 판별에 드는 비용은 재시작 한 번과 샘플 몇 줄이다.

3.메모리 한도는 어떤 입력으로 재야 하나

컨테이너에 memory: 한 줄을 적어야 한다. 얼마로 적을 것인가. 보통은 한 번 돌려 보고 peak RSS에 여유를 얹는다. 그 방법 자체는 맞다. 틀리는 자리는 그 앞에 있다 — 어떤 입력으로 돌려 봤느냐가 답을 정한다.

그러니 질문은 "얼마나 쓰나"가 아니라 "시스템이 받아들이는 가장 무거운 입력이 무엇인가"가 먼저다. 그리고 이게 생각보다 안 뻔하다. 입력 검사가 조건 하나면 쉽지만, 대개는 여러 개가 and로 걸려 있기 때문이다.

입력 검사 — 조건이 둘이다python
if width > 3000 or height > 3000:      # ① 변 길이 상한
    reject()
if width * height > 9_000_000:        # ② 총 픽셀 상한
    reject()

②만 보고 "9MP까지 받는다"고 읽기 쉽다. 그런데 ①이 함께 걸려 있으면 9MP짜리 이미지 대부분이 거절된다.

통과 여부text
4200 x 2100  =  8.82MP   -> 거절   # 픽셀은 통과, 변에서 걸림
3722 x 2369  =  8.82MP   -> 거절   # 마찬가지
3000 x 3000  =  9.00MP   -> 통과   # 두 상한에 동시에 닿는 유일한 지점

정사각형에 가까울수록만 픽셀 상한에 닿을 수 있고, 정확히 3000×3000에서 둘이 동시에 최대가 된다. 제약이 여러 개면 최악 입력은 어느 한 상한이 아니라 상한들이 함께 걸리는 모서리에 있다. 그 지점을 찾아 픽스처를 하나 만들어 두는 것이 한도를 정하는 첫 단계다.

손에 있던 픽스처로 재면 그 크기가 곧 한도가 된다

이 서비스의 4GB 한도는 1.16MP 픽스처로 잰 peak RSS에서 나왔다. 테스트에 있던 이미지라 그걸 썼을 뿐이고, 실제로 받아들이는 최대 입력의 8분의 1이었다. 9MP 조건에서는 한 번도 측정된 적이 없었고, 부하가 걸리자 그 차이가 그대로 드러났다.

4.여유는 무엇 대비로 계산하나

한도를 정했으면 다음 질문은 "여유가 충분한가"다. 여기서 값을 하나 고르게 되는데, 그 선택이 결과를 두 배 넘게 바꾼다. 유휴 상주로 계산하느냐, 부하 피크로 계산하느냐.

같은 한도, 다른 분자text
유휴 상주 기준   3.5GiB / 6.0GiB = 58%   -> 여유 41%   # 넉넉해 보인다
부하 피크 기준   5.6GiB / 6.0GiB = 91%   -> 여유  9%   # 실제로는 아슬아슬하다

유휴 상주는 "가만히 있을 때 얼마를 붙들고 있나"이고, 부하 피크는 "가장 몰릴 때 얼마까지 가나"다. 한도를 지키는 것은 후자이므로 계산도 후자로 해야 한다. 그런데 유휴 값이 재기 쉽고 안정적이라 자꾸 그쪽을 쓰게 된다.

피크를 만드는 것이 무엇인지도 같이 세어야 한다. 동시에 살아 있을 수 있는 작업 수는 대개 세마포어나 워커 설정에 이미 적혀 있다. 검출 워커가 1이고 배경 정리 세마포어가 2라면, 최악은 그 셋이 겹치는 순간이다. 이 프로젝트에서 컨테이너를 죽인 것도 그 겹침이었다 — 검출이 3.5GB를 쥔 상태에서 정리 추론이 올라왔고, 스왑이 없으니 그 자리에서 끝났다.

혼자 써 보는 것은 반증이 되지 않는다

단일 사용자 흐름은 대개 순차라 이 조건을 만들지 않는다. 직접 써 보면 쾌적한 것이 정상이고, 그래서 "내가 써 봤는데 잘 되던데"는 없다는 증거가 못 된다. 겹침이 일어나는 조건은 손으로 만들어야 나온다 — 동시 요청을 인위적으로 넣거나, 세마포어 상한만큼 밀어 넣거나.

뒤집으면, 평범하게 한 건씩 쓰는 흐름에서는 이 조건이 아예 만들어지지 않는다.

겹치지 않을 때는 어떤지

Reflatten — 평평한 이미지를 편집 가능한 텍스트로

한 장씩 올리는 평범한 흐름에서는 이 글의 피크 조건이 만들어지지 않는다. 컨테이너를 죽인 것은 인위적 동시 부하였다.

5.이 결론이 안 통하는 조건

위 절차는 작업 크기에 상한이 있고, 동시에 도는 작업 수에도 상한이 있는 시스템을 전제한다. 그래야 "최악 입력 × 최대 겹침"이라는 유한한 최악이 존재하고, 그 위로 여유를 얹는 계산이 성립한다. 스트리밍처럼 입력 크기가 열려 있거나 워커 수가 오토스케일되는 구조라면 한도를 고정값으로 못 잡고 백프레셔 설계로 가야 한다.

그리고 고원이라고 해서 영원히 고원인 것도 아니다. 모델을 하나 더 올리거나 동시 실행 상한을 늘리면 새 고원이 생기므로, 이 판별은 그 설정이 바뀔 때마다 다시 해야 한다.

남은 비용도 적어 둔다.

선택비용그래도 이 쪽인 이유
여유를 피크 위로 642MiB만 남김 넉넉하지 않다 더 올리면 한도에 닿았을 때 그 컨테이너만 죽이고 끝낼 여유가 사라진다. 호스트 전체가 스왑으로 기어가는 것보다 하나가 죽고 재시작하는 편이 낫다
스왑을 두지 않음 한도를 넘는 순간 유예 없이 죽는다 유예가 생기면 대신 전체가 느려지고, 그건 감지도 복구도 더 어렵다. 빨리 죽고 빨리 뜨는 편을 골랐다
가장 안 쓰는 컨테이너 한도를 먼저 줄임 거기서 나중에 필요해지면 다시 손봐야 한다 웹 컨테이너는 한도 512M에 실측 84MiB로 16%만 쓴다. 모두가 한도까지 쓰는 최악을 계산할 때 이 차이가 크다

마지막으로 짚어 둘 것 하나. 메모리 피크는 CPU 한도의 함수이기도 하다. 작업이 빨리 끝나면 동시에 살아 있는 시간이 짧아져 겹침이 줄고, 실제로 이 프로젝트에서 CPU 한도를 풀자 같은 부하의 메모리 피크가 494MiB 내려갔다. 메모리가 빠듯한데 CPU에 여유가 있다면 그쪽을 먼저 볼 만하다.

6.정리

컨테이너 메모리 한도를 정할 때 쓸 수 있는 순서로 묶으면 이렇다.

  1. 간헐 장애는 재현하려 하지 말고 흔적을 찾는다. 재시작 카운터와 .State.OOMKilled는 자동 복구가 지우지 못하는 몇 안 되는 값이다.
  2. 입력 제약을 전부 나열한다. 여러 개가 and면 최악은 어느 한 상한이 아니라 그것들이 함께 걸리는 모서리에 있다. 그 지점의 픽스처를 만들어 둔다.
  3. 재시작 후 같은 작업을 여러 건 넣고 RSS 기울기를 본다. 평평하면 한도 상향이 해결이고, 우상향이면 누수를 찾아야 한다. 이 판별에 드는 비용은 재시작 한 번과 샘플 몇 줄이다.
  4. 여유는 부하 피크 대비로 센다. 유휴 상주로 재면 두 배 넘게 낙관한다. 동시에 살아 있을 수 있는 작업 수는 세마포어·워커 설정에 이미 적혀 있다.

전부 명령 한 줄이거나 샘플 몇 줄이다. 실제로 비싼 것은 재보지 않고 정한 값이 프로덕션에서 드러날 때다 — 여기서는 그게 간헐 502였고, 원인을 좁히는 데 든 시간이 처음부터 재는 데 들 시간의 몇 배였다.

이미지 편집 엔진 · 성능 측정

반응형

댓글