← Posts

DevOps · Container · Docker

컨테이너 - 프로세스를 싸 들고 다닌다

OS를 통째로 복제하지 않고도 환경을 같이 옮기는 방법. 격리·이미지·레이어를 VM과 비교해서 잡는다.

목차
  1. 같은 서버에 두 버전을 올려야 한다
  2. 프로세스는 원래 서로가 다 보인다
  3. 격리는 커널이 해준다
  4. VM과 무엇이 다른가
  5. 이미지와 컨테이너는 다른 것이다
  6. 이미지는 층으로 쌓인다
  7. 컨테이너는 버려도 되는 물건이다
  8. 그러면 상태는 어디로 가나
  9. 실무에서 컨테이너가 실제로 바꾼 것
  10. 정리

앞 글에서 “환경을 같이 들고 다니되 OS 전체를 복제하지는 말자”까지 왔다. 그 선이 어디인지 보자.

같은 서버에 두 버전을 올려야 한다

결제 API를 v1에서 v2로 바꾸는 중이다. 그런데 일부 클라이언트가 아직 v1을 쓰고 있어서, 당분간 두 버전을 같은 서버에서 동시에 돌려야 한다.

문제는 v1이 Node 18을 쓰고 v2는 Node 22를 쓴다는 것이다. 서버에 Node는 하나만 깔린다. 하나를 맞추면 다른 하나가 안 뜬다.

억지로 맞춰볼 수는 있다. 버전 매니저를 깔고, 실행할 때 각각 다른 Node를 가리키게 하고, 포트를 8080과 8081로 나누고, 로그 경로가 겹치지 않게 손보고… 하나하나는 가능하다. 그런데 이걸 앱 열 개에 대해 하면 서버가 아무도 손댈 수 없는 물건이 된다.

원인은 하나다. 같은 OS에서 도는 프로세스들은 기본적으로 모든 걸 공유한다.

프로세스는 원래 서로가 다 보인다

리눅스에서 프로세스 둘을 띄우면 이런 것들을 같이 쓴다.

공유하는 것그래서 생기는 일
파일시스템/usr/lib의 라이브러리가 하나뿐이다
포트8080은 먼저 잡은 쪽이 임자다
프로세스 목록ps로 남의 프로세스가 다 보인다
사용자 · 권한같은 계정으로 돌면 남의 파일을 읽고 지운다
CPU · 메모리하나가 폭주하면 옆이 굶는다

앞 글의 “환경 차이” 문제와 이 “공유” 문제는 사실 한 뿌리다. 프로세스가 자기 환경을 못 가지니까, 환경이 서버에 하나뿐이고, 그래서 옮길 때마다 어긋나고 같은 서버에 둘을 못 올린다.

그러면 프로세스마다 자기 세계를 주면 어떨까. 자기만의 파일시스템, 자기만의 포트 공간, 자기만의 프로세스 목록. 그게 컨테이너다.

컨테이너는 격리된 환경에서 도는 프로세스다. 새로운 종류의 무언가가 아니라, 그냥 프로세스인데 자기 시야가 잘려 있는 프로세스다.

격리는 커널이 해준다

여기서 중요한 게 하나 있다. 이 격리는 컨테이너 도구가 만들어내는 마법이 아니라 리눅스 커널이 원래 가진 기능이다.

크게 둘이다.

  • 네임스페이스(namespace) - “무엇이 보이는가”를 자른다. 파일시스템·프로세스 목록·네트워크·호스트 이름 등을 종류별로 따로 잘라준다. 컨테이너 안에서 ps를 치면 자기 프로세스만 보이고, 자기가 PID 1이다.
  • cgroup - “얼마나 쓸 수 있는가”를 자른다. CPU·메모리 상한을 걸어서 하나가 폭주해도 옆이 안 굶는다.

이 둘이 합쳐지면 프로세스는 혼자 서버를 쓰는 것처럼 착각하게 된다. 실제로는 옆방에 다른 컨테이너가 있는데 보이지가 않는다.

참고

네임스페이스와 cgroup은 도커보다 먼저 있었다. 도커가 발명한 건 격리 기술이 아니라, 격리된 환경을 이미지로 포장하고 배포하는 방식이다. 그래서 도커 없이도 컨테이너를 만들 수 있고, 도커 아닌 런타임(containerd, Podman)도 존재한다.

VM과 무엇이 다른가

앞 글에서 VM은 “맞지만 무겁다”고 했다. 무엇이 무거운지가 여기서 갈린다.

diagramdiagram

