LabHub
배우기 러닝패스 코스

FDE 캡스톤: 창고가 같은 주문을 세 번 받았다 · 고객 VM 에 서비스로 설치하기 · 이론

떠 있는 것과 부팅 때 뜨는 것은 다르다

LabHub 에서 이어서 보기

한 줄 요약

"떠 있다" 와 "부팅 때 뜬다" 는 서로 다른 사실이다. 앞의 것은 지금 프로세스가 있다는 뜻이고, 뒤의 것은 systemd 가 부팅 트랜잭션에 그 서비스를 넣도록 enable 이 심링크를 만들어 두었다는 뜻이다. 고객 VM 인계는 뒤의 것을 증명해야 끝난다.

왜 이게 필요했나

현장에서 가장 흔한 설치는 이렇다. 앱을 복사하고, 셸에서 nohup python3 app.py & 를 치고, 브라우저로 열어 보고 "잘 돈다" 고 보고한다. 몇 주 뒤 고객이 보안 패치로 VM 을 재부팅하고, 아무것도 뜨지 않는다. 그 프로세스는 서비스 관리자가 모르는 프로세스였기 때문이다. 죽어도 아무도 되살리지 않고, 부팅 때 아무도 띄우지 않는다. 로그는 nohup.out 어딘가에 있고, 그 파일도 누가 지웠는지 모른다.

systemd 서비스로 올리면 이 질문들에 답이 생긴다. 누가 띄우는가(systemd), 어떤 사용자로(User=), 설정은 어디서 오는가(EnvironmentFile=), 죽으면 어떻게 하나(Restart=), 부팅 때 언제 서는가(WantedBy=·After=), 로그는 어디 있나(journal). FDE 가 고객 VM 에 무언가를 설치한다면 이 여섯 줄이 인계 문서의 뼈대다.

어떻게 동작하나

유닛 파일과 로드된 설정은 다르다. 관리자가 만든 유닛은 /etc/systemd/system/ 에 두고, 이 경로가 배포판 패키지의 /usr/lib/systemd/system/ 보다 우선한다. 그런데 systemd 는 파일을 매번 읽지 않는다. 로드해 둔 설정으로 움직이므로, 이미 로드된 유닛의 파일을 고쳤다면 systemctl daemon-reload 로 다시 읽혀야 한다. 고친 뒤 reload 를 잊으면 systemctl showNeedDaemonReload=yes 로 드러나고, systemctl 이 경고를 낸다.

Wants 와 After 는 서로 대신하지 않는다. systemd.unit 매뉴얼은 요구 의존(Wants=·Requires=)이 시작 순서에 영향을 주지 않으며 순서는 After=·Before= 로 따로 정한다고 못박는다. 그래서 네트워크가 설정된 뒤에 떠야 하는 서비스라면 systemd.special 매뉴얼의 말대로 network-online.target 을 Wants= 로 끌어오고 동시에 After= 로 그 뒤에 선다. 한쪽만 적으면 끌어오기만 하고 순서가 없거나, 순서만 있고 아무도 그 target 을 끌어오지 않는다.

enable 은 시작이 아니라 심링크다. [Install] 섹션은 평소 실행 중에는 해석되지 않는다. systemctl enable 이 WantedBy= 를 읽어 multi-user.target.wants/ 에 심링크를 만들고, 그 심링크가 부팅 때 multi-user.target 에서 이 서비스로 가는 Wants= 의존 역할을 한다. 반대로 systemctl start 는 지금 한 번 띄울 뿐 심링크를 만들지 않는다. 그래서 "떠 있는데 재부팅하면 안 뜬다" 는 거의 언제나 enable 누락이다.

Restart= 의 기본값은 no 다. on-failure 는 0 이 아닌 종료 코드, 신호에 의한 종료, 시간 초과, 워치독에서 재시작한다. 다만 매뉴얼은 SIGHUP·SIGINT·SIGTERM·SIGPIPE 를 깨끗한 종료로 친다. 그래서 kill -9(SIGKILL)로 죽이면 되살아나지만 systemctl stop 은 물론이고 평범한 kill(SIGTERM)도 on-failure 를 부르지 않는다. 재시작 간격 RestartSec= 의 기본은 100ms 이고, 너무 자주 시작하면 StartLimitIntervalSec=·StartLimitBurst= 의 제한에 걸려 더는 시작하지 않는다(reset-failed 로 푼다).

