서비스가 멈춘 뒤에 확인하는 운영을 끝내기

Linux에서 애플리케이션을 직접 nohup이나 셸 스크립트로 실행하면 프로세스가 종료된 시점, 종료 원인, 재시작 여부를 일관되게 관리하기 어렵습니다. systemd 서비스 유닛으로 등록하면 부팅 시 시작, 비정상 종료 후 재시작, 로그 수집, 실행 권한 제한을 운영 규칙으로 고정할 수 있습니다. 중요한 점은 무조건 재시작을 켜는 것이 아니라, 장애가 반복될 때 서버 자원을 소모하지 않도록 재시작 조건과 제한을 함께 정하는 것입니다.

서비스 계정과 실행 파일을 분리한다

웹 애플리케이션을 root로 실행하면 애플리케이션 취약점이 곧 서버 전체 권한으로 이어질 수 있습니다. 서비스 전용 계정을 만들고, 실행 파일과 설정 파일의 소유권을 분리합니다. 배포 파일은 읽기 전용으로 두고, 업로드나 캐시처럼 쓰기가 필요한 경로만 별도로 지정하는 방식이 안전합니다. 환경 변수에 비밀값을 직접 넣기보다 권한이 제한된 EnvironmentFile을 사용하고, 해당 파일은 저장소와 배포 산출물에 포함하지 않아야 합니다.

sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
sudo install -d -o myapp -g myapp /opt/myapp /var/lib/myapp
sudo install -m 640 -o root -g myapp /etc/myapp/myapp.env

서비스 계정은 로그인 셸이 없어야 하며, 애플리케이션이 실제로 써야 하는 데이터 디렉터리만 소유합니다. 로그를 파일로 직접 쓴다면 로그 회전 정책까지 함께 관리해야 하지만, 가능하면 표준 출력과 표준 오류로 기록하고 journald에서 수집하는 편이 장애 시점 분석과 권한 관리에 유리합니다.

재시작 정책이 포함된 유닛 파일

다음 예시는 HTTP 서버나 워커 프로세스에 적용할 수 있는 기본 형태입니다. ExecStart에는 셸을 거치지 않는 절대 경로를 사용합니다. 셸 문법, 파이프, 리다이렉션이 필요하다면 별도 스크립트로 분리해 테스트한 뒤 호출하는 편이 실행 실패 원인을 줄입니다.

[Unit]
Description=My application service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp

[Install]
WantedBy=multi-user.target

Restart=on-failure는 비정상 종료나 신호에 따른 실패에서만 다시 시작합니다. 정상 종료까지 계속 살려야 하는 상시 서비스라면 Restart=always를 검토할 수 있지만, 작업을 마치고 의도적으로 종료하는 배치 프로그램에는 적합하지 않습니다. RestartSec은 즉시 재기동으로 발생하는 로그 폭주와 외부 의존성 부하를 줄입니다. StartLimitIntervalSec과 StartLimitBurst는 정해진 시간에 실패가 반복되면 재시작을 멈추게 하므로, 무한 재시작 상태를 경보 대상으로 전환할 수 있습니다.

종료 신호와 무중단 배포를 확인한다

systemctl restart는 기존 프로세스에 종료 신호를 보낸 후 새 프로세스를 시작합니다. 서버 코드는 SIGTERM을 받으면 새 요청 수신을 중단하고, 진행 중인 요청과 데이터베이스 연결을 제한 시간 안에 정리해야 합니다. 이를 구현하지 않으면 배포 때 요청이 끊기거나 데이터 처리 중복이 생길 수 있습니다. TimeoutStopSec보다 오래 걸리면 systemd가 강제 종료할 수 있으므로, 실제 종료 시간이 제한 안에 들어오는지 운영 환경에서 검증해야 합니다.

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service
journalctl -u myapp.service -n 100 --no-pager
journalctl -u myapp.service -f

배포 직후에는 status의 Main PID와 최근 종료 코드를 확인하고, 의도적으로 프로세스를 종료해 재시작 정책이 동작하는지 점검합니다. StartLimitBurst에 걸린 경우에는 무작정 reset-failed를 실행하기 전에 애플리케이션 로그, 포트 충돌, 환경 변수 누락, 데이터베이스 연결 실패를 확인합니다. 재시작은 증상을 숨기는 장치가 아니라, 일시적 장애에서 복구 시간을 줄이는 장치입니다.

보안 격리는 단계적으로 적용한다

NoNewPrivileges는 하위 프로세스가 추가 권한을 얻지 못하게 하고, PrivateTmp는 서비스별 임시 디렉터리를 분리합니다. ProtectSystem=strict와 ProtectHome=true는 파일 시스템 접근 범위를 크게 줄입니다. 다만 업로드 디렉터리, 인증서 읽기 경로, 소켓 파일 등 애플리케이션이 필요한 경로를 ReadWritePaths 또는 ReadOnlyPaths에 명시하지 않으면 정상 실행이 실패할 수 있습니다. 처음에는 스테이징 환경에서 적용하고 journal 로그의 권한 오류를 기준으로 최소 범위만 추가합니다.

운영 체크리스트

  • 서비스는 root가 아닌 전용 시스템 계정으로 실행한다.
  • 실행 명령과 환경 파일 경로는 절대 경로로 고정한다.
  • 재시작 조건, 간격, 반복 실패 제한을 함께 설정한다.
  • SIGTERM 종료 처리와 TimeoutStopSec을 실제 배포 흐름에서 검증한다.
  • journald 로그, 종료 코드, 실패 횟수를 모니터링 대상으로 등록한다.
  • 파일 시스템 보호 옵션은 스테이징에서 검증한 뒤 필요한 경로만 예외 처리한다.