핵심은 가운데 칸이다. VM은 게스트 OS를 각자 하나씩 들고 있고, 컨테이너는 호스트 커널을 나눠 쓴다.

가상 머신컨테이너
격리 단위가상 하드웨어 + OS프로세스
커널각자 가짐호스트 것을 공유
크기보통 기가바이트 단위보통 수십~수백 메가바이트
시작부팅을 한다프로세스를 띄운다
격리 강도강하다VM보다 약하다

컨테이너가 가벼운 이유가 여기서 나온다. 부팅할 OS가 없다. 컨테이너를 “시작한다”는 건 프로세스 하나를 실행하는 것이라, 프로세스가 뜨는 만큼 걸린다.

대신 대가가 있다. 커널을 공유하니 커널을 고를 수 없다. 리눅스 호스트에서 윈도우 컨테이너는 못 돌린다. 그리고 격리가 커널 기능에 기대고 있으니, 커널에 취약점이 생기면 격리가 뚫릴 여지도 VM보다 크다.

macOS나 윈도우에서 도커를 쓰면 리눅스 커널이 없는데 어떻게 도나 싶을 것이다. 실제로는 그 안에 작은 리눅스 VM이 하나 돌고 있고, 컨테이너는 그 VM 안에서 뜬다. “컨테이너가 VM보다 가볍다”는 말은 리눅스 위에서 돌 때의 이야기다.

이미지와 컨테이너는 다른 것이다

여기서 초중급자가 가장 많이 헷갈리는 지점을 짚는다. 이미지와 컨테이너는 같은 말이 아니다.

  • 이미지 - 실행에 필요한 파일 전부를 담은 읽기 전용 꾸러미. 앱 코드, 라이브러리, 런타임, 시스템 라이브러리가 들어 있다. 정지된 물건이다.
  • 컨테이너 - 그 이미지를 실행한 상태. 움직이는 물건이다.

클래스와 인스턴스의 관계와 같다. 이미지 하나로 컨테이너를 열 개 띄울 수 있고, 열 개는 서로 독립이다.

diagramdiagram

이 구분이 왜 중요하냐면, 옮기는 건 이미지고 돌리는 건 컨테이너이기 때문이다. 앞 글에서 “환경을 한 덩어리로 옮긴다”고 했던 그 덩어리가 바로 이미지다. 그리고 이미지에는 태그로 버전이 붙는다. myapp:1.4.2로 되돌리는 게 “서버를 어제 상태로”보다 훨씬 명확한 이유다.

이미지는 층으로 쌓인다

이미지를 통짜 파일로 만들면 문제가 생긴다. 앱 코드 한 줄을 고쳤을 뿐인데 수백 메가바이트를 통째로 다시 만들고 다시 전송해야 한다.

그래서 이미지는 **레이어(layer)**로 쌓는다. 각 레이어는 “이전 상태에서 무엇이 달라졌는가”의 기록이고, 겹쳐놓으면 하나의 파일시스템으로 보인다.

diagramdiagram

여기서 두 가지 이득이 나온다.

첫째, 안 바뀐 층은 다시 안 만든다. 앱 코드만 고치면 맨 위 레이어만 새로 만들면 된다. 아래 세 층은 그대로 재사용된다.

둘째, 같은 층은 여러 이미지가 나눠 쓴다. 앱 A와 앱 B가 같은 Node 이미지를 바탕으로 만들어졌다면, 그 아래 두 층은 디스크에 한 벌만 있으면 된다. 전송할 때도 서버에 이미 있는 층은 건너뛴다.

레이어를 어떻게 쌓느냐가 빌드 속도와 이미지 크기를 좌우하는데, 그 이야기는 실제로 이미지를 만들어보는 다음 글에서 한다.

중요

레이어는 쌓이기만 하고 지워지지 않는다. 위 레이어에서 파일을 삭제해도 아래 레이어에는 그 파일이 그대로 남아 있고, 화면에서만 안 보이게 가려진다. 그래서 “비밀 키를 넣었다가 다음 줄에서 지운” 이미지는 여전히 키를 품고 있다. 이미지에 시크릿을 넣으면 안 되는 이유다.

컨테이너는 버려도 되는 물건이다

컨테이너를 쓰기 시작하면 태도가 하나 바뀐다. 고쳐 쓰지 않고 버리고 새로 만든다.

전통적인 서버 운영은 반대였다. 서버에 문제가 생기면 들어가서 고친다. 패키지를 다시 깔고 설정을 손본다. 서버는 오래 살아남고 이름이 붙고 사연이 쌓인다.

