가장 간단한 Go 프로그램
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("hello")
for {
time.Sleep(1 * time.Second)
}
}
디버깅 심볼을 제외한 바이너리 크기는 1,229,464 바이트 (약 1.2 MiB) 입니다.
컴파일된 바이너리를 readelf 도구로 확인해보면, Program Header에 3개의 로드 가능한 세그먼트가 있음을 알 수 있습니다.
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0815e7 0x0815e7 R E 0x1000
LOAD 0x082000 0x0000000000482000 0x0000000000482000 0x091cd0 0x091cd0 R 0x1000
LOAD 0x114000 0x0000000000514000 0x0000000000514000 0x017ea0 0x0497f0 RW 0x1000
Flg 값을 통해 각각 코드 세그먼트, 상수 세그먼트, 변수 세그먼트에 해당함을 알 수 있습니다. 각각의 메모리 크기는 약 517.47 KiB, 583.20 KiB, 293.98 KiB 로, 총 약 1.2 MiB 입니다.
Go의 힙(heap)은 일반적인 프로세스 힙과 개념이 다릅니다. 크기 관계는 다음과 같습니다.
Go 힙 < 프로세스 힙
본 분석의 목적은 다음과 같습니다.
- Go 프로세스 힙에 Go 힙 외에 어떤 내용이 포함되는지 확인
- Go 프로세스의 RSS 값이 Go 힙보다 훨씬 큰 이유 파악
상세 분석
readelf -W -S main 명령어를 통해 변수 세그먼트가 다음 섹션들을 포함하고 있음을 확인할 수 있습니다. 각 섹션의 유형은 ELF 문서에 설명되어 있습니다.
- PROGBITS: ELF 파일 내용에 의해 완전히 정의된 섹션
- NOBITS: 파일 공간을 차지하지 않지만 실제 오프셋은 존재하는 섹션. 로더에게 파일 내용을 실제로 읽을 필요가 없음을 알려줍니다.
| 섹션 | 크기(바이트) | 유형 | 설명 |
|---|---|---|---|
| .go.buildinfo | 224 | PROGBITS | Go 빌드 정보 |
| .noptrdata | 67104 | PROGBITS | 포인터가 아닌 데이터 (약 65 KiB) |
| .data | 30608 | PROGBITS | 포인터 데이터일 가능성이 있는 데이터 (약 29 KiB) |
| .bss | 183 KiB | NOBITS | 초기화되지 않은 전역 정적 데이터, Go 런타임이 C 스택을 사용하는 부분으로 추정 |
| .noptrbss | 14 KiB | NOBITS | 초기화되지 않은 포인터가 아닌 데이터 |
C 언어에 존재하는 섹션 외에도 Go는 자체적인 사용자 정의 섹션을 가지고 있으며, 이들은 모두 상수 세그먼트에 포함됩니다.
| 섹션 | 설명 | 비고 |
|---|---|---|
| .typelink | 타입 링크 섹션 | 모듈에 기록된 타입 테이블 오프셋 또는 typemap의 키 |
| .itablink | 인터페이스 링크 섹션 | 인터페이스 개수에 따라 크기가 결정됨 |
| .gosymtab | 심볼 섹션 | strip 후에는 비어 있음 |
| .gopclntab | 스택 추적에 사용되는 PC와 스택 프레임 매핑 테이블 | 코드가 많을수록 섹션 크기가 커짐 |
Go에서 module은 package와 다릅니다. Go는 플러그인을 지원하므로 일반적으로 컴파일된 프로그램은 하나의 모듈만 가집니다.
readelf로 확인 가능한 로드 가능한 섹션은 12개이지만, /proc/<pid>/smap을 확인하면 최대 22개의 섹션이 관찰됩니다.
| 세그먼트 | 용도 | 출처 | 권한 | 해당 섹션 |
|---|---|---|---|---|
| 00400000-00482000 | 코드 세그먼트 | 바이너리 내 코드 | r-xp | - |
| 00482000-00514000 | 상수 세그먼트 | 바이너리 내 | r--p | - |
| 00514000-0052c000 | 변수 세그먼트 | 바이너리 내 | rw-p | .go.buildinfo, .noptrdata, .bss (일부) |
| 0052c000-0055e000 | 변수 세그먼트 | 바이너리 내 | rw-p | .bss (일부), .noptrbss |
| c000000000-c000400000 | 스택 (레지스터 내용 기반) | - | rw-p | - |
| c000400000-c004000000 | 스택 | - | ---p | - |
| 7fcce0548000-7fcce2800000 | mspan | - | rw-p | - |
| 7fcce2800000-7fcce2c00000 | mspan | - | rw-p | - |
| 7fcce2c00000-7fcce2c04000 | mspan | - | rw-p | - |
| 7fcce2c04000-7fccf317d000 | mspan | - | ---p | - |
| 7fccf317d000-7fccf317e000 | mspan | - | rw-p | - |
| 7fccf317e000-7fcd0502d000 | mspan | - | ---p | - |
| 7fcd0502d000-7fcd0502e000 | mspan | - | rw-p | - |
| 7fcd0502e000-7fcd07403000 | mspan | - | rw-p | - |
| 7fcd07403000-7fcd07404000 | mspan | - | rw-p | - |
| 7fcd07404000-7fcd0787d000 | mspan | - | ---p | - |
| 7fcd0787d000-7fcd0787e000 | mspan | - | rw-p | - |
| 7fcd0787e000-7fcd078fd000 | mspan | - | ---p | - |
| 7fcd078fd000-7fcd0795d000 | mspan | - | rw-p | - |
| 7ffdd88fd000-7ffdd891f000 | C 스택 및 시스템 스택 | - | rw-p | - |
| 7ffdd898c000-7ffdd8990000 | vvar | - | r--p | - |
| 7ffdd8990000-7ffdd8992000 | vdso | - | r-xp | - |
참고:
---p유형은 아직 물리 페이지에 매핑되지 않은 영역입니다.mspan은 필자가 추가한 명칭이며, 실제 smaps 내용에는 이런 식별자가 없습니다. 필자는 dlv 등을 통해 판단했습니다. mspan은 Go 힙을 관리하는 데이터 구조입니다.
질문: mspan과 힙 영역 사이에 왜 큰 공간이 존재하나요?
답변: mspan은 mmap(NULL, 8, PROT_READ|PROT_WRITE, MAP_ANON|MAP_PRIVATE, -1, 0) 플래그로 할당됩니다. 반면 힙 영역은 엄격한 주소 공간 제약 조건을 가지며, mmap을 통해 시작 주소를 지정하여 할당됩니다. 이렇게 하면 주소를 통해 특정 메모리 영역을 쉽게 판별할 수 있는 장점이 있습니다.
할당 대기 공간은 runtime.mheap_.arenaHints (단일 연결 리스트)로 관리됩니다. Go 1.20 이후에는 userArena가 추가되었습니다. dlv 디버깅을 통해 c000000000-c000400000 스택 세그먼트가 runtime.sysAlloc을 통해 할당되었음을 확인할 수 있습니다. 즉, 시작 주소를 지정한 mmap 방식으로 할당됩니다. 할당 후에는 runtime.mheap_.arenaHints에서 제거됩니다.
dlv에서 확인한 내용은 다음과 같습니다.
(dlv) p %x runtime.mheap_.arenaHints
*runtime.arenaHint {
_: runtime/internal/sys.NotInHeap {
_: runtime/internal/sys.nih {},},
addr: c004000000,
down: %!x(bool=false),
next: *runtime.arenaHint {
_: (*"runtime/internal/sys.NotInHeap")(0x7fcd079219c8),
addr: 1c000000000,
down: %!x(bool=false),
next: *(*runtime.arenaHint)(0x7fcd079219b0),},}
요약
위 분석을 통해 Go 프로세스의 RSS 메모리 점유는 다음과 같이 구성됩니다.
전반적으로 Go 메모리에 가장 큰 영향을 미치는 두 가지 요소는 다음과 같습니다.
- 비즈니스 로직이 복잡할수록 코드, 상수, 전역 변수가 더 많은 메모리를 차지하며, 특히 상수 영역의
.gopclntab증가가 가장 두드러집니다. kubectl을 예로 들면, 42 MiB 바이너리에서 이 섹션이 약 12 MiB를 차지합니다. - 비즈니스 동시성 수준이 높고 고루틴이 많을수록 Go 힙 사용량이 증가합니다. Go 힙에는 사용자 고루틴 스택이 포함되기 때문입니다.
Go 프로세스 RSS가 높은 이유
Go로 개발된 대부분의 애플리케이션은 컨테이너 환경을 대상으로 하며, 정적 컴파일이 요구됩니다. 결과적으로 Go 코드 세그먼트는 다른 Go 애플리케이션과 재사용될 수 없어 RSS가 높아집니다.
또한 Go는 자체 스택 관리를 구현하므로, 스택 추적(스택 덤프, GC 시나리오) 시 pclntab에 의존해야 합니다. 이 정보는 위에서 언급했듯이 많은 메모리 공간을 차지하며, 다른 Go 프로그램과 재사용될 수 없어 Go의 메모리 점유율을 더욱 악화시킵니다.
이 두 가지 요소를 고려하여 메모리 사용량을 최적화하려면 어떻게 해야 할까요?
코드 세그먼트 줄이기
동적 컴파일은 Go 커뮤니티에서 지원될 가능성이 낮습니다 (관련 이슈 참조). 따라서 이 방법으로 코드 세그먼트를 줄이는 것은 불가능합니다.
Go는 Python과 같은 스크립트 언어와 달리, 패키지 내의 특정 소스 파일에서 하나의 인터페이스만 사용하더라도 해당 패키지의 다른 모든 소스 파일이 바이너리에 포함됩니다.
만약 어떤 부분이 큰 비중을 차지하지만 실제로 사용되지 않는지 식별할 수 있다면, //go:build 빌드 태그를 사용하여 최적화할 수 있습니다. 바이너리에서 어떤 패키지가 큰 비중을 차지하는지 확인하려면 다음 도구를 사용할 수 있습니다: https://github.com/goccy/go-graphviz
GC 튜닝
또 다른 접근 방식은 GC 튜닝입니다.
Go에는 GOGC라는 하나의 튜닝 매개변수만 있지만, 관련 요소가 많으므로 별도의 문서가 필요합니다.