서버 4대에 분산 배포되는 13개 Docker 프로젝트가 있다. nginx, mariadb, redis, php-fpm, node, activemq 등 각각 독립된 Docker wrapper 프로젝트로, 공식 이미지를 기반으로 커스텀 설정과 배포 스크립트를 관리한다.
문제는 이 프로젝트들이 시간이 지나면서 제각각의 구조를 갖게 되었다는 점이다.
Table of contents
Open Table of contents
문제
.gitignore가 있는 프로젝트와 없는 프로젝트가 혼재build.sh헤더(shebang, set 옵션)가 제각각.env.example이 있는 곳과 없는 곳- 배포 스크립트 형식 불일치 (rsync vs scp, 경로 하드코딩)
.gitattributes미설정으로 CRLF/LF 문제 빈발- 일부 프로젝트에만 README 존재
이런 상태에서는 새 프로젝트를 추가하거나, 기존 프로젝트를 수정할 때마다 “이 프로젝트는 어떤 구조였지?” 확인부터 해야 한다.
표준 구조
먼저 모든 프로젝트가 따를 표준 구조를 정의했다.
docker/{project}/
├── .gitattributes # CRLF/LF 제어 (29줄 표준)
├── .gitignore # .env*, 데이터 디렉토리 제외
├── .env.example # 환경변수 템플릿 (TZ, LANG 필수)
├── build.sh # 이미지 빌드/풀 (표준 헤더)
├── run.sh # 컨테이너 실행
├── deploy.sh # Linux/macOS 배포 (rsync)
├── deploy.ps1 # Windows 배포 (scp)
├── README.md # 프로젝트 문서
├── src/ # 커스텀 설정/스크립트 (선택)
└── env/ # 서비스별 환경변수 (선택)
7단계 표준화 과정
1단계: .gitattributes 강화
모든 프로젝트에 동일한 29줄 .gitattributes를 적용했다. 핵심은 셸 스크립트의 LF 강제이다.
# 기본: 자동 감지
* text=auto
# 셸 스크립트: 항상 LF (서버에서 실행)
*.sh text eol=lf
*.bash text eol=lf
# 설정 파일: 항상 LF
*.conf text eol=lf
*.cfg text eol=lf
*.ini text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.env text eol=lf
*.env.* text eol=lf
Dockerfile text eol=lf
docker-compose*.yml text eol=lf
# Windows 스크립트: 항상 CRLF
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
# 바이너리
*.gz binary
*.tar binary
*.zip binary
Windows에서 개발하고 Linux 서버에 배포하는 환경에서 .gitattributes는 선택이 아니라 필수다. 이것이 없으면 \r\n이 섞인 셸 스크립트가 서버에서 /bin/bash^M: bad interpreter 에러를 낸다.
2단계: .gitignore 표준화
# 환경변수 (시크릿)
.env
.env.*
!.env.example
# 데이터/로그 (런타임)
data/
logs/
tmp/
# 배포 관련
*.tar.gz
.env.example만 추적하고, 실제 .env는 제외한다. 데이터 디렉토리도 제외하여 대용량 파일이 저장소에 들어가는 것을 방지한다.
3단계: build.sh 헤더 통일
#!/usr/bin/env bash
set -euo pipefail
# Docker 이미지 빌드/풀
# Usage: ./build.sh
모든 build.sh에 동일한 shebang과 set -euo pipefail을 적용했다. set -e(에러 시 중단), set -u(미정의 변수 에러), set -o pipefail(파이프 에러 전파)은 셸 스크립트의 기본 안전장치다.
4단계: deploy 스크립트 이원화
배포 환경에 따라 두 가지 스크립트를 표준화했다.
deploy.sh (Linux/macOS): rsync 기반
#!/usr/bin/env bash
set -euo pipefail
SERVER="target-server"
REMOTE_PATH="~/project-name"
rsync -avz --delete \
--exclude='.env*' \
--exclude='deploy.*' \
--exclude='.git*' \
--exclude='README.md' \
--exclude='data/' \
./ "${SERVER}:${REMOTE_PATH}/"
ssh "$SERVER" "cd ${REMOTE_PATH} && cp .env.production .env"
deploy.ps1 (Windows): scp 기반 + CRLF→LF 변환
$Server = "target-server"
$RemotePath = "~/project-name"
# scp로 파일 전송
scp -r ./src "${Server}:${RemotePath}/src"
# 셸 스크립트 실행 권한 복원
ssh $Server "chmod +x ${RemotePath}/*.sh"
5단계: .env.example 통일
# === System ===
TZ=Asia/Seoul
LANG=C.UTF-8
모든 프로젝트에 최소한 TZ와 LANG을 포함하는 .env.example을 배치했다. 서비스별 추가 변수는 env/ 디렉토리에 분리한다.
6단계: README.md 표준화
# {프로젝트명}
{한 줄 설명}
## 서버
{배포 대상 서버}
## 실행
{빌드 및 실행 명령어}
## 배포
{배포 절차}
## 설정
{주요 설정 파일 설명}
7단계: 일괄 검증
모든 프로젝트를 순회하며 표준 파일 존재 여부를 확인했다.
for dir in docker/*/; do
echo "=== $dir ==="
for f in .gitattributes .gitignore .env.example build.sh run.sh README.md; do
[ -f "$dir/$f" ] && echo " ✓ $f" || echo " ✗ $f"
done
done
결과
| 항목 | Before | After |
|---|---|---|
| .gitattributes | 0/13 | 13/13 |
| .gitignore | 5/13 | 13/13 |
| .env.example | 8/13 | 13/13 |
| build.sh 헤더 통일 | 3/13 | 13/13 |
| deploy.sh + deploy.ps1 | 6/13 | 13/13 |
| README.md | 4/13 | 13/13 |
주의했던 점
에이전트 자동화의 함정
AI 에이전트로 일괄 작업을 수행할 때 두 가지 문제가 발생했다.
- 보호된 파일 우회:
.env.example을 Write 도구로 덮어쓰기 — Edit hook의 보호 규칙을 우회한 것이다. Write와 Edit 모두 같은 보호 규칙을 적용해야 한다. - 기존 파일 오분류: untracked 파일을 모두 “에이전트가 임의 생성한 파일”로 분류하여 의도적으로 생성한 파일까지 삭제 대상에 포함시켰다. 자동화된 정리 작업에서는 반드시 파일 목록을 사전에 확인해야 한다.
버전 체계
Docker wrapper 프로젝트는 upstream 이미지 버전을 따르지 않고 독립적인 v0.x.x 버전 체계를 사용한다. wrapper의 변경(설정 수정, 스크립트 개선)과 upstream 이미지 업데이트는 별개이기 때문이다.
핵심 정리
구조 표준화의 가치는 “새 프로젝트를 추가할 때”와 “6개월 후 기존 프로젝트를 수정할 때” 나타난다. 13개 프로젝트가 모두 같은 구조를 따르면, 어떤 프로젝트를 열어도 어디에 무엇이 있는지 즉시 파악할 수 있다. 표준화 자체보다 표준을 유지하는 것이 더 어렵다 — 그래서 검증 스크립트와 규칙을 함께 만들어두는 것이 중요하다.
참고 자료
- GitHub gitattributes — GitHub 커뮤니티 .gitattributes 컬렉션