컨테이너는 그러지 않는다. 문제가 생기면 죽이고 같은 이미지로 다시 띄운다. 새 컨테이너는 이미지가 정의한 딱 그 상태에서 시작하니, 매번 같은 상태다. 손으로 고친 흔적이 쌓일 자리가 없다.

이게 가능한 건 컨테이너 안에 잃으면 안 되는 것이 없기 때문이다. 그리고 이 성질은 무상태 이야기와 정확히 같은 것이다. 서버가 요청 사이의 기억을 안 가지면 어느 서버가 받아도 같아지고, 그래서 죽이고 늘리는 게 자유로워진다. 컨테이너는 그 성질을 실행 단위 차원에서 다시 한번 밀어붙인 것이다.

그러면 상태는 어디로 가나

당연히 데이터를 아무 데도 안 두고 살 수는 없다. 컨테이너를 버려도 되게 만들려면, 잃으면 안 되는 것을 컨테이너 밖으로 내보내야 한다.

무엇어디로
업로드된 파일외부 스토리지 또는 볼륨
데이터베이스 데이터DB 서버 (또는 볼륨을 붙인 DB 컨테이너)
세션공용 저장소 (Redis 등)
로그표준 출력으로 흘려보내고 밖에서 수집
설정값 · 시크릿환경변수나 외부 설정 저장소로 주입

컨테이너에 밖의 디렉터리를 붙여 쓰는 장치를 볼륨이라고 한다. 컨테이너가 죽어도 볼륨은 남는다.

정리하면 이렇다. 컨테이너는 계산하는 부분만 담고, 남아야 하는 것은 밖에 둔다. 그렇게 나눠야 “죽이고 다시 띄운다”가 무섭지 않은 일이 된다.

참고

서버 층에서도 같은 표를 만든다. 서버를 여러 대로 늘릴 때 무엇을 밖으로 치워야 하는지는 수평 확장 글이 다룬다. 같은 원칙이 층만 바꿔 반복된다.

실무에서 컨테이너가 실제로 바꾼 것

컨테이너가 널리 쓰이는 이유는 “환경 차이가 없어진다” 하나만이 아니다. 배포 단위가 통일되면서 그 위의 것들이 같이 단순해졌다.

배포 단위가 언어와 무관해진다. Java 앱이든 Node 앱이든 Go 앱이든, 배포하는 물건은 다 “이미지”다. 서버를 준비하는 절차가 앱마다 달랐던 게 하나로 합쳐진다. 그래서 여러 언어를 쓰는 조직일수록 이득이 크다.

로컬에서 의존 서비스를 띄우기 쉬워진다. PostgreSQL을 노트북에 설치하고 버전을 관리하는 대신, 이미지를 하나 띄우고 끝나면 지운다. 프로젝트마다 다른 버전을 쓰는 것도 문제가 안 된다.

CI가 깨끗한 상태에서 돈다. 빌드 서버에 이것저것 쌓이면서 “빌드 서버에서만 실패”하는 일이 생기는데, 매번 새 컨테이너에서 돌리면 시작 상태가 늘 같다.

롤백이 이미지 태그 교체가 된다. 앞 글에서 “어제 서버 상태를 아무도 모른다”고 했던 문제가, “어제 태그로 다시 띄운다”로 바뀐다.

그리고 이 마지막 성질이 다음 이야기로 이어진다. 죽이고 다시 띄우는 게 값싼 일이 되면, 그 일을 사람이 아니라 기계가 하게 만들 수 있다.

정리

컨테이너란격리된 환경에서 도는 프로세스
격리는 누가리눅스 커널 - 네임스페이스(무엇이 보이나) + cgroup(얼마나 쓰나)
VM과 차이VM은 게스트 OS를 각자 가짐, 컨테이너는 호스트 커널을 공유
그래서가볍고 빨리 뜨지만 커널을 고를 수 없고 격리는 더 약하다
이미지 ≠ 컨테이너이미지는 읽기 전용 꾸러미, 컨테이너는 그걸 실행한 상태
레이어이미지는 층으로 쌓인다 - 안 바뀐 층은 재사용, 지운 파일은 아래에 남는다
버려도 되는 물건상태를 밖으로 내보내면 죽이고 다시 띄우는 게 안전해진다

컨테이너가 하는 일은 결국 하나다. 실행에 필요한 것의 경계를 프로세스 단위로 그어준다.

그러면 그 경계 안에 무엇을 어떻게 담는가. 이미지는 저절로 생기지 않는다. 다음 글에서 이미지를 실제로 만들어본다.

관련 글