EnvironmentFile 은 프로세스를 띄울 때 읽는다. 매뉴얼은 이 파일들이 프로세스 실행 직전에 읽힌다고 적는다. 줄마다 KEY=VALUE 이고 # 로 시작하는 줄은 무시되며, 파일이 없으면 서비스가 시작에 실패한다(경로 앞에 - 를 붙이면 없어도 넘어간다). 그러니 토큰을 바꿨다면 유닛 파일은 그대로라 daemon-reload 는 필요 없고, 재시작해야 새 프로세스가 새 값을 받는다.

샌드박싱은 점수로 본다. systemd.exec 의 옵션으로 서비스를 가둔다. NoNewPrivileges=yes 는 execve 로 새 권한을 얻지 못하게 하고, ProtectSystem=strict 는 /dev·/proc·/sys 를 뺀 파일 시스템 전체를 읽기 전용으로 붙이며(쓰는 경로는 ReadWritePaths= 로 연다), ProtectHome=yes 는 /home·/root·/run/user 를 비워 보이게 하고, PrivateTmp=yes 는 전용 /tmp 를 준다. systemd-analyze security <유닛> 은 이런 설정을 훑어 0.0 에서 10.0 사이의 노출 수준을 낸다. 높을수록 덜 가둔 것이다. 매뉴얼이 강조하듯 이 점수는 systemd 가 걸어 준 장치만 보므로 "취약하다" 는 판정이 아니라 비교의 잣대다.

[Unit]Wants=network-online.targetAfter=network-online.target[Service]User=ordersEnvironmentFile=/etc/orders/orders.envExecStart=/usr/bin/python3 /opt/orders-svc/app.pyRestart=on-failureProtectSystem=strictReadWritePaths=/var/lib/orders[Install]WantedBy=multi-user.target

현장에서 만나는 모습

실습 VM 에서 이전 담당자의 프로세스를 따라가 보면(실측) /proc/<pid>/cgroup/system.slice/cloud-final.service 로 나온다. 준비 스크립트를 돌린 유닛 안에 얹혀 있을 뿐 그 유닛은 이 앱을 다시 띄울 책임이 없다. 새 서비스를 올려도 첫 기동이 실패하는데, 떠돌이 프로세스가 아직 포트를 잡고 있어서다. systemctl start 는 Type=simple 이라 프로세스를 띄운 순간 성공으로 돌아오므로 원인은 journal 의 bind failed … Address already in use 에서만 보인다. Restart=on-failure 에 RestartSec=1 이면 1초마다 같은 실패를 되풀이하고, journal 에는 Failed with result 'exit-code' 가 줄줄이 쌓인다(실측). 이 상태로 5번을 넘기면 기본 시작 제한(10초에 5번)에 걸린다. 반대로 kill -9status=9/KILL 뒤 1초 만에 새 PID 로 되살아났고, 평범한 kill(SIGTERM)은 Deactivated successfully 로 끝나 inactive 로 남았다(실측).

샌드박싱을 켤 때도 전형적인 순서가 있다. 파일을 고치면 systemctl 이 "Warning: The unit file, source configuration file or drop-ins of orders.service changed on disk. Run 'systemctl daemon-reload' to reload units." 라고 경고한다(실측). reload 하고 재시작하면, ReadWritePaths 를 빠뜨린 경우 앱이 데이터 디렉터리에 못 써서 곧바로 죽는다. 이 실습의 서비스는 User=orders 만 준 상태에서 노출 점수가 9.2 이었고, 위 다섯 옵션을 더하자 8.3 로 내려갔다(실측).

실무에서 진짜 중요한 것

다음 실습에서 할 것

우분투 VM 에서 이전 담당자가 nohup 으로 띄운 앱을 찾아 정체를 기록하고, 전용 계정과 환경 파일을 만든 뒤 유닛을 올린다. 첫 기동 실패의 원인을 journal 에서 찾고, 떠돌이를 내려 서비스로 세우고, enable 이 만든 심링크를 확인한다. kill -9 로 되살아나는지 보고, 샌드박싱을 더해 daemon-reload 경고와 노출 점수 변화를 기록하고, 토큰을 교체해 재시작으로 반영한다. 마지막으로 재부팅 없이 부팅 조건을 판정하는 점검 스크립트를 준비된 유닛들에 돌린다.

참고 문서: [systemd.service(5)](https://man7.org/linux/man-pages/man5/systemd.service.5.html) · [systemd.exec(5)](https://man7.org/linux/man-pages/man5/systemd.exec.5.html) · [systemd.unit(5)](https://man7.org/linux/man-pages/man5/systemd.unit.5.html) · [systemd.special(7)](https://man7.org/linux/man-pages/man7/systemd.special.7.html) · [systemd-analyze(1)](https://man7.org/linux/man-pages/man1/systemd-analyze.1.html)