Skip to content

Commit 2d29c20

Browse files
committed
Remember why a container stopped across an apiserver restart
The exit code lived only in the in-memory ContainerState, so it survived exactly as long as the apiserver process did. Restart the engine and a container that had died with 127 was indistinguishable from one stopped on purpose - which is the moment you most want the answer, since you are usually restarting *because* something went wrong. - ExitRecord is written to exit.json in the container bundle when handleContainerExit sees a code, and read back by loadAtBoot into the snapshot it builds. Bundle already owned this directory's layout, so the read/write pair sits there next to the other bundle files. - A failed write is logged, not thrown: the container really has exited, and refusing to record that would leave the state machine wedged. - ContainerStatus carries exitCode/exitedAt too, so `container inspect` shows them. ManagedContainer is what the CLI serializes, and it was dropping both on the floor.
1 parent 79a8f53 commit 2d29c20

5 files changed

Lines changed: 78 additions & 4 deletions

File tree

Sources/ContainerResource/Container/Bundle.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public struct Bundle: Sendable {
2626
private static let containerRootFsFilename = "rootfs.json"
2727

2828
static let containerConfigFilename = "config.json"
29+
/// Why the container last stopped. Persisted beside the configuration so the
30+
/// answer survives an apiserver restart — in memory only, every stopped
31+
/// container looks alike after a restart.
32+
public static let exitStatusFilename = "exit.json"
2933

3034
/// The path to the bundle.
3135
public let path: URL
@@ -154,6 +158,16 @@ extension Bundle {
154158
try FileManager.default.removeItem(at: self.path)
155159
}
156160

161+
/// The recorded exit of the container's initial process, if it has stopped at least
162+
/// once. Absent for one that has never run.
163+
public var exitStatus: ExitRecord? {
164+
try? load(filename: Self.exitStatusFilename)
165+
}
166+
167+
public func setExitStatus(_ record: ExitRecord) throws {
168+
try write(filename: Self.exitStatusFilename, value: record)
169+
}
170+
157171
public func write(filename: String, value: Encodable) throws {
158172
try Self.write(self.path.appendingPathComponent(filename), value: value)
159173
}

Sources/ContainerResource/Container/ContainerStatus.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,24 @@ public struct ContainerStatus: Codable, Sendable {
2525
public let networks: [Attachment]
2626
/// When the container was started, if it has been.
2727
public let startedDate: Date?
28+
/// The exit code of the container's initial process, once it has exited.
29+
/// `nil` while running, and for containers stopped by an engine that
30+
/// predates the recorded exit status.
31+
public let exitCode: Int32?
32+
/// When the container's initial process exited.
33+
public let exitedAt: Date?
2834

29-
public init(state: RuntimeStatus, networks: [Attachment], startedDate: Date? = nil) {
35+
public init(
36+
state: RuntimeStatus,
37+
networks: [Attachment],
38+
startedDate: Date? = nil,
39+
exitCode: Int32? = nil,
40+
exitedAt: Date? = nil
41+
) {
3042
self.state = state
3143
self.networks = networks
3244
self.startedDate = startedDate
45+
self.exitCode = exitCode
46+
self.exitedAt = exitedAt
3347
}
3448
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
19+
/// How a container's initial process last exited.
20+
///
21+
/// Written to the container's bundle when it stops, so the reason outlives the apiserver.
22+
/// Kept in memory alone, a restart erased it and every stopped container looked identical
23+
/// — including the one that had crashed.
24+
public struct ExitRecord: Codable, Sendable, Equatable {
25+
public let exitCode: Int32
26+
public let exitedAt: Date
27+
28+
public init(exitCode: Int32, exitedAt: Date) {
29+
self.exitCode = exitCode
30+
self.exitedAt = exitedAt
31+
}
32+
}

Sources/ContainerResource/Container/ManagedContainer.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ public struct ManagedContainer: ManagedResource {
6262
self.status = ContainerStatus(
6363
state: snapshot.status,
6464
networks: snapshot.networks,
65-
startedDate: snapshot.startedDate
65+
startedDate: snapshot.startedDate,
66+
exitCode: snapshot.exitCode,
67+
exitedAt: snapshot.exitedAt
6668
)
6769
}
6870
}

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,15 @@ public actor ContainersService {
132132
continue
133133
}
134134

135+
let exit = ContainerResource.Bundle(path: dir).exitStatus
135136
let state = ContainerState(
136137
snapshot: .init(
137138
configuration: config,
138139
status: .stopped,
139140
networks: [],
140-
startedDate: nil
141+
startedDate: nil,
142+
exitCode: exit?.exitCode,
143+
exitedAt: exit?.exitedAt
141144
),
142145
)
143146
results[config.id] = state
@@ -992,10 +995,19 @@ public actor ContainersService {
992995

993996
state.snapshot.status = .stopped
994997
state.snapshot.networks = []
995-
// Keep why it stopped, not just that it did.
998+
// Keep why it stopped, not just that it did — in memory for this apiserver, and
999+
// on disk so the answer survives a restart.
9961000
if let code {
9971001
state.snapshot.exitCode = code.exitCode
9981002
state.snapshot.exitedAt = code.exitedAt
1003+
do {
1004+
try bundle.setExitStatus(
1005+
ExitRecord(exitCode: code.exitCode, exitedAt: code.exitedAt))
1006+
} catch {
1007+
self.log.warning(
1008+
"failed to record exit status",
1009+
metadata: ["id": "\(id)", "error": "\(error)"])
1010+
}
9991011
}
10001012
state.client = nil
10011013
await self.setContainerState(id, state, context: context)

0 commit comments

Comments
 (0)