Ubuntu 홈 서버 배포 - EF Core Migration과 MSSQL 문제 해결
지난 글에서는 ASP.NET Core 게임 서버를 Ubuntu 홈 서버에 올리고, Nginx와 Cloudflare를 통해 <DOMAIN>으로 접속되도록 만드는 과정까지 정리했다.
이번 글은 그 과정에서 따로 발생한 DB 관련 문제를 정리하려고 한다.
처음에는 Docker 컨테이너만 잘 뜨면 앱이 바로 실행될 줄 알았다. 그런데 실제로는 방치되어있던 프로젝트를 그대로 가져오는 바람에 실제 애플리케이션이 실행되려면 DB 연결과 스키마 생성을 문제가 발생하고 나서야 해결해나갔다. 여기서 MSSQL 연결 문자열 문제, EF Core Migration 순서 문제, SQL Server의 외래키 제약 조건 문제가 이어졌다.
처음에는 “서버에 앱만 올리면 되겠지” 정도로 생각했는데, 실제로는 애플리케이션 코드와 DB 스키마가 운영 환경에서 같이 검증되어야 했다.
오늘 작업 목표
이번 작업의 목표는 단순했다.
ASP.NET Core 컨테이너 실행
↓
MSSQL 컨테이너 연결
↓
EF Core Migration 적용
↓
DB 스키마 생성
↓
애플리케이션 정상 실행
최종적으로 보고 싶었던 상태는 앱 컨테이너가 중간에 종료되지 않고 계속 실행되는 것이었다.
정상 실행 로그는 대략 이런 형태다.
Applying migration 'InitialCreate'
데이터베이스 마이그레이션 완료
Now listening on: http://[::]:8080
Application started.
이 로그가 나오면 최소한 앱이 DB에 연결했고, 필요한 테이블 생성까지 끝났다고 볼 수 있다.
현재 구조
이번 배포에서는 ASP.NET Core 앱과 MSSQL을 각각 Docker 컨테이너로 실행했다.
survivalgame container
↓
mssql-db container
여기서 중요한 점은 컨테이너 안에서의 localhost가 내가 생각하는 서버 전체의 localhost와 다르다는 것이다.
서버에서 직접 MSSQL에 접속할 때는 아래처럼 localhost를 사용할 수 있다.
-S localhost
하지만 ASP.NET Core 컨테이너 안에서 localhost라고 쓰면 그것은 MSSQL 컨테이너가 아니라 자기 자신을 의미한다.
그래서 Docker Compose 안에서 컨테이너끼리 통신할 때는 서비스 이름을 사용했다.
Server=mssql-db,1433;
최종 연결 문자열은 이런 형태로 잡았다.
SURVIVALGAME_DB_CONNECTION="Server=mssql-db,1433;Database=<DB_NAME>;User Id=****;Password=****<MSSQL_PASSWORD>;Encrypt=False;TrustServerCertificate=True;MultipleActiveResultSets=true"
MSSQL 비밀번호 문제
처음에는 MSSQL 로그인 실패가 발생했다.
Login failed for user 'sa'
이 문제는 단순히 비밀번호를 잘못 입력해서 생긴 문제처럼 보였다. 그런데 실제 원인은 조금 달랐다.
MSSQL Docker 컨테이너는 처음 초기화될 때 MSSQL_SA_PASSWORD 값을 사용한다. 이미 DB 데이터 디렉터리가 만들어진 뒤에는 .env의 비밀번호 값을 바꿔도 기존 sa 비밀번호가 자동으로 바뀌지 않는다.
이번 구성에서는 MSSQL 데이터가 서버의 디렉터리에 저장되고 있었다.
volumes:
- ../mssql/data:/var/opt/mssql
이런 방식을 Bind Mount라고 한다.
Bind Mount는 컨테이너 내부 경로를 호스트 서버의 실제 디렉터리와 연결하는 방식이다. 컨테이너를 삭제해도 호스트 디렉터리에 있는 데이터는 남는다. DB 컨테이너에서는 이 특성이 중요하다. 컨테이너를 다시 만들어도 기존 DB 데이터와 설정이 유지되기 때문이다.
이번에는 아직 테스트 배포 단계였고 보존해야 할 데이터가 없었다. 그래서 MSSQL 데이터 디렉터리를 초기화하고 다시 시작했다.
cd ~/docker/compose
docker compose down
sudo rm -rf ~/docker/mssql/data/*
docker compose up -d mssql
SQL Server는 컨테이너가 뜬 직후 바로 접속되지 않을 수 있다. 내부 초기화에 시간이 조금 걸리기 때문이다.
접속 확인은 sqlcmd로 했다.
docker exec -it mssql-db /opt/mssql-tools18/bin/sqlcmd \
-S localhost \
-U sa \
-P "<MSSQL_PASSWORD>" \
-C \
-Q "SELECT 1;"
정상이라면 아래처럼 1이 반환된다.
-----------
1
이 부분에서 중요한 건 .env를 수정하는 것과 DB 내부 상태를 바꾸는 것은 별개의 일이라는 점이다. 컨테이너 환경변수는 컨테이너 생성 시점에 들어가지만, 이미 만들어진 MSSQL 데이터는 별도로 유지된다.
SQL Server 연결 문자열 문제
비밀번호 문제를 정리한 뒤에는 TLS 관련 오류가 발생했다.
A connection was successfully established with the server,
but then an error occurred during the pre-login handshake.
The remote certificate was rejected.
처음에는 이것도 로그인 문제와 비슷하게 보였지만, 실제로는 인증서 검증 문제였다.
ASP.NET Core에서 사용하는 SQL Server 클라이언트는 연결 과정에서 암호화와 인증서 검증 설정의 영향을 받는다. Docker 컨테이너로 띄운 SQL Server의 인증서를 애플리케이션 컨테이너가 신뢰하지 못하면 연결이 실패할 수 있다.
이번에는 우선 내부 Docker 네트워크에서 테스트 배포를 진행하는 상황이었기 때문에 아래 옵션을 추가했다.
Encrypt=False;
TrustServerCertificate=True;
다만 이 설정은 임시 성격에 가깝다.
현재는 앱 컨테이너와 DB 컨테이너가 같은 서버 안에서 통신하고 있어서 우선 연결을 성공시키는 쪽을 선택했다. 하지만 나중에 운영에 더 가깝게 정리한다면 Encrypt=True로 바꾸고, SQL Server 인증서 신뢰 설정도 따로 정리하는 게 맞다.
.env를 수정한 뒤에는 컨테이너를 다시 만들어야 한다.
docker compose up -d --force-recreate survivalgame
이미 만들어진 컨테이너는 .env 변경을 자동으로 반영하지 않기 때문이다.
컨테이너에 환경변수가 제대로 들어갔는지는 아래처럼 확인했다.
docker inspect survivalgame --format '{{range .Config.Env}}{{println .}}{{end}}' | grep ConnectionStrings
앱이 재시작 중이라 docker exec가 안 될 때도 docker inspect는 사용할 수 있어서 편했다.
EF Core Migration 문제
DB 연결이 해결된 뒤에는 EF Core Migration에서 문제가 생겼다.
오류의 형태는 대략 이랬다.
Applying migration 'AddSomething'
Cannot find the object "<TABLE_NAME>"
어떤 Migration이 특정 테이블을 수정하려고 하는데, 정작 그 테이블이 아직 생성되지 않은 상태였다.
개발 과정에서 Migration이 여러 개 쌓이다 보면 이런 문제가 생길 수 있다. 특히 기능을 여러 브랜치에서 추가하고 병합하다 보면 모델 상태와 Migration 순서가 깔끔하게 맞지 않을 수 있다.
운영 데이터가 이미 중요한 상황이라면 기존 Migration을 함부로 지우면 안 된다. 그때는 Migration 순서를 보정하거나 별도 보정 Migration을 만들어야 한다.
하지만 이번은 아직 실제 운영 전 테스트 배포 단계였다. DB를 새로 만들어도 되는 상태였기 때문에 기존 Migration을 정리하고, 현재 모델 기준으로 InitialCreate를 다시 만들기로 했다.
작업은 별도 브랜치에서 진행했다.
git checkout -b fix/reset-migrations
기존 Migration 폴더를 삭제했다.
Remove-Item -Recurse -Force .\SurvivalGame.API\SurvivalGame.API\Migrations
그리고 현재 모델 기준으로 새 Migration을 생성했다.
dotnet ef migrations add InitialCreate `
--project .\SurvivalGame.API\SurvivalGame.API\SurvivalGame.API.csproj `
--startup-project .\SurvivalGame.API\SurvivalGame.API\SurvivalGame.API.csproj `
--output-dir Migrations
여기서 중요한 건 “Migration을 초기화했다”는 표현을 조심해서 써야 한다는 점이다.
이 방식은 운영 DB가 이미 있는 상황에서 쉽게 할 수 있는 방법이 아니다. 이번에는 아직 테스트 배포 단계였고, DB를 삭제해도 되는 상황이었기 때문에 선택할 수 있었다.
서버 브랜치 확인
Migration을 새로 만들고 push했는데도 서버에서는 계속 예전 Migration이 실행됐다.
여기서 문제는 코드가 아니라 서버의 Git 브랜치였다.
서버는 여전히 main 브랜치를 보고 있었고, 새 Migration 커밋은 아직 별도 브랜치에 있었다.
서버에서 브랜치를 확인했다.
cd ~/docker/game/<REPOSITORY>
git branch
git log --oneline -5
원격 브랜치도 확인했다.
git fetch --all
git branch -a
git log --oneline --all -10
새 Migration 작업은 아래 브랜치에 있었다.
origin/fix/reset-migrations
그래서 서버에서 해당 브랜치로 전환했다.
git checkout -b fix/reset-migrations origin/fix/reset-migrations
그다음 이미지를 다시 빌드했다.
docker build --no-cache -t survivalgame:test .
여기서 작업 흐름을 다시 정리할 수 있었다.
GitHub에 push
↓
서버에서 git pull 또는 branch 전환
↓
Docker 이미지 다시 build
↓
컨테이너 재생성
GitHub에 코드가 올라갔다고 해서 서버 코드가 자동으로 바뀌는 것은 아니다. 그리고 서버 코드가 바뀌었다고 해서 Docker 이미지가 자동으로 바뀌는 것도 아니다.
이 부분은 나중에 GitHub Actions로 자동화할 계획이다.
SQL Server Multiple Cascade Path 문제
새 InitialCreate가 적용되기 시작했지만, 이번에는 SQL Server의 외래키 제약 조건에서 막혔다.
오류는 이런 형태였다.
Introducing FOREIGN KEY constraint may cause cycles or multiple cascade paths.
이 오류는 SQL Server에서 여러 삭제 경로가 생길 수 있는 외래키 관계를 제한할 때 발생한다.
조금 단순화해서 보면 이런 상황이다.
Parent
├── ChildA
└── ChildB
└── ChildA 참조
어떤 부모 데이터가 삭제될 때 여러 경로를 통해 같은 자식 테이블에 삭제나 NULL 처리 동작이 전달될 수 있다. SQL Server는 이런 구조를 허용하지 않는 경우가 있다.
이번 프로젝트에서도 특정 엔티티가 같은 대상 테이블을 여러 개의 선택적 외래키로 참조하고 있었다. 각각의 참조에 SetNull 삭제 동작이 걸려 있었고, 이 설정이 SQL Server에서 Multiple Cascade Path 문제를 만들었다.
기존 설정은 대략 이런 형태였다.
entity.HasOne(e => e.RelatedItem1)
.WithMany()
.HasForeignKey(e => e.RelatedItem1Id)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.RelatedItem2)
.WithMany()
.HasForeignKey(e => e.RelatedItem2Id)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.RelatedItem3)
.WithMany()
.HasForeignKey(e => e.RelatedItem3Id)
.OnDelete(DeleteBehavior.SetNull);
SetNull은 참조 대상이 삭제될 때 외래키 값을 NULL로 바꾸는 동작이다. 겉으로 보기에는 안전해 보이지만, SQL Server 입장에서는 여러 외래키 경로를 통해 같은 테이블에 삭제 관련 동작이 생기는 구조가 될 수 있다.
그래서 이 관계들은 DB가 자동으로 처리하지 않도록 NoAction으로 바꿨다.
entity.HasOne(e => e.RelatedItem1)
.WithMany()
.HasForeignKey(e => e.RelatedItem1Id)
.OnDelete(DeleteBehavior.NoAction);
entity.HasOne(e => e.RelatedItem2)
.WithMany()
.HasForeignKey(e => e.RelatedItem2Id)
.OnDelete(DeleteBehavior.NoAction);
entity.HasOne(e => e.RelatedItem3)
.WithMany()
.HasForeignKey(e => e.RelatedItem3Id)
.OnDelete(DeleteBehavior.NoAction);
이렇게 바꾸면 참조 대상이 삭제될 때 DB가 자동으로 외래키를 NULL로 바꾸지 않는다. 대신 필요한 정리는 애플리케이션 코드에서 직접 처리해야 한다.
이번 경우에는 자동 삭제 동작보다 Migration이 SQL Server에서 안정적으로 적용되는 것이 우선이었기 때문에 NoAction이 더 적절했다.
수정 후 Migration을 다시 생성했다.
Remove-Item -Recurse -Force .\SurvivalGame.API\SurvivalGame.API\Migrations
dotnet ef migrations add InitialCreate `
--project .\SurvivalGame.API\SurvivalGame.API\SurvivalGame.API.csproj `
--startup-project .\SurvivalGame.API\SurvivalGame.API\SurvivalGame.API.csproj `
--output-dir Migrations
생성된 Migration에서 해당 외래키에 onDelete가 붙지 않는 것을 확인했다.
table.ForeignKey(
name: "FK_SomeTable_OtherTable_RelatedItemId",
column: x => x.RelatedItemId,
principalTable: "OtherTable",
principalColumn: "Id");
이 상태로 다시 적용하니 Multiple Cascade Path 오류는 발생하지 않았다.
DB 삭제 후 다시 검증
Migration을 처음부터 검증하기 위해 기존 DB를 삭제하고 다시 실행했다.
cd ~/docker/compose
docker compose stop survivalgame
DB 삭제는 sqlcmd로 처리했다.
docker exec -it mssql-db /opt/mssql-tools18/bin/sqlcmd \
-S localhost \
-U sa \
-P "<MSSQL_PASSWORD>" \
-C \
-Q "
IF DB_ID(N'<DB_NAME>') IS NOT NULL
BEGIN
ALTER DATABASE [<DB_NAME>] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [<DB_NAME>];
END
"
그다음 앱 컨테이너를 다시 만들었다.
docker compose rm -f survivalgame
docker compose up -d survivalgame
로그는 아래 명령으로 확인했다.
docker logs -f survivalgame
여기서 -f는 follow의 의미다. 로그를 한 번 출력하고 끝내는 것이 아니라, 새 로그가 생길 때마다 계속 따라간다.
그래서 세션이 멈춘 것처럼 보일 수 있지만, 실제로는 로그를 계속 보고 있는 상태다.
빠져나오려면 Ctrl + C를 누르면 된다.
정상 로그는 아래처럼 나왔다.
Applying migration 'InitialCreate'
데이터베이스 마이그레이션 완료
Now listening on: http://[::]:8080
Application started.
최종 확인
마지막으로 컨테이너와 HTTP 응답을 확인했다.
docker ps
앱 컨테이너와 DB 컨테이너가 실행 중인지 확인했다.
survivalgame Up
mssql-db Up
서버 내부에서 앱이 응답하는지도 확인했다.
curl -I http://127.0.0.1:18080
정상 결과는 아래처럼 나온다.
HTTP/1.1 200 OK
Server: Kestrel
외부 도메인도 확인했다.
curl -I https://<DOMAIN>
정상적으로 200 OK가 나오면 Nginx와 Cloudflare를 거친 외부 요청까지 처리되고 있는 상태다.
오늘 배운 점
이번 작업에서 가장 크게 느낀 건 EF Core Migration이 단순히 코드에서 생성되는 파일이 아니라, 실제 DB에 적용되는 스키마 변경 절차라는 점이다.
개발 중에는 모델만 보고 지나갈 수 있지만, 배포 환경에서는 DBMS의 제약 조건까지 같이 봐야 한다.
또 하나는 테스트 배포와 운영 배포를 구분해야 한다는 점이다.
이번에는 테스트 단계였기 때문에 MSSQL 데이터를 초기화하고 Migration을 다시 만들 수 있었다. 하지만 운영 데이터가 쌓인 뒤에는 같은 방식으로 처리하면 안 된다. 그때는 기존 Migration 이력을 유지하면서 별도의 보정 Migration을 만들어야 한다.
마지막으로 서버 브랜치와 Docker 이미지 상태를 항상 확인해야 한다는 것도 다시 확인했다.
git branch
git log --oneline -5
docker ps
docker logs --tail 100 survivalgame
서버가 어느 브랜치를 보고 있는지, 어떤 이미지가 실행 중인지 확인하지 않으면 로컬에서 수정한 내용과 서버에서 실행 중인 내용이 달라질 수 있다.
다음 작업
현재는 배포할 때 서버에 직접 접속해서 아래 과정을 수행하고 있다.
git pull
docker build --no-cache -t survivalgame:test .
docker compose up -d --force-recreate survivalgame
이 방식은 직접 눈으로 확인할 수 있다는 장점은 있지만, 매번 반복하기에는 번거롭다.
다음 작업은 GitHub Actions 자동 배포다.
목표는 아래 흐름이다.
로컬에서 push
↓
GitHub Actions 실행
↓
SSH로 홈 서버 접속
↓
서버에서 git pull
↓
Docker 이미지 빌드
↓
Docker Compose 재실행
↓
curl로 배포 확인
그리고 실제 데이터가 쌓이기 전에 MSSQL 백업 방식도 정리해야 한다.
이번에는 DB를 지우고 다시 만들 수 있었지만, 다음부터는 데이터가 있는 상태에서 안전하게 업데이트하는 방법을 같이 고민해야 한다.