Linux

[Docker] .NET 웹 API 멀티스테이지 빌드로 초경량 배포 이미지 만들기 (Chiseled Ubuntu)

전자기린 2026. 11. 11. 12:09

 

ASP.NET Core 웹 API를 멀티스테이지 빌드로 게시하고, Chiseled Ubuntu 기반의 초경량 이미지로 배포하는 절차입니다. 최종 이미지에는 셸과 패키지 관리자가 없고, 비루트 사용자로 실행됩니다.

 

절차

 

  1. 게시 방식 선택
  2. 멀티스테이지 Dockerfile 작성
  3. .dockerignore 정리
  4. 빌드와 이미지 크기 확인
  5. 컨테이너 실행과 포트 설정
  6. 로그·진단 수집
  7. 자주 막히는 지점

 

1. 게시 방식 선택

 

  1. 프레임워크 종속(--self-contained false): 최종 이미지에 aspnet 런타임이 필요합니다. 이미지가 크지만 빌드가 빠릅니다.
  2. 자체 포함 + 단일 파일: 런타임을 이미지에 포함합니다. 베이스가 runtime-deps로 줄어듭니다.
  3. Native AOT: 런타임 없이 네이티브 실행 파일만 올립니다. 시작이 가장 빠르고 이미지가 가장 작습니다.

 

이 글은 유지보수가 쉬운 1번(프레임워크 종속)을 기준으로 하며, Chiseled 베이스는 세 방식 모두에 쓸 수 있습니다.

 

2. 멀티스테이지 Dockerfile

 

# 1단계: 빌드
FROM mcr.microsoft.com/dotnet/sdk:11.0 AS build
WORKDIR /src

# 프로젝트 파일만 먼저 복사해 restore 레이어를 캐시
COPY *.sln ./
COPY src/MyApi/MyApi.csproj src/MyApi/
RUN dotnet restore src/MyApi/MyApi.csproj

# 나머지 소스 복사 후 게시
COPY . .
RUN dotnet publish src/MyApi/MyApi.csproj -c Release -o /out \
    --no-restore /p:UseAppHost=false

# 2단계: 실행
FROM mcr.microsoft.com/dotnet/aspnet:11.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /out .

ENV ASPNETCORE_URLS=http://+:8080 \
    DOTNET_RUNNING_IN_CONTAINER=true \
    DOTNET_NOLOGO=true
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "MyApi.dll"]

 

  1. csproj만 먼저 복사하고 restore를 실행하면, 소스만 바뀐 빌드에서 패키지 복원 레이어를 재사용합니다.
  2. UseAppHost=false로 두면 불필요한 실행 파일이 이미지에 들어가지 않습니다.
  3. 최종 스테이지에는 빌드 도구와 SDK가 남지 않습니다.
  4. USER app은 Chiseled 이미지에 기본 생성된 비루트 사용자입니다. 생략해도 기본값이 비루트지만, 명시해 두면 의도가 드러납니다.

 

3. .dockerignore

 

**/bin
**/obj
**/.vs
**/.git
**/node_modules

 

bin·obj를 그대로 복사하면 이전 빌드 산출물이 이미지 레이어에 섞여 캐시가 무효화되고 이미지가 커집니다. COPY . . 앞에 반드시 정리합니다.

 

4. 빌드와 크기 확인

 

docker build -t myapi:1.0.0 .
docker images myapi --format '{{.Repository}}:{{.Tag}}\t{{.Size}}'

# 스테이지별 비교: 빌드 이미지와 최종 이미지는 별개다
docker build --target build -t myapi:build .
docker images | grep myapi

 

최종 이미지 크기는 베이스 이미지가 대부분입니다. 크기를 더 줄이려면 자체 포함 단일 파일이나 AOT로 게시하고 runtime-deps:11.0-noble-chiseled를 베이스로 바꿉니다.

 

5. 실행과 포트

 

docker run --rm -p 8080:8080 \
  -e ASPNETCORE_ENVIRONMENT=Production \
  myapi:1.0.0

curl -i http://localhost:8080/health

 

  1. Chiseled 이미지는 8080을 기본으로 열지 않습니다. ASPNETCORE_URLS 또는 ASPNETCORE_HTTP_PORTS를 명시합니다.
  2. docker run -it ... /bin/sh는 동작하지 않습니다. 셸이 없기 때문이며, 디버깅은 이미지 밖에서 합니다.
  3. 비루트 사용자로 실행되므로 호스트 볼륨을 붙일 때 쓰기 권한을 확인합니다.

 

docker run --rm --user 1654:1654 -v $(pwd)/logs:/app/logs myapi:1.0.0

 

6. 로그와 진단

 

Chiseled 이미지에는 curl, bash, apt가 없습니다. 상태 확인과 진단은 다음 방식으로 대체합니다.

 

  1. 헬스 체크: 애플리케이션에 /health 엔드포인트를 만들고 오케스트레이터가 호출하게 합니다.
  2. 로그: 표준 출력으로만 남기고 docker logs로 수집합니다.
  3. 덤프·추적: 사이드카나 dotnet-dump를 별도 이미지에서 붙입니다.

 

builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/health");

 

Kubernetes를 쓴다면 probe 설정을 이 경로로 맞춥니다.

 

livenessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 20

 

7. 자주 막히는 지점

 

  1. 문화권 데이터 오류: Chiseled 기본 이미지에는 ICU가 없습니다. InvariantGlobalization=true로 두거나, ICU가 포함된 -extra 태그를 사용합니다.
  2. 시간대 오류: tzdata가 없어 Asia/Seoul 변환이 실패할 수 있습니다. 시간대가 필요하면 -extra 태그를 쓰고 컨테이너에 TZ를 지정합니다.
  3. 파일 쓰기 실패: 비루트 사용자로 실행되므로 /app 하위에 쓰지 않습니다. 임시 파일은 /tmp를 사용합니다.
  4. 포트 미스매치: 컨테이너가 8080으로 뜨는데 80으로 매핑하면 연결이 거부됩니다. EXPOSE와 -p와 ASPNETCORE_URLS를 같은 값으로 맞춥니다.
  5. 캐시 무효화: COPY . .를 restore보다 먼저 두면 소스 한 줄만 바뀌어도 패키지 복원을 다시 합니다.

 

정리

 

SDK 이미지에서 게시하고 Chiseled ASP.NET Core 이미지로 복사하는 2단계 구성이면 최종 이미지에 빌드 도구와 셸이 남지 않습니다. csproj 우선 복사로 복원 캐시를 살리고, 8080 포트와 비루트 실행, /health 엔드포인트를 함께 맞추면 운영에서 손댈 곳이 줄어듭니다.