CI에서 빌드·테스트하고 체크섬을 확인한 실행 가능 JAR
애플리케이션 · 설치·설정 레시피
Spring Boot를 systemd 서비스로 운영하기
실행 가능한 JAR을 전용 리눅스 계정으로 격리하고, 외부 설정·안전한 systemd 유닛·로그·재시작·부팅 자동 시작·롤백까지 운영 가능한 형태로 구성합니다.BEFORE YOU START
시작 전에 준비하세요
애플리케이션이 요구하는 정확한 Java 주 버전과 메모리 계획
사용할 포트·프로필·외부 설정·비밀값 보관 위치
헬스 체크 경로와 이전 정상 JAR로 되돌리는 롤백 절차
권장 대상 Spring Boot JAR을 VM 또는 물리 서버에서 직접 운영하는 개발자·운영자
FOLLOW THE RECIPE
8단계 구성·점검 레시피
JAR·Java·서버 환경 확인
애플리케이션의 빌드 Java와 운영 JRE 호환성을 먼저 확인합니다. 예시는 Ubuntu 24.04의 OpenJDK 21을 사용하지만 프로젝트 요구 버전이 우선입니다.
cat /etc/os-releasejava -versionlscpufree -hfile /path/to/myapp.jar- 애플리케이션이 지원하는 Java 주 버전을 빌드 설정에서 확인했다.
- JAR은 CI 산출물이고 체크섬·버전을 식별할 수 있다.
- JVM과 OS가 사용할 충분한 메모리 여유가 있다.
UnsupportedClassVersionError는 보통 빌드 Java가 운영 Java보다 새 버전일 때 발생합니다. 무작정 JRE를 바꾸기보다 지원 매트릭스를 확인합니다.
기존 계정·서비스·포트·파일을 점검합니다.
기존 서비스·포트·배포 경로 점검
동일 이름의 유닛과 실행 계정, 기본 포트, 기존 JAR을 확인해 운영 중 서비스를 덮어쓰지 않습니다.
getent passwd myappsystemctl status myapp.service --no-pagersudo ss -ltnp | grep ':8080 'sudo ls -la /opt/myappdf -h /opt /var/log- myapp 이름과 8080 포트가 기존 서비스와 충돌하지 않는다.
- 기존 JAR·설정·유닛이 있으면 소유 팀과 롤백 버전을 확인했다.
- 배포 및 로그 경로에 충분한 여유 공간이 있다.
기존 서비스가 active라면 신규 설치가 아니라 무중단 또는 점검 배포입니다. 트래픽 제거와 롤백 계획을 먼저 세웁니다.
호환 JRE와 전용 실행 계정을 준비합니다.
JRE와 전용 실행 계정 준비
GUI가 필요 없는 서버용 JRE를 설치하고 로그인할 수 없는 전용 시스템 계정을 만듭니다. 계정이 이미 있으면 useradd를 다시 실행하지 않습니다.
sudo apt updatesudo apt-get install -y openjdk-21-jre-headless/usr/bin/java -versionsudo useradd --system --home-dir /opt/myapp --shell /usr/sbin/nologin myappgetent passwd myapp 결과가 없을 때만 실행합니다.sudo install -d -o root -g myapp -m 0750 /opt/myappsudo install -d -o myapp -g myapp -m 0750 /var/lib/myapp /var/lib/myapp/tmp /var/log/myappsudo install -d -o root -g myapp -m 0750 /etc/myapp- 애플리케이션 요구 Java 버전과 /usr/bin/java가 일치한다.
- myapp 계정은 nologin 셸을 사용하고 sudo 권한이 없다.
- /opt/myapp은 root:myapp 소유 0750으로 서비스 계정이 수정할 수 없다.
- myapp은 /var/lib/myapp·/var/log/myapp과 systemd가 만드는 /run/myapp만 쓸 수 있다.
한 서버에 여러 Java 버전이 있으면 systemd ExecStart에 명시한 절대 경로가 실제 지원 JRE인지 확인합니다.
JAR·외부 설정·systemd 유닛을 배치합니다.
JAR·외부 설정·systemd 유닛 구성
작업마다 고유한 <BACKUP_SUFFIX>를 정해 기존 산출물·유닛·환경 파일을 먼저 보존합니다. 비밀값은 유닛 파일이나 ExecStart 인자에 넣지 않고 권한이 제한된 외부 파일 또는 운영 비밀 저장소에서 제공합니다.
date -u +%Y%m%dT%H%M%SZ출력값을 이번 배포의 <BACKUP_SUFFIX>로 사용하고 배포 기록에 남깁니다. JAR·유닛·환경 파일 백업에는 모두 같은 값을 사용합니다.sudo cp --preserve=all --no-clobber /opt/myapp/myapp.jar /opt/myapp/myapp.jar.<BACKUP_SUFFIX>.bak기존 파일이 있을 때만 실행합니다. 대상이 이미 있으면 덮어쓰지 말고 새 고유 접미사를 만듭니다.sudo cp --preserve=all --no-clobber /etc/systemd/system/myapp.service /etc/systemd/system/myapp.service.<BACKUP_SUFFIX>.bak기존 파일이 있을 때만 실행하고 JAR과 같은 <BACKUP_SUFFIX>를 사용합니다.sudo install -o root -g myapp -m 0640 /path/to/myapp.jar /opt/myapp/myapp.jar기존 JAR을 백업했고 새 산출물의 체크섬을 확인한 뒤 실행합니다.sudo cp --preserve=all --no-clobber /etc/myapp/myapp.env /etc/myapp/myapp.env.<BACKUP_SUFFIX>.bak환경 파일이 이미 있을 때만 실행하고 JAR·유닛과 같은 <BACKUP_SUFFIX>를 사용합니다.sudo touch /etc/myapp/myapp.envsudo chown root:myapp /etc/myapp/myapp.envsudo chmod 640 /etc/myapp/myapp.envsudoedit /etc/myapp/myapp.env아래 예시를 환경에 맞게 입력하고 실제 비밀값은 가능한 한 별도 비밀 저장소에서 주입합니다.sudo touch /etc/systemd/system/myapp.servicetouch는 기존 유닛 내용을 지우지 않습니다.sudo chown root:root /etc/systemd/system/myapp.servicesudo chmod 644 /etc/systemd/system/myapp.servicesudoedit /etc/systemd/system/myapp.service아래 예시를 반영하고 ExecStart의 Java·JAR 절대 경로를 실제 환경과 대조합니다.SPRING_PROFILES_ACTIVE=prod
SERVER_ADDRESS=127.0.0.1
SERVER_PORT=8080
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=70.0 -Djava.io.tmpdir=/var/lib/myapp/tmpDB 비밀번호 같은 비밀값은 가능하면 별도 비밀 저장소에서 주입합니다. 파일에 둘 수밖에 없다면 0640 권한과 접근 감사를 유지합니다.[Unit]
Description=My Spring Boot application
Wants=network-online.target
After=network-online.target
[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/usr/bin/java -jar /opt/myapp/myapp.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=5s
UMask=0027
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp /var/log/myapp /run/myapp
[Install]
WantedBy=multi-user.target애플리케이션이 다른 경로에 써야 한다면 오류 로그를 확인하고 그 경로만 ReadWritePaths에 추가합니다. 보호 옵션은 스테이징에서 기능 검증 후 운영 적용합니다.- /opt/myapp과 JAR은 root:myapp 소유이며 각각 0750·0640으로 서비스 계정이 수정할 수 없다.
- 유닛 파일의 ExecStart는 java와 JAR의 절대 경로를 사용한다.
- 유닛 파일에 비밀번호·토큰이 없다.
- 쓰기 가능 경로를 /var/lib/myapp·/var/log/myapp·/run/myapp으로 제한했다.
- 환경 파일 접근 권한이 root와 myapp 그룹으로 제한된다.
ProtectSystem=strict 적용 후 쓰기 오류가 나면 보호 기능을 통째로 끄지 말고 필요한 단일 경로를 ReadWritePaths에 추가합니다.
유닛 문법을 검증하고 서비스를 시작합니다.
유닛 검증 후 시작과 자동 시작
systemd가 유닛을 읽기 전에 문법을 검사합니다. 시작 실패 시 자동 재시작에 맡기지 말고 첫 로그를 확인합니다.
sudo install -d -o myapp -g myapp -m 0750 /var/lib/myapp/tmpsudo systemd-analyze verify /etc/systemd/system/myapp.servicesudo systemctl daemon-reloadsudo systemctl enable --now myapp.servicesystemctl status myapp.service --no-pagersudo journalctl -u myapp.service -n 120 --no-pager- systemd-analyze verify가 오류를 출력하지 않는다.
- myapp.service가 active (running) 상태다.
- 로그에 프로필·포트·DB 연결·권한 오류가 없다.
- 부팅 자동 시작이 enabled 상태다.
Start request repeated too quickly는 원인이 아니라 반복 실패 결과입니다. journalctl에서 가장 처음 발생한 Java 예외나 권한 오류를 찾습니다.
프로세스·포트·HTTP·재부팅 동작을 검증합니다.
프로세스·포트·헬스·재부팅 검증
systemd 상태만 보지 않고 실제 포트와 HTTP 응답을 확인합니다. 애플리케이션이 Actuator를 사용한다면 인증·노출 정책에 맞는 헬스 경로를 사용합니다.
systemctl is-active myapp.servicesystemctl is-enabled myapp.servicesystemctl show myapp.service -p MainPID -p User -p Groupsudo ss -ltnp | grep ':8080 'curl --fail --silent --show-error --output /dev/null http://127.0.0.1:8080/actuator/healthActuator 헬스가 활성화된 경우 사용합니다. 아니면 서비스가 제공하는 비민감 헬스 경로로 교체합니다.sudo journalctl -u myapp.service -b --no-pager- 프로세스가 root가 아닌 myapp 계정으로 실행된다.
- 8080은 127.0.0.1에만 바인딩되어 있다.
- 헬스 체크가 정상 상태를 반환하고 핵심 의존성도 확인한다.
- 유지보수 재부팅 후 서비스·헬스·로그를 다시 검증했다.
active 상태여도 HTTP가 실패하면 포트·컨텍스트 경로·프로필·의존성 준비 상태를 확인합니다. 헬스 엔드포인트에 상세 비밀 정보를 공개하지 않습니다.
서비스 격리·비밀값·외부 노출을 최종 확인합니다.
실행 권한·비밀값·외부 노출 점검
서비스 계정과 쓰기 경로를 제한하고, 애플리케이션 포트는 NGINX 같은 역방향 프록시 뒤의 루프백으로 유지합니다.
systemd-analyze security myapp.servicestat -c '%U %G %a %n' /opt/myapp /opt/myapp/myapp.jar각각 root myapp 750, root myapp 640이 기대 결과입니다.sudo -u myapp test ! -w /opt/myapp/myapp.jarsudo -u myapp test ! -w /opt/myappstat -c '%U %G %a %n' /var/lib/myapp /var/log/myapp /run/myappstat -c '%U %G %a %n' /etc/myapp/myapp.envsudo ss -ltnp | grep ':8080 'systemctl show myapp.service -p EnvironmentFiles- myapp 계정은 로그인·sudo 권한이 없고 애플리케이션 전용이다.
- /opt/myapp과 JAR은 root:myapp 소유이고 서비스 계정이 수정할 수 없다.
- 서비스 계정의 쓰기는 /var/lib/myapp·/var/log/myapp·/run/myapp으로 제한된다.
- 비밀번호·토큰을 유닛·ExecStart·Git·로그에 넣지 않는다.
- 8080은 루프백에 바인딩하고 외부 요청은 TLS 프록시를 통해 받는다.
- Actuator는 필요한 엔드포인트만 인증된 내부망에 노출한다.
- JRE와 Spring Boot 보안 업데이트를 정기 적용하고 재부팅·재배포 검증한다.
systemd-analyze security 점수는 절대적인 취약점 판정이 아니라 격리 옵션 확인 도구입니다. 기능을 깨지 않는 범위에서 항목별로 보강합니다.
이전 JAR·유닛으로 되돌리는 절차를 확인합니다.
이전 JAR·유닛으로 안전하게 롤백
백업 목록과 배포 기록에서 이번 변경의 <BACKUP_SUFFIX>를 명시적으로 선택합니다. 기존 배포는 이전 JAR·유닛과 기존 환경 파일을 복원하고, 이번에 새로 만든 환경 파일은 먼저 고유한 .disabled 파일로 이동한 뒤에만 이전 서비스를 시작합니다. 최초 설치는 서비스와 유닛을 비활성화하고 신규 환경 파일도 .disabled로 이동하며 JAR·상태·로그는 삭제하지 않습니다.
sudo systemctl stop myapp.servicesudo find /opt/myapp -maxdepth 1 -type f -name 'myapp.jar.*.bak' -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' | sort배포 기록·파일 시각·체크섬을 대조해 복원할 한 개의 <BACKUP_SUFFIX>를 고릅니다. 최신 파일을 자동 선택하지 않습니다.sudo find /etc/systemd/system -maxdepth 1 -type f -name 'myapp.service.*.bak' -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' | sortJAR과 같은 <BACKUP_SUFFIX>의 유닛 백업을 선택합니다.sudo find /etc/myapp -maxdepth 1 -type f -name 'myapp.env.*.bak' -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' | sort이번 배포 전 환경 파일이 있었는지 배포 기록과 함께 확인합니다.sudo test -f /opt/myapp/myapp.jar.<BACKUP_SUFFIX>.bak명시적으로 고른 <BACKUP_SUFFIX>를 사용합니다. 기존 배포 롤백 분기에서 성공해야 하며 신규 설치라면 복원 명령을 건너뜁니다.sudo test -f /etc/systemd/system/myapp.service.<BACKUP_SUFFIX>.bak기존 배포 분기에서는 JAR과 같은 접미사의 두 백업이 모두 성공해야 합니다. 신규 설치라면 복원 명령을 건너뜁니다.sudo test -f /etc/myapp/myapp.env.<BACKUP_SUFFIX>.bak성공하면 기존 환경 파일 복원 분기를 실행합니다. 실패했다는 이유만으로 신규 분기를 실행하지 말고 배포 기록에서 이번 배포 전 환경 파일이 없었음을 확인합니다.sudo cp --preserve=all /etc/myapp/myapp.env.<BACKUP_SUFFIX>.bak /etc/myapp/myapp.env기존 환경 파일 백업 확인이 성공한 경우에만 실행하며 이전 JAR·유닛을 시작하기 전에 완료합니다.sudo chown root:myapp /etc/myapp/myapp.env기존 환경 파일 복원 분기에서만 실행합니다.sudo chmod 640 /etc/myapp/myapp.env기존 환경 파일 복원 분기에서만 실행합니다.sudo test ! -f /etc/myapp/myapp.env.<BACKUP_SUFFIX>.bak성공하고 배포 기록상 myapp.env가 이번 배포에서 새로 생성됐을 때만 다음 명령을 실행합니다.sudo mv --no-clobber /etc/myapp/myapp.env /etc/myapp/myapp.env.disabled.<BACKUP_SUFFIX>신규 환경 파일 분기에서 이전 JAR·유닛을 시작하기 전에 실행합니다. 대상이 이미 있으면 새 고유 접미사를 사용하며 파일을 삭제하거나 덮어쓰지 않습니다.sudo test ! -e /etc/myapp/myapp.env신규 환경 파일 분기에서 성공해야 합니다. 실패하면 이전 JAR·유닛을 시작하지 않습니다.sudo stat -c '%U %G %a %n' /etc/myapp/myapp.env기존 환경 파일 복원 분기에서 root myapp 640이 출력되는지 확인합니다.sudo cp --preserve=all /opt/myapp/myapp.jar.<BACKUP_SUFFIX>.bak /opt/myapp/myapp.jar기존 JAR 백업 확인이 성공한 경우에만 실행합니다.sudo cp --preserve=all /etc/systemd/system/myapp.service.<BACKUP_SUFFIX>.bak /etc/systemd/system/myapp.service기존 유닛 백업 확인이 성공한 경우에만 실행합니다.sudo chown root:myapp /opt/myapp/myapp.jar기존 배포 롤백 분기에서만 실행합니다.sudo chmod 640 /opt/myapp/myapp.jar기존 배포 롤백 분기에서만 실행합니다.sudo chown root:root /etc/systemd/system/myapp.service기존 배포 롤백 분기에서만 실행합니다.sudo chmod 644 /etc/systemd/system/myapp.service기존 배포 롤백 분기에서만 실행합니다.sudo systemctl disable myapp.service백업이 없는 최초 설치 롤백 분기에서 실행합니다.sudo mv --no-clobber /etc/systemd/system/myapp.service /etc/systemd/system/myapp.service.disabled.<BACKUP_SUFFIX>JAR·유닛 백업이 모두 없는 최초 설치 롤백 분기에서만 실행합니다. 대상이 이미 있으면 새 고유 접미사를 사용하며 JAR·상태·로그 파일은 삭제하지 않습니다.sudo systemctl daemon-reloadsudo systemd-analyze verify /etc/systemd/system/myapp.service기존 배포 롤백 분기에서만 실행합니다. 환경 파일은 이미 복원됐거나 신규 파일이 .disabled로 이동한 상태여야 합니다.sudo systemctl start myapp.serviceJAR·유닛 백업을 복원하고, 환경 파일도 기존 백업으로 복원했거나 신규 파일을 .disabled로 이동했음을 확인한 기존 배포 분기에서만 실행합니다.sudo journalctl -u myapp.service -n 120 --no-pager- 이전 JAR의 체크섬·버전·DB 호환성을 확인했다.
- 백업 후보 목록과 배포 기록을 대조해 한 개의 <BACKUP_SUFFIX>를 명시적으로 선택했다.
- 기존 배포는 같은 접미사의 JAR·유닛 백업을 모두 확인한 뒤 복원했다.
- 복원된 JAR은 root:myapp 0640, 유닛은 root:root 0644이다.
- 기존 환경 파일은 같은 접미사의 백업으로 복원해 root:myapp 0640을 적용했다.
- 이번 배포에서 새로 만든 환경 파일은 이전 서비스 시작 전에 고유한 .disabled 파일로 이동했다.
- 최초 설치 롤백은 서비스 자동 시작을 해제하고 유닛과 신규 환경 파일을 각각 .disabled로 이동했으며 JAR·데이터·로그는 보존했다.
- 이전 JAR·유닛이 이번 배포의 새 환경 파일을 읽지 않는 것을 확인한 뒤에만 서비스를 시작했다.
- 롤백 후 헬스 체크와 핵심 업무 기능을 다시 검증했다.
- 실패 산출물과 로그를 원인 분석용으로 별도 보존했다.
이전 JAR이 새 DB 스키마와 호환되지 않으면 단순 파일 롤백이 안전하지 않습니다. 배포 전 backward-compatible 마이그레이션과 별도 DB 복구 계획이 필요합니다.
계속 실패하면 systemd·포트·NGINX 장애 대응 가이드로 이어갑니다.
SECURITY CHECK
운영 전 마지막 보안 점검
- 서비스는 로그인할 수 없는 전용 myapp 계정으로 실행한다.
- JAR·유닛·환경 파일의 소유권과 쓰기 권한을 최소화한다.
- 비밀번호·토큰을 유닛과 명령 인자에 넣지 않는다.
- 애플리케이션 포트는 루프백에 두고 외부에는 TLS 프록시만 노출한다.
- Actuator와 관리 엔드포인트를 최소 범위로 제한한다.
- JRE·Spring Boot 보안 업데이트와 롤백 가능한 배포 이력을 유지한다.
COMMON ERRORS
자주 막히는 지점
서비스가 즉시 종료됨
- 증상
- myapp.service가 failed 또는 activating과 failed를 반복합니다.
- 가능한 원인
- Java 버전 불일치, 잘못된 프로필·환경값, DB 연결 실패 또는 파일 권한 오류가 흔합니다.
- 확인 순서
- journalctl에서 첫 Java 예외를 확인하고 /usr/bin/java, JAR, 환경 파일, 쓰기 경로를 순서대로 검증합니다.
포트가 이미 사용 중
- 증상
- Web server failed to start 또는 Address already in use 오류가 발생합니다.
- 가능한 원인
- 기존 프로세스나 다른 서비스가 같은 포트를 리스닝합니다.
- 확인 순서
- ss로 PID와 소유 서비스를 확인하고 임의 종료하지 말고 포트 소유 관계를 정리합니다.
NGINX에서 502·504 발생
- 증상
- 로컬 서비스는 시작됐지만 프록시 요청이 502 또는 504로 실패합니다.
- 가능한 원인
- upstream 주소·포트 불일치, 애플리케이션 지연 또는 방화벽·바인딩 문제가 원인일 수 있습니다.
- 확인 순서
- 로컬 헬스, 리스닝 주소, NGINX upstream, 타임아웃과 애플리케이션 로그 순서로 확인합니다.
PRIMARY REFERENCES
공식 문서
- Spring Boot 애플리케이션 systemd 설치
- Spring Boot 외부 설정 공식 문서
- Ubuntu 24.04 OpenJDK 21 런타임 패키지
- systemd 실행 환경·격리 옵션 공식 문서
설치 저장소와 지원 버전은 바뀔 수 있습니다. 검토일 이후에는 링크된 공식 문서와 현재 서버의 패키지 후보 버전을 함께 확인하세요.