서명·checksum을 검증한 JAR과 이전 정상 release
JAVA APPLICATION
Java 21·Spring Boot·NGINX 운영 구성
Ubuntu 24.04의 OpenJDK 21 runtime에 검증된 Spring Boot JAR을 비 root systemd 서비스로 배치하고 loopback NGINX TLS reverse proxy, 외부 설정, 권한과 롤백을 구성합니다.BEFORE YOU START
시작 전에 준비하세요
전용 <APP_USER>·<APP_GROUP>, loopback <APP_PORT>, <APP_DOMAIN>
유효한 TLS 인증서와 private key의 제한된 경로
DB migration·외부 설정·비밀의 버전별 rollback 계획
권장 대상 Spring Boot JAR을 단일 Linux 호스트에 최소 권한 서비스와 TLS reverse proxy로 운영할 애플리케이션·서버 담당자
FOLLOW THE RECIPE
8단계 구성·점검 레시피
Java·OS·JAR·자원 기준 확인
Ubuntu 24.04와 Java 21 package 후보, 현재 서비스와 자원을 확인합니다. JDK diagnostic tool이 필요하면 별도 승인하며 운영 실행에는 headless JRE를 기본으로 합니다.
cat /etc/os-release
java -version 2>&1 || true
readlink -f /usr/bin/java 2>/dev/null || trueapt-cache policy openjdk-21-jre-headless nginxgetent passwd <APP_USER>
id <APP_USER>
free -h
df -hT / /opt /varsystemctl status <APP_SERVICE>.service nginx.service --no-pager -l
sudo ss -lntp | grep -E ':(<APP_PORT>|80|443)\b'- Ubuntu 24.04와 OpenJDK 21 후보를 확인했습니다.
- JAR·설정·DB schema의 정상 rollback 묶음이 있습니다.
- heap·native memory·page cache를 포함한 host 여유를 계산했습니다.
다른 Java major, 충돌 port, 부족한 메모리 또는 rollback 불가 migration이면 설치를 중단합니다.
현재 파일·TLS·NGINX server block 충돌을 확인합니다.
artifact·TLS·기존 설정 사전 점검
배포 artifact의 checksum과 JAR 구조, TLS 인증서 만료, 기존 unit·NGINX 설정을 변경 전에 확인합니다. 설정 원문과 환경 변수는 외부로 출력하지 않습니다.
sha256sum <REVIEWED_APP_JAR>unzip -p <REVIEWED_APP_JAR> META-INF/MANIFEST.MF | grep -E '^(Main-Class|Start-Class|Spring-Boot-Version):'openssl x509 -in /etc/letsencrypt/live/<APP_DOMAIN>/fullchain.pem -noout -subject -issuer -dates -fingerprint -sha256sudo nginx -T 2>&1 | grep -E 'server_name|listen|proxy_pass'전체 nginx -T 출력에는 내부 host와 인증서 경로가 있으므로 제한된 터미널에서만 확인합니다.- JAR checksum이 승인 artifact와 일치합니다.
- 인증서 이름·만료와 domain이 일치합니다.
- 같은 server_name·port의 기존 설정 소유자를 확인했습니다.
artifact·인증서·server_name이 다르면 임시 우회 없이 올바른 입력을 다시 준비합니다.
Java·NGINX를 고정 package로 설치합니다.
OpenJDK 21 runtime과 NGINX 설치
Ubuntu 서명 저장소의 후보를 검토하고 승인한 package version을 명시해 설치합니다. 자동 major 변경을 허용하지 않습니다.
sudo apt update
apt-cache policy openjdk-21-jre-headless nginxsudo apt install openjdk-21-jre-headless=<APPROVED_JAVA21_PACKAGE_VERSION> nginx=<APPROVED_NGINX_PACKAGE_VERSION>/usr/bin/java -version 2>&1
/usr/sbin/nginx -v 2>&1
readlink -f /usr/bin/java- java major가 21이고 package 공급원이 Ubuntu입니다.
- NGINX version과 보안 업데이트 상태를 기록했습니다.
- unattended upgrade 정책이 service compatibility와 맞습니다.
예상하지 않은 package 제거·downgrade가 제안되면 설치를 취소하고 apt policy를 검토합니다.
전용 계정·디렉터리·unit·외부 설정·NGINX를 백업하고 편집합니다.
systemd·외부 설정·NGINX 백업과 편집
JAR은 root 소유, 서비스 그룹 읽기 전용으로 설치하고 쓰기 경로를 분리합니다. Spring 설정과 NGINX server block을 각각 백업한 뒤 편집합니다.
sudo install -d -o root -g <APP_GROUP> -m 0750 /opt/<APP_SLUG> /etc/<APP_SLUG>
sudo install -d -o <APP_USER> -g <APP_GROUP> -m 0750 /var/lib/<APP_SLUG> /var/log/<APP_SLUG>
sudo install -o root -g <APP_GROUP> -m 0640 <REVIEWED_APP_JAR> /opt/<APP_SLUG>/<APP_SLUG>.jarif sudo test -f /etc/systemd/system/<APP_SERVICE>.service; then sudo cp --archive --no-clobber /etc/systemd/system/<APP_SERVICE>.service /etc/systemd/system/<APP_SERVICE>.service.<BACKUP_SUFFIX>; fi
sudoedit /etc/systemd/system/<APP_SERVICE>.serviceif sudo test -f /etc/<APP_SLUG>/application.yaml; then sudo cp --archive --no-clobber /etc/<APP_SLUG>/application.yaml /etc/<APP_SLUG>/application.yaml.<BACKUP_SUFFIX>; fi
sudoedit /etc/<APP_SLUG>/application.yamlif sudo test -f /etc/nginx/sites-available/<APP_DOMAIN>.conf; then sudo cp --archive --no-clobber /etc/nginx/sites-available/<APP_DOMAIN>.conf /etc/nginx/sites-available/<APP_DOMAIN>.conf.<BACKUP_SUFFIX>; fi
sudoedit /etc/nginx/sites-available/<APP_DOMAIN>.conf[Unit]
Description=<APP_SERVICE> Spring Boot
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=<APP_USER>
Group=<APP_GROUP>
WorkingDirectory=/var/lib/<APP_SLUG>
ExecStart=/usr/bin/java -XX:+ExitOnOutOfMemoryError -jar /opt/<APP_SLUG>/<APP_SLUG>.jar --spring.config.additional-location=file:/etc/<APP_SLUG>/
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/<APP_SLUG> /var/log/<APP_SLUG>
UMask=0027
[Install]
WantedBy=multi-user.targetDB password를 ExecStart나 Environment에 넣지 않습니다. 조직 secret store 또는 권한 제한 configtree 파일을 사용합니다.server:
address: 127.0.0.1
port: <APP_PORT>
forward-headers-strategy: native
management:
server:
address: 127.0.0.1
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never실제 업무·DB secret은 예시에 넣지 않습니다. proxy 신뢰 경계와 Actuator 노출은 애플리케이션 버전에 맞춰 검증합니다.server {
listen 80;
server_name <APP_DOMAIN>;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name <APP_DOMAIN>;
ssl_certificate /etc/letsencrypt/live/<APP_DOMAIN>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<APP_DOMAIN>/privkey.pem;
location / {
proxy_pass http://127.0.0.1:<APP_PORT>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}승인된 인증서 경로를 사용하고 TLS 생성·갱신은 NGINX·Certbot 가이드에서 검증합니다.- 기존 파일은 고유한 suffix로 백업했고, 원본이 없던 파일은 신규 생성으로 변경 기록에 남겼습니다.
- JAR·config는 root 소유 0640이고 서비스 계정은 쓰기 data·log만 가집니다.
- Spring은 loopback에 bind하고 NGINX만 외부 TLS를 종료합니다.
- 명령행과 unit에 비밀 literal이 없습니다.
서비스 계정이 JAR을 수정할 수 있거나 app port가 공개 bind면 시작하지 않습니다.
systemd·NGINX 문법을 검증하고 순서대로 시작합니다.
문법 검증 후 Java와 NGINX 시작
systemd가 unit과 sandbox를 해석하는지, Spring config가 로드되는지, NGINX 문법이 맞는지 검증한 뒤 외부 traffic 순서로 시작합니다.
sudo systemd-analyze verify /etc/systemd/system/<APP_SERVICE>.service
sudo nginx -tsudo systemctl daemon-reload
sudo systemctl enable --now <APP_SERVICE>.servicecurl -fsS http://127.0.0.1:<APP_PORT>/<APP_HEALTH_PATH>sudo ln -s /etc/nginx/sites-available/<APP_DOMAIN>.conf /etc/nginx/sites-enabled/<APP_DOMAIN>.conf
sudo nginx -t && sudo systemctl reload nginx.service동일 이름 link가 없는 신규 구성에만 생성하고 reload 전 문법 검사가 성공해야 합니다.- systemd unit과 nginx -t가 성공합니다.
- Spring local health가 NGINX 전환 전에 성공합니다.
- 두 서비스가 active이고 restart loop가 없습니다.
local health 실패 상태에서 NGINX를 reload하지 말고 journal의 첫 원인을 확인합니다.
내부·외부 health와 실제 forward header·로그를 검증합니다.
local·TLS·업무 smoke test 검증
local Spring과 외부 TLS를 분리해 확인하고 인증서·header·status code, service restart count와 핵심 업무를 검증합니다.
systemctl status <APP_SERVICE>.service nginx.service --no-pager -l
systemctl show <APP_SERVICE>.service -p NRestarts -p ExecMainStatuscurl -fsS -D - http://127.0.0.1:<APP_PORT>/<APP_HEALTH_PATH> -o /dev/nullcurl -fsS -I https://<APP_DOMAIN>/
openssl s_client -connect <APP_DOMAIN>:443 -servername <APP_DOMAIN> -verify_return_error </dev/nulljournalctl -u <APP_SERVICE>.service -u nginx.service --since '-15 min' --no-pager개인정보·token·내부 주소를 외부 공유 전에 마스킹합니다.- local health와 외부 TLS가 모두 성공합니다.
- NRestarts가 증가하지 않고 ExecMainStatus가 0입니다.
- Host·X-Forwarded-* 처리와 redirect가 예상대로입니다.
- 핵심 read/write 업무와 오류율·지연이 기준 범위입니다.
502이면 Spring process·local port, redirect loop면 forward header와 framework proxy trust를 분리합니다.
JAR·설정·TLS key·Actuator·service sandbox를 점검합니다.
권한·Actuator·TLS·JVM 진단 파일 점검
서비스 계정이 code를 바꿀 수 없고 app·management port가 loopback인지 확인합니다. heap dump·로그·config는 고객 데이터와 비밀을 포함할 수 있어 접근·보존을 제한합니다.
stat -Lc '%a %U:%G %n' /opt/<APP_SLUG>/<APP_SLUG>.jar /etc/<APP_SLUG>/application.yaml /etc/systemd/system/<APP_SERVICE>.servicesystemd-analyze security <APP_SERVICE>.service --no-pagersudo ss -lntp | grep -E ':(<APP_PORT>|80|443)\b'
sudo ufw status verbosesudo stat -Lc '%a %U:%G %n' /etc/letsencrypt/live/<APP_DOMAIN>/privkey.pem- JAR·설정은 root 소유이며 서비스 그룹 읽기만 가능합니다.
- app·management port는 loopback이고 외부에는 443만 노출합니다.
- Actuator는 health·info 최소 범위이며 detail을 공개하지 않습니다.
- TLS private key·configtree·dump·log의 접근과 보존이 제한됩니다.
- JVM·Spring·NGINX 보안 update와 dependency scan을 운영 기준에 포함했습니다.
- <...> 자리표시자는 승인된 리터럴 값으로 직접 치환하고 외부 입력으로 shell 명령을 조립하거나 eval하지 않습니다.
공개 Actuator, 쓰기 가능한 JAR, 명령행 비밀 또는 보호되지 않은 dump 경로가 있으면 공개 전환을 중단합니다.
세 파일과 JAR·DB schema를 이전 정상 묶음으로 복구할 준비를 확인합니다.
이전 JAR·설정·unit·NGINX로 복구
현재 파일 checksum과 journal을 보존하고 DB migration 호환성을 확인한 뒤 승인된 이전 release와 명시한 backup suffix만 사용해 복원합니다.
sha256sum /opt/<APP_SLUG>/<APP_SLUG>.jar /etc/<APP_SLUG>/application.yaml /etc/systemd/system/<APP_SERVICE>.service /etc/nginx/sites-available/<APP_DOMAIN>.conf
journalctl -u <APP_SERVICE>.service --since '-30 min' --no-pagersudo install -o root -g <APP_GROUP> -m 0640 /opt/<APP_SLUG>/releases/<APPROVED_PREVIOUS_RELEASE>.jar /opt/<APP_SLUG>/<APP_SLUG>.jarif sudo test -f /etc/<APP_SLUG>/application.yaml.<BACKUP_SUFFIX>; then sudo cp --archive /etc/<APP_SLUG>/application.yaml.<BACKUP_SUFFIX> /etc/<APP_SLUG>/application.yaml; fi
if sudo test -f /etc/systemd/system/<APP_SERVICE>.service.<BACKUP_SUFFIX>; then sudo cp --archive /etc/systemd/system/<APP_SERVICE>.service.<BACKUP_SUFFIX> /etc/systemd/system/<APP_SERVICE>.service; fi
if sudo test -f /etc/nginx/sites-available/<APP_DOMAIN>.conf.<BACKUP_SUFFIX>; then sudo cp --archive /etc/nginx/sites-available/<APP_DOMAIN>.conf.<BACKUP_SUFFIX> /etc/nginx/sites-available/<APP_DOMAIN>.conf; fisudo systemctl disable --now <APP_SERVICE>.service
sudo install -d -o root -g root -m 0700 <APPROVED_QUARANTINE_DIR>
if sudo test -f /etc/<APP_SLUG>/application.yaml; then sudo mv --no-clobber /etc/<APP_SLUG>/application.yaml <APPROVED_QUARANTINE_DIR>/application.yaml.new; fi
if sudo test -f /etc/systemd/system/<APP_SERVICE>.service; then sudo mv --no-clobber /etc/systemd/system/<APP_SERVICE>.service <APPROVED_QUARANTINE_DIR>/<APP_SERVICE>.service.new; fi
if sudo test -f /etc/nginx/sites-available/<APP_DOMAIN>.conf; then sudo mv --no-clobber /etc/nginx/sites-available/<APP_DOMAIN>.conf <APPROVED_QUARANTINE_DIR>/<APP_DOMAIN>.conf.new; fi
if sudo test -L /etc/nginx/sites-enabled/<APP_DOMAIN>.conf; then sudo mv --no-clobber /etc/nginx/sites-enabled/<APP_DOMAIN>.conf <APPROVED_QUARANTINE_DIR>/<APP_DOMAIN>.conf.link; fi
sudo systemctl daemon-reload
sudo nginx -t && sudo systemctl reload nginx.servicebackup이 없고 변경 기록으로 이 레시피가 신규 생성한 정확한 파일·link임이 확인될 때만 A 대신 실행합니다. package·JAR·data는 purge하지 않습니다.if sudo test -f /etc/systemd/system/<APP_SERVICE>.service; then sudo systemd-analyze verify /etc/systemd/system/<APP_SERVICE>.service; sudo systemctl daemon-reload; sudo systemctl restart <APP_SERVICE>.service; fi
sudo nginx -t
if systemctl is-active --quiet nginx.service; then sudo systemctl reload nginx.service; fi
if systemctl is-active --quiet <APP_SERVICE>.service; then curl -fsS https://<APP_DOMAIN>/<APP_HEALTH_PATH>; fi- 복구 JAR·설정·schema가 같은 정상 release입니다.
- Java 21과 unit·NGINX 문법이 성공합니다.
- health·핵심 업무·오류율이 회복됐습니다.
- 실패 파일과 로그를 분석 전까지 삭제하지 않았습니다.
JAR rollback은 외부 DB migration·message format·파일 데이터를 자동 복구하지 않습니다. 데이터 호환성 확인이 우선입니다.
원인과 적용·복구 checksum, 시간, 지표를 기록하고 staging 검증을 추가합니다.
SECURITY CHECK
운영 전 마지막 보안 점검
- Ubuntu 서명 저장소의 OpenJDK 21·NGINX 고정 package를 사용합니다.
- 서비스 계정은 비 root이고 JAR·config·unit을 수정할 수 없습니다.
- app·management port는 loopback, 외부 traffic은 TLS NGINX만 받습니다.
- Actuator·header trust·TLS key·비밀 configtree를 최소 권한으로 제한합니다.
- heap dump·GC log·journal에는 민감 데이터가 있을 수 있어 0600과 보존 정책을 적용합니다.
- <...> 자리표시자는 승인된 리터럴 값으로 직접 치환하고 외부 입력으로 shell 명령을 조립하거나 eval하지 않습니다.
COMMON ERRORS
자주 막히는 지점
NGINX 502 Bad Gateway
- 증상
- 외부 요청은 502지만 NGINX는 active입니다.
- 가능한 원인
- Spring service 중단, 잘못된 loopback port 또는 proxy_pass 불일치일 수 있습니다.
- 확인 순서
- 먼저 local health·ss·journal을 확인하고 NGINX timeout부터 늘리지 않습니다.
Spring Boot Java heap space
- 증상
- service가 OutOfMemoryError 뒤 재시작합니다.
- 가능한 원인
- heap 부족·누수·container/host memory 한계 또는 native memory 문제일 수 있습니다.
- 확인 순서
- JVM OOM 가이드에서 OS OOM과 Java OOM을 구분하고 dump 공간·민감도를 확인합니다.
HTTPS redirect loop
- 증상
- 브라우저가 리디렉션 반복 오류를 냅니다.
- 가능한 원인
- X-Forwarded-Proto와 Spring proxy trust가 실제 TLS 종료 경계와 맞지 않을 수 있습니다.
- 확인 순서
- local·external header를 비교하고 신뢰 proxy 범위를 좁힌 상태에서 설정을 수정합니다.
PRIMARY REFERENCES
공식 문서
- Ubuntu 24.04 · openjdk-21-jre-headless 패키지
- Spring Boot · systemd 서비스 설치
- Spring Boot · Externalized Configuration
- NGINX · Reverse Proxy 공식 가이드
설치 저장소와 지원 버전은 바뀔 수 있습니다. 검토일 이후에는 링크된 공식 문서와 현재 서버의 패키지 후보 버전을 함께 확인하